Skip to content

Commit 0b339e5

Browse files
committed
feat(ses): hostEvaluators lockdown option
1 parent 59bf360 commit 0b339e5

10 files changed

+172
-32
lines changed

packages/ses/docs/lockdown.md

+29
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Each option is explained in its own section below.
3030
| `reporting` | `'platform'` | `'console'` `'none'` | where to report warnings ([details](#reporting-options))
3131
| `unhandledRejectionTrapping` | `'report'` | `'none'` | handling of finalized unhandled rejections ([details](#unhandledrejectiontrapping-options)) |
3232
| `evalTaming` | `'safeEval'` | `'unsafeEval'` `'noEval'` | `eval` and `Function` of the start compartment ([details](#evaltaming-options)) |
33+
| `hostEvaluators` | `'all'` | `'none'` `'no-direct'` | handling of sloppy and indirect eval ([details](#hostevaluators-options)) |
3334
| `stackFiltering` | `'concise'` | `'verbose'` | deep stacks signal/noise ([details](#stackfiltering-options)) |
3435
| `overrideTaming` | `'moderate'` | `'min'` or `'severe'` | override mistake antidote ([details](#overridetaming-options)) |
3536
| `overrideDebug` | `[]` | array of property names | detect override mistake ([details](#overridedebug-options)) |
@@ -51,6 +52,7 @@ for threading environment variables into a JavaScript program.
5152
| `reporting` | `LOCKDOWN_REPORTING` | |
5253
| `unhandledRejectionTrapping` | `LOCKDOWN_UNHANDLED_REJECTION_TRAPPING` | |
5354
| `evalTaming` | `LOCKDOWN_EVAL_TAMING` | |
55+
| `hostEvaluators` | `LOCKDOWN_HOST_EVALUATORS` | |
5456
| `stackFiltering` | `LOCKDOWN_STACK_FILTERING` | |
5557
| `overrideTaming` | `LOCKDOWN_OVERRIDE_TAMING` | |
5658
| `overrideDebug` | `LOCKDOWN_OVERRIDE_DEBUG` | comma separated names |
@@ -617,6 +619,33 @@ LOCKDOWN_EVAL_TAMING=noEval
617619
LOCKDOWN_EVAL_TAMING=unsafeEval
618620
```
619621

622+
## `hostEvaluators` Options
623+
624+
**Background**: Hermes is a JavaScript engine that does not yet support direct `eval()` nor the `with` statement. The SES `evalTaming` default option `"safeEval"` uses multiple nested `with` statements to create a restricted scope chain, so on Hermes we must run under the `"unsafeEval"` option. However SES cannot initialize unless 'eval' is the original intrinsic 'eval', suitable for direct-eval (dynamically scoped eval), which is where we introduce the `hostEvaluators` option `"no-direct"`.
625+
626+
```js
627+
lockdown(); // Warn user to set `hostEvaluators`, which will soon default to 'all' (breaking change with a strict Content Security Policy)
628+
// or
629+
lockdown({ hostEvaluators: 'all' }); // SES fails to initialize if direct-eval is not functional or evaluators are not allowed to execute
630+
// vs
631+
lockdown({ hostEvaluators: 'none' }); // SES initializes when evaluators are not allowed to execute (e.g. a strict CSP)
632+
// vs
633+
lockdown({ hostEvaluators: 'no-direct' }); // SES initializes without direct-eval (e.g. on Hermes) but does not allow Compartment evaluate
634+
```
635+
636+
```js
637+
lockdown({ evalTaming: 'unsafeEval', hostEvaluators: 'no-direct' }); // Both options required on Hermes to initialize SES
638+
```
639+
640+
If `lockdown` does not receive a `hostEvaluators` option, it will respect
641+
`process.env.LOCKDOWN_HOST_EVALUATORS`.
642+
643+
```console
644+
LOCKDOWN_HOST_EVALUATORS=all
645+
LOCKDOWN_HOST_EVALUATORS=none
646+
LOCKDOWN_HOST_EVALUATORS=no-direct
647+
```
648+
620649
## `stackFiltering` Options
621650

622651
**Background**: The error stacks shown by many JavaScript engines are

packages/ses/error-codes/SES_DIRECT_EVAL.md

+13-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,19 @@
33
The SES Hardened JavaScript shim captures the `eval` function when it is
44
initialized.
55
The `eval` function it finds must be the original `eval` because SES uses its
6-
dynamic scope to implement its isolated eval.
6+
dynamic scope to implement its isolated eval.
77

88
If you see this error, something running before `ses` initialized, most likely
99
another instance of `ses`, has replaced `eval` with something else.
10+
11+
If you're running under an environment that doesn't support direct eval (Hermes), try setting `hostEvaluators` to `no-direct`.
12+
13+
If you're running under CSP, try setting `hostEvaluators` to `none`.
14+
15+
# _hostEvaluators_ was set to _none_, but evaluators are not blocked (`SES_DIRECT_EVAL`)
16+
17+
It seems your CSP allows execution of `eval()`, try setting `hostEvaluators` to `all` or `no-direct`.
18+
19+
# `"hostEvaluators" was set to "no-direct", but direct eval is functional
20+
21+
If evaluators are working, if seems you've upgraded host to a version that now supports them (future Hermes).

packages/ses/src/compartment.js

+20-2
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ defineProperties(InertCompartment, {
213213
* @param {object} [options]
214214
* @param {Compartment} [options.parentCompartment]
215215
* @param {boolean} [options.enforceNew]
216+
* @param {string} [options.hostEvaluators]
216217
* @returns {Compartment['constructor']}
217218
*/
218219

@@ -270,7 +271,12 @@ export const makeCompartmentConstructor = (
270271
targetMakeCompartmentConstructor,
271272
intrinsics,
272273
markVirtualizedNativeFunction,
273-
{ parentCompartment = undefined, enforceNew = false } = {},
274+
// eslint-disable-next-line default-param-last
275+
{
276+
parentCompartment = undefined,
277+
enforceNew = false,
278+
hostEvaluators = undefined,
279+
} = {},
274280
) => {
275281
function Compartment(...args) {
276282
if (enforceNew && new.target === undefined) {
@@ -326,6 +332,18 @@ export const makeCompartmentConstructor = (
326332
sloppyGlobalsMode: false,
327333
});
328334

335+
let evaluator;
336+
337+
if (hostEvaluators === 'no-direct') {
338+
evaluator = () => {
339+
throw TypeError(
340+
'Compartment evaluation not supported without direct eval.',
341+
);
342+
};
343+
} else {
344+
evaluator = safeEvaluate;
345+
}
346+
329347
setGlobalObjectMutableProperties(globalObject, {
330348
intrinsics,
331349
newGlobalPropertyNames: sharedGlobalPropertyNames,
@@ -337,7 +355,7 @@ export const makeCompartmentConstructor = (
337355
// TODO: maybe add evalTaming to the Compartment constructor 3rd options?
338356
setGlobalObjectEvaluators(
339357
globalObject,
340-
safeEvaluate,
358+
evaluator,
341359
markVirtualizedNativeFunction,
342360
);
343361

packages/ses/src/global-object.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { constantProperties, universalPropertyNames } from './permits.js';
1919
* guest programs, we cannot emulate the proper behavior.
2020
* With this shim, assigning Symbol.unscopables causes the given lexical
2121
* names to fall through to the terminal scope proxy.
22-
* But, we can install this setter to prevent a program from proceding on
22+
* But, we can install this setter to prevent a program from proceeding on
2323
* this false assumption.
2424
*
2525
* @param {object} globalObject
@@ -75,6 +75,7 @@ export const setGlobalObjectConstantProperties = globalObject => {
7575
* @param {Function} args.makeCompartmentConstructor
7676
* @param {(object) => void} args.markVirtualizedNativeFunction
7777
* @param {Compartment} [args.parentCompartment]
78+
* @param {string} [args.hostEvaluators]
7879
*/
7980
export const setGlobalObjectMutableProperties = (
8081
globalObject,
@@ -84,6 +85,7 @@ export const setGlobalObjectMutableProperties = (
8485
makeCompartmentConstructor,
8586
markVirtualizedNativeFunction,
8687
parentCompartment,
88+
hostEvaluators,
8789
},
8890
) => {
8991
for (const [name, intrinsicName] of entries(universalPropertyNames)) {
@@ -121,6 +123,7 @@ export const setGlobalObjectMutableProperties = (
121123
parentCompartment,
122124
enforceNew: true,
123125
},
126+
hostEvaluators,
124127
),
125128
);
126129

packages/ses/src/lockdown.js

+85-20
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,30 @@ const safeHarden = makeHardener();
100100
// only ever need to be called once and that simplifying lockdown will improve
101101
// the quality of audits.
102102

103-
const assertDirectEvalAvailable = () => {
104-
let allowed = false;
103+
const probeHostEvaluators = () => {
104+
let functionAllowed;
105105
try {
106-
allowed = FERAL_FUNCTION(
106+
functionAllowed = FERAL_FUNCTION('return true')();
107+
} catch (_error) {
108+
// We reach here if the Function() constructor has not been implemented by the
109+
// host, or is outright forbidden by a Content Security Policy.
110+
functionAllowed = false;
111+
}
112+
113+
let evalAllowed;
114+
try {
115+
evalAllowed = FERAL_EVAL('true');
116+
} catch (_error) {
117+
// We reach here if eval() has not been implemented by the host, or is outright
118+
// forbidden by a Content Security Policy.
119+
// We allow this for SES usage that delegates the responsibility to isolate
120+
// guest code to production code generation.
121+
evalAllowed = false;
122+
}
123+
124+
let directEvalAllowed;
125+
if (functionAllowed && evalAllowed) {
126+
directEvalAllowed = FERAL_FUNCTION(
107127
'eval',
108128
'SES_changed',
109129
`\
@@ -115,21 +135,12 @@ const assertDirectEvalAvailable = () => {
115135
// and indirect, which generally creates a new global.
116136
// We are going to throw an exception for failing to initialize SES, but
117137
// good neighbors clean up.
118-
if (!allowed) {
138+
if (!directEvalAllowed) {
119139
delete globalThis.SES_changed;
120140
}
121-
} catch (_error) {
122-
// We reach here if eval is outright forbidden by a Content Security Policy.
123-
// We allow this for SES usage that delegates the responsibility to isolate
124-
// guest code to production code generation.
125-
allowed = true;
126-
}
127-
if (!allowed) {
128-
// See https://github.com/endojs/endo/blob/master/packages/ses/error-codes/SES_DIRECT_EVAL.md
129-
throw TypeError(
130-
`SES cannot initialize unless 'eval' is the original intrinsic 'eval', suitable for direct-eval (dynamically scoped eval) (SES_DIRECT_EVAL)`,
131-
);
132141
}
142+
143+
return { functionAllowed, evalAllowed, directEvalAllowed };
133144
};
134145

135146
/**
@@ -152,11 +163,11 @@ export const repairIntrinsics = (options = {}) => {
152163
// The `stackFiltering` is not a safety issue. Rather it is a tradeoff
153164
// between relevance and completeness of the stack frames shown on the
154165
// console. Setting`stackFiltering` to `'verbose'` applies no filters, providing
155-
// the raw stack frames that can be quite versbose. Setting
166+
// the raw stack frames that can be quite verbose. Setting
156167
// `stackFrameFiltering` to`'concise'` limits the display to the stack frame
157168
// information most likely to be relevant, eliminating distracting frames
158169
// such as those from the infrastructure. However, the bug you're trying to
159-
// track down might be in the infrastrure, in which case the `'verbose'` setting
170+
// track down might be in the infrastructure, in which case the `'verbose'` setting
160171
// is useful. See
161172
// [`stackFiltering` options](https://github.com/Agoric/SES-shim/blob/master/packages/ses/docs/lockdown.md#stackfiltering-options)
162173
// for an explanation.
@@ -189,6 +200,10 @@ export const repairIntrinsics = (options = {}) => {
189200
/** @param {string} debugName */
190201
debugName => debugName !== '',
191202
),
203+
// TODO: Remove '_legacy' when breaking change introduced (hostEvaluators: 'all' as default), where strict CSPs will require 'none'.
204+
hostEvaluators = /** @type { '_legacy' | 'all' | 'none' | 'no-direct' } */ (
205+
getenv('LOCKDOWN_HOST_EVALUATORS', '_legacy')
206+
),
192207
legacyRegeneratorRuntimeTaming = getenv(
193208
'LOCKDOWN_LEGACY_REGENERATOR_RUNTIME_TAMING',
194209
'safe',
@@ -208,6 +223,17 @@ export const repairIntrinsics = (options = {}) => {
208223
evalTaming === 'noEval' ||
209224
Fail`lockdown(): non supported option evalTaming: ${q(evalTaming)}`;
210225

226+
// TODO: Remove '_legacy' when breaking change introduced (hostEvaluators: 'all' as default), where strict CSPs will require 'none'.
227+
hostEvaluators === '_legacy' ||
228+
hostEvaluators === 'all' ||
229+
hostEvaluators === 'none' ||
230+
hostEvaluators === 'no-direct' ||
231+
Fail`lockdown(): non supported option hostEvaluators: ${q(hostEvaluators)}`;
232+
233+
evalTaming === 'safeEval' &&
234+
hostEvaluators === 'no-direct' &&
235+
Fail`lockdown(): option evalTaming: ${q(evalTaming)} is incompatible with hostEvaluators: ${q(hostEvaluators)}`;
236+
211237
// Assert that only supported options were passed.
212238
// Use Reflect.ownKeys to reject symbol-named properties as well.
213239
const extraOptionsNames = ownKeys(extraOptions);
@@ -218,17 +244,21 @@ export const repairIntrinsics = (options = {}) => {
218244
const { warn } = reporter;
219245

220246
if (dateTaming !== undefined) {
221-
// eslint-disable-next-line no-console
222247
warn(
223248
`SES The 'dateTaming' option is deprecated and does nothing. In the future specifying it will be an error.`,
224249
);
225250
}
226251
if (mathTaming !== undefined) {
227-
// eslint-disable-next-line no-console
228252
warn(
229253
`SES The 'mathTaming' option is deprecated and does nothing. In the future specifying it will be an error.`,
230254
);
231255
}
256+
if (hostEvaluators === '_legacy') {
257+
// TODO: Remove when 'all' introduced as the new default option (breaking change).
258+
warn(
259+
`SES Please now use the 'hostEvaluators' option. In the future not specifying 'none' will error under strict CSP.`,
260+
);
261+
}
232262

233263
priorRepairIntrinsics === undefined ||
234264
// eslint-disable-next-line @endo/no-polymorphic-call
@@ -242,7 +272,41 @@ export const repairIntrinsics = (options = {}) => {
242272
// trace retained:
243273
priorRepairIntrinsics.stack;
244274

245-
assertDirectEvalAvailable();
275+
const { functionAllowed, evalAllowed, directEvalAllowed } =
276+
probeHostEvaluators();
277+
278+
// This could be a strict Content Security Policy containing either a `default-src` or a `script-src` directive, or an ES host with broken APIs.
279+
const noEvaluators = !evalAllowed && !functionAllowed; // eval() itself and the Function() constructor are not allowed to execute.
280+
281+
hostEvaluators === 'all' &&
282+
assert(
283+
!noEvaluators,
284+
"'hostEvaluators' was set to 'all', but the Function() constructor and eval() are not allowed to execute (SES_DIRECT_EVAL)",
285+
);
286+
287+
hostEvaluators === 'none' &&
288+
assert(
289+
noEvaluators,
290+
"'hostEvaluators' was set to 'none', but the Function() constructor and eval() are allowed to execute (SES_DIRECT_EVAL)",
291+
);
292+
293+
hostEvaluators === 'no-direct' &&
294+
assert(
295+
!directEvalAllowed,
296+
`'hostEvaluators' was set to 'no-direct', but ${directEvalAllowed === true ? 'direct eval is functional' : 'the Function() constructor and eval() are not allowed to execute'} (SES_DIRECT_EVAL)`,
297+
);
298+
299+
// TODO: Remove '_legacy' when 'all' introduced as the new default option (breaking change).
300+
// For backwards compatibility under '_legacy', we do not error with a strict CSP, since directEvalAllowed remains undefined.
301+
if (
302+
(hostEvaluators === '_legacy' || hostEvaluators === 'all') &&
303+
directEvalAllowed === false
304+
) {
305+
// See https://github.com/endojs/endo/blob/master/packages/ses/error-codes/SES_DIRECT_EVAL.md
306+
throw TypeError(
307+
"SES cannot initialize unless 'eval' is the original intrinsic 'eval', suitable for direct-eval (dynamically scoped eval) (SES_DIRECT_EVAL)",
308+
);
309+
}
246310

247311
/**
248312
* Because of packagers and bundlers, etc, multiple invocations of lockdown
@@ -406,6 +470,7 @@ export const repairIntrinsics = (options = {}) => {
406470
newGlobalPropertyNames: initialGlobalPropertyNames,
407471
makeCompartmentConstructor,
408472
markVirtualizedNativeFunction,
473+
hostEvaluators,
409474
});
410475

411476
if (evalTaming === 'noEval') {

packages/ses/src/make-eval-function.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/**
22
* makeEvalFunction()
33
* A safe version of the native eval function which relies on
4-
* the safety of safeEvaluate for confinement.
4+
* the safety of safeEvaluate for confinement, unless noEval
5+
* is specified (then a TypeError is thrown).
56
*
6-
* @param {Function} safeEvaluate
7+
* @param {Function} evaluator
8+
* @param legacyHermesTaming
79
*/
8-
export const makeEvalFunction = safeEvaluate => {
10+
export const makeEvalFunction = evaluator => {
911
// We use the concise method syntax to create an eval without a
1012
// [[Construct]] behavior (such that the invocation "new eval()" throws
1113
// TypeError: eval is not a constructor"), but which still accepts a
@@ -19,7 +21,7 @@ export const makeEvalFunction = safeEvaluate => {
1921
// rule. Track.
2022
return source;
2123
}
22-
return safeEvaluate(source);
24+
return evaluator(source);
2325
},
2426
}.eval;
2527

packages/ses/src/make-function-constructor.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ const { Fail } = assert;
1212
/*
1313
* makeFunctionConstructor()
1414
* A safe version of the native Function which relies on
15-
* the safety of safeEvaluate for confinement.
15+
* the safety of safeEvaluate for confinement, unless noEval
16+
* is specified (then a TypeError is thrown).
1617
*/
17-
export const makeFunctionConstructor = safeEvaluate => {
18+
export const makeFunctionConstructor = evaluator => {
1819
// Define an unused parameter to ensure Function.length === 1
1920
const newFunction = function Function(_body) {
2021
// Sanitize all parameters at the entry point.
@@ -54,7 +55,7 @@ export const makeFunctionConstructor = safeEvaluate => {
5455
// TODO: since we create an anonymous function, the 'this' value
5556
// isn't bound to the global object as per specs, but set as undefined.
5657
const src = `(function anonymous(${parameters}\n) {\n${bodyText}\n})`;
57-
return safeEvaluate(src);
58+
return evaluator(src);
5859
};
5960

6061
defineProperties(newFunction, {
@@ -72,7 +73,7 @@ export const makeFunctionConstructor = safeEvaluate => {
7273
getPrototypeOf(FERAL_FUNCTION) === FERAL_FUNCTION.prototype ||
7374
Fail`Function prototype is the same accross compartments`;
7475
getPrototypeOf(newFunction) === FERAL_FUNCTION.prototype ||
75-
Fail`Function constructor prototype is the same accross compartments`;
76+
Fail`Function constructor prototype is the same across compartments`;
7677

7778
return newFunction;
7879
};

packages/ses/test/error/permit-removal-warnings-node.test.js

+5
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ test('node reporting to stderr with indented group', async t => {
3838
const stderrText = new TextDecoder().decode(stderrBytes);
3939
const stderrLines = stderrText.trim().split('\n');
4040

41+
// Group label for removing unpermitted intrinsics
42+
t.is(
43+
stderrLines.shift(),
44+
"SES Please now use the 'hostEvaluators' option. In the future not specifying 'none' will error under strict CSP.",
45+
);
4146
// Group label for removing unpermitted intrinsics
4247
t.is(stderrLines.shift(), 'SES Removing unpermitted intrinsics');
4348
// And all remaining lines have exactly a two space indent

0 commit comments

Comments
 (0)