Skip to content

Commit 1e5c83f

Browse files
committed
feat(ses): hostEvaluators lockdown option
1 parent a7954e9 commit 1e5c83f

12 files changed

+220
-39
lines changed

packages/ses/docs/lockdown.md

+67-1
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 |
@@ -463,7 +465,7 @@ the container to exit explicitly, and we highly recommend setting
463465

464466
## `reporting` Options
465467

466-
**Background**: Lockdown and `repairIntrinsics` report warnings if they
468+
**Background**: `lockdown` and `repairIntrinsics` report warnings if they
467469
encounter unexpected but repairable variations on the shared intrinsics, which
468470
regularly occurs if the version of `ses` predates the introduction of new
469471
language features.
@@ -617,6 +619,70 @@ 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(); // Warning: `SES Please now use the 'hostEvaluators' option. In the future not specifying 'none' will error under strict CSP.`
628+
// or
629+
lockdown({ hostEvaluators: 'all' }); // SES fails to initialize if direct-eval is not the original intrinsic 'eval'
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)
634+
```
635+
636+
Further
637+
638+
* `'all'`: asserts evaluators are allowed to execute
639+
* `'none'`: asserts evaluators are *not* allowed to execute
640+
* `'no-direct'`: asserts direct-eval is not available
641+
642+
Hermes example
643+
644+
```js
645+
lockdown({ evalTaming: 'unsafeEval', hostEvaluators: 'no-direct' }); // Recommended on Hermes
646+
```
647+
648+
However, attempting `"safeEval"` with `"no-direct"` we fail early, due to Hermes' lack of `with` statement
649+
650+
```js
651+
lockdown({ evalTaming: 'safeEval', hostEvaluators: 'no-direct' }); // Fail: `lockdown(): option evalTaming: safeEval is incompatible with hostEvaluators: no-direct`
652+
```
653+
654+
Since Compartments currently evaluate using `safeEval` by default, we throw a descriptive error on attempt to `.evaluate`: `'Compartment evaluation not supported without direct eval.'` However this will only be surfaced once Compartment creation is supported on Hermes under `lockdown` (currently incompatible with `removeUnpermittedIntrinsics`).
655+
656+
For users with a strict CSP, `hostEvaluators` defaulting to `all` is a breaking change and will error (SES_DIRECT_EVAL), so for backward-compatibility we warn instead to set it to `none`.
657+
658+
If `lockdown` does not receive a `hostEvaluators` option, it will respect
659+
`process.env.LOCKDOWN_HOST_EVALUATORS`.
660+
661+
```console
662+
LOCKDOWN_HOST_EVALUATORS=all
663+
LOCKDOWN_HOST_EVALUATORS=none
664+
LOCKDOWN_HOST_EVALUATORS=no-direct
665+
```
666+
667+
Once Hermes engine supports direct-eval, `'no-direct'` will no longer be required.
668+
Currently there is an open feature request and open pull request targeting Static Hermes.
669+
670+
* <https://github.com/facebook/hermes/issues/957>
671+
* <https://github.com/facebook/hermes/pull/1515>
672+
673+
You can also test and verify `lockdown` completing on this change by building and running Hermes on the following fork for example:
674+
<https://github.com/leotm/hermes/tree/ses-lockdown-test-static-hermes-compiler-vm>
675+
676+
Once Hermes engine supports the `with` statement, `evalTaming: 'safeEval'` will be possible.
677+
Currently there is an open feature request and open pull request targeting Static Hermes.
678+
679+
* <https://github.com/facebook/hermes/issues/1056>
680+
* <https://github.com/facebook/hermes/pull/1571>
681+
682+
There is also an open alternate idea to sandbox `Compartment` _without_ the `with` statement.
683+
684+
* <https://github.com/endojs/endo/discussions/1944>
685+
620686
## `stackFiltering` Options
621687

622688
**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/scripts/hermes-test.sh

+3-5
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,9 @@ $HERMESC test/_hermes-smoke-dist.js -emit-binary -out test/_hermes-smoke-dist.hb
4141
echo "Generated: test/_hermes-smoke-dist.hbc"
4242
echo "Hermes compiler done"
4343

44-
# TODO: Disabled until https://github.com/endojs/endo/issues/1891 complete
45-
# echo "Executing generated bytecode file on Hermes VM"
46-
# $HERMES -b test/_hermes-smoke-dist.hbc
47-
# echo "Hermes VM done"
48-
echo "Skipping: Hermes VM"
44+
echo "Executing generated bytecode file on Hermes VM"
45+
$HERMES -b test/_hermes-smoke-dist.hbc
46+
echo "Hermes VM done"
4947

5048
echo "Hermes tests complete"
5149

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

+79-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,35 @@ 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+
noEvaluators &&
283+
Fail`'hostEvaluators' was set to 'all', but the Function() constructor and eval() are not allowed to execute (SES_DIRECT_EVAL)`;
284+
285+
hostEvaluators === 'none' &&
286+
!noEvaluators &&
287+
Fail`'hostEvaluators' was set to 'none', but the Function() constructor and eval() are allowed to execute (SES_DIRECT_EVAL)`;
288+
289+
hostEvaluators === 'no-direct' &&
290+
directEvalAllowed &&
291+
Fail`'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)`;
292+
293+
// TODO: Remove '_legacy' when 'all' introduced as the new default option (breaking change).
294+
// For backwards compatibility under '_legacy', we do not error with a strict CSP, since directEvalAllowed remains undefined.
295+
if (
296+
(hostEvaluators === '_legacy' || hostEvaluators === 'all') &&
297+
directEvalAllowed === false
298+
) {
299+
// See https://github.com/endojs/endo/blob/master/packages/ses/error-codes/SES_DIRECT_EVAL.md
300+
throw TypeError(
301+
"SES cannot initialize unless 'eval' is the original intrinsic 'eval', suitable for direct-eval (dynamically scoped eval) (SES_DIRECT_EVAL)",
302+
);
303+
}
246304

247305
/**
248306
* Because of packagers and bundlers, etc, multiple invocations of lockdown
@@ -406,6 +464,7 @@ export const repairIntrinsics = (options = {}) => {
406464
newGlobalPropertyNames: initialGlobalPropertyNames,
407465
makeCompartmentConstructor,
408466
markVirtualizedNativeFunction,
467+
hostEvaluators,
409468
});
410469

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

0 commit comments

Comments
 (0)