Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ses): hostEvaluators lockdown option #2723

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/ses/docs/lockdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Each option is explained in its own section below.
| `reporting` | `'platform'` | `'console'` `'none'` | where to report warnings ([details](#reporting-options))
| `unhandledRejectionTrapping` | `'report'` | `'none'` | handling of finalized unhandled rejections ([details](#unhandledrejectiontrapping-options)) |
| `evalTaming` | `'safeEval'` | `'unsafeEval'` `'noEval'` | `eval` and `Function` of the start compartment ([details](#evaltaming-options)) |
| `hostEvaluators` | `'all'` | `'none'` `'no-direct'` | handling of sloppy and indirect eval ([details](#hostevaluators-options)) |
| `stackFiltering` | `'concise'` | `'verbose'` | deep stacks signal/noise ([details](#stackfiltering-options)) |
| `overrideTaming` | `'moderate'` | `'min'` or `'severe'` | override mistake antidote ([details](#overridetaming-options)) |
| `overrideDebug` | `[]` | array of property names | detect override mistake ([details](#overridedebug-options)) |
Expand All @@ -51,6 +52,7 @@ for threading environment variables into a JavaScript program.
| `reporting` | `LOCKDOWN_REPORTING` | |
| `unhandledRejectionTrapping` | `LOCKDOWN_UNHANDLED_REJECTION_TRAPPING` | |
| `evalTaming` | `LOCKDOWN_EVAL_TAMING` | |
| `hostEvaluators` | `LOCKDOWN_HOST_EVALUATORS` | |
| `stackFiltering` | `LOCKDOWN_STACK_FILTERING` | |
| `overrideTaming` | `LOCKDOWN_OVERRIDE_TAMING` | |
| `overrideDebug` | `LOCKDOWN_OVERRIDE_DEBUG` | comma separated names |
Expand Down Expand Up @@ -617,6 +619,10 @@ LOCKDOWN_EVAL_TAMING=noEval
LOCKDOWN_EVAL_TAMING=unsafeEval
```

## `hostEvaluators` Options

TODO: Introduced for Hermes.

## `stackFiltering` Options

**Background**: The error stacks shown by many JavaScript engines are
Expand Down
14 changes: 13 additions & 1 deletion packages/ses/error-codes/SES_DIRECT_EVAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,19 @@
The SES Hardened JavaScript shim captures the `eval` function when it is
initialized.
The `eval` function it finds must be the original `eval` because SES uses its
dynamic scope to implement its isolated eval.
dynamic scope to implement its isolated eval.

If you see this error, something running before `ses` initialized, most likely
another instance of `ses`, has replaced `eval` with something else.

If you're running under an environment that doesn't support direct eval (Hermes), try setting `hostEvaluators` to `no-direct`.

If you're running under CSP, try setting `hostEvaluators` to `none`.

# _hostEvaluators_ was set to _none_, but evaluators are not blocked (`SES_DIRECT_EVAL`)

It seems your CSP allows execution of `eval()`, try setting `hostEvaluators` to `all` or `no-direct`.

# `"hostEvaluators" was set to "no-direct", but direct eval is functional

If evaluators are working, if seems you've upgraded host to a version that now supports them (future Hermes).
2 changes: 1 addition & 1 deletion packages/ses/src/global-object.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { constantProperties, universalPropertyNames } from './permits.js';
* guest programs, we cannot emulate the proper behavior.
* With this shim, assigning Symbol.unscopables causes the given lexical
* names to fall through to the terminal scope proxy.
* But, we can install this setter to prevent a program from proceding on
* But, we can install this setter to prevent a program from proceeding on
* this false assumption.
*
* @param {object} globalObject
Expand Down
74 changes: 58 additions & 16 deletions packages/ses/src/lockdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,13 @@ const safeHarden = makeHardener();
// only ever need to be called once and that simplifying lockdown will improve
// the quality of audits.

const assertDirectEvalAvailable = () => {
let allowed = false;
const assertDirectEval = () => {
let directEvalAllowed = false;
let functionAllowed = false;
let evalAllowed = true;
try {
allowed = FERAL_FUNCTION(
functionAllowed = FERAL_FUNCTION('return true')();
directEvalAllowed = FERAL_FUNCTION(
'eval',
'SES_changed',
`\
Expand All @@ -115,21 +118,17 @@ const assertDirectEvalAvailable = () => {
// and indirect, which generally creates a new global.
// We are going to throw an exception for failing to initialize SES, but
// good neighbors clean up.
if (!allowed) {
if (!directEvalAllowed) {
delete globalThis.SES_changed;
}
} catch (_error) {
// We reach here if eval is outright forbidden by a Content Security Policy.
// We allow this for SES usage that delegates the responsibility to isolate
// guest code to production code generation.
allowed = true;
}
if (!allowed) {
// See https://github.com/endojs/endo/blob/master/packages/ses/error-codes/SES_DIRECT_EVAL.md
throw TypeError(
`SES cannot initialize unless 'eval' is the original intrinsic 'eval', suitable for direct-eval (dynamically scoped eval) (SES_DIRECT_EVAL)`,
);
evalAllowed = false;
}

return { directEvalAllowed, functionAllowed, evalAllowed };
};

/**
Expand All @@ -152,11 +151,11 @@ export const repairIntrinsics = (options = {}) => {
// The `stackFiltering` is not a safety issue. Rather it is a tradeoff
// between relevance and completeness of the stack frames shown on the
// console. Setting`stackFiltering` to `'verbose'` applies no filters, providing
// the raw stack frames that can be quite versbose. Setting
// the raw stack frames that can be quite verbose. Setting
// `stackFrameFiltering` to`'concise'` limits the display to the stack frame
// information most likely to be relevant, eliminating distracting frames
// such as those from the infrastructure. However, the bug you're trying to
// track down might be in the infrastrure, in which case the `'verbose'` setting
// track down might be in the infrastructure, in which case the `'verbose'` setting
// is useful. See
// [`stackFiltering` options](https://github.com/Agoric/SES-shim/blob/master/packages/ses/docs/lockdown.md#stackfiltering-options)
// for an explanation.
Expand Down Expand Up @@ -189,6 +188,10 @@ export const repairIntrinsics = (options = {}) => {
/** @param {string} debugName */
debugName => debugName !== '',
),
hostEvaluators = /** @type { 'all' | 'none' | 'no-direct' } */ (
// TODO: Breaking change, ensure backwards compatibility under CSP.
getenv('LOCKDOWN_HOST_EVALUATORS', 'all')
Copy link
Contributor Author

@leotm leotm Feb 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined as default value not currently supported by getEnvironmentOption

),
legacyRegeneratorRuntimeTaming = getenv(
'LOCKDOWN_LEGACY_REGENERATOR_RUNTIME_TAMING',
'safe',
Expand All @@ -208,6 +211,15 @@ export const repairIntrinsics = (options = {}) => {
evalTaming === 'noEval' ||
Fail`lockdown(): non supported option evalTaming: ${q(evalTaming)}`;

hostEvaluators === 'all' ||
hostEvaluators === 'none' ||
hostEvaluators === 'no-direct' ||
Fail`lockdown(): non supported option hostEvaluators: ${q(hostEvaluators)}`;

evalTaming === 'safeEval' &&
hostEvaluators === 'no-direct' &&
Fail`lockdown(): option evalTaming: ${q(evalTaming)} is incompatible with hostEvaluators: ${q(hostEvaluators)}`;

// Assert that only supported options were passed.
// Use Reflect.ownKeys to reject symbol-named properties as well.
const extraOptionsNames = ownKeys(extraOptions);
Expand All @@ -218,13 +230,11 @@ export const repairIntrinsics = (options = {}) => {
const { warn } = reporter;

if (dateTaming !== undefined) {
// eslint-disable-next-line no-console
warn(
`SES The 'dateTaming' option is deprecated and does nothing. In the future specifying it will be an error.`,
);
}
if (mathTaming !== undefined) {
// eslint-disable-next-line no-console
warn(
`SES The 'mathTaming' option is deprecated and does nothing. In the future specifying it will be an error.`,
);
Expand All @@ -242,7 +252,32 @@ export const repairIntrinsics = (options = {}) => {
// trace retained:
priorRepairIntrinsics.stack;

assertDirectEvalAvailable();
const { directEvalAllowed, functionAllowed, evalAllowed } =
assertDirectEval();

// A Content Security Policy containing either a default-src or a script-src directive.
const csp = !evalAllowed && !functionAllowed;

// Assert a regular environment where eval() and the Function() constructor are allowed to execute and direct eval() is not sloppy.
if (hostEvaluators === 'all' && !directEvalAllowed && !csp) {
// See https://github.com/endojs/endo/blob/master/packages/ses/error-codes/SES_DIRECT_EVAL.md
throw TypeError(
"SES cannot initialize unless 'eval' is the original intrinsic 'eval', suitable for direct-eval (dynamically scoped eval) (SES_DIRECT_EVAL)",
);
}

// Assert we in an environment with a CSP that does not allow APIs to execute.
hostEvaluators === 'none' &&
assert(
!directEvalAllowed && csp,
"hostEvaluators' was set to 'none', but the CSP is allowing APIs to execute (SES_DIRECT_EVAL)",
);

hostEvaluators === 'no-direct' &&
assert(
!directEvalAllowed && !csp,
`"hostEvaluators" was set to "no-direct", but ${directEvalAllowed === true ? 'direct eval is functional' : 'evaluators are not allowed to execute'} (SES_DIRECT_EVAL)`,
);

/**
* Because of packagers and bundlers, etc, multiple invocations of lockdown
Expand Down Expand Up @@ -408,6 +443,13 @@ export const repairIntrinsics = (options = {}) => {
markVirtualizedNativeFunction,
});

// TODO: disable compartmentInstance.evaluate instead?
if (hostEvaluators === 'no-direct') {
globalThis.testCompartmentHooks = undefined;
// @ts-ignore Compartment does exist on globalThis
delete globalThis.Compartment;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IF an environment is lacking the direct eval support, we won't be able to provide the compartment evaluation capabilities, but we can have a constructor that creates instances with fresh tamed copies of compartmentInstance.globalThis that we use in bundling (lavamoat's webpack and browserify) and might want to use in Hermes in the future. So I'm not 100% sure if we want to delete Compartment or make evaluation related functionality throw a descriptive error from the implementation that depends on direct eval.

Copy link
Contributor Author

@leotm leotm Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so currently deleting compartment breaks hermes on webpack (repack)? 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and only disabling compartmentInstance.evaluate would be more ideal?
to still be able to construct instances (with fresh tamed copies of compartmentInstance.globalThis)

Copy link
Contributor Author

@leotm leotm Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see, bundling ses without compartment-shim proved difficult, partial compartments seem a better alternative

// Creating a compartment should be possible in no-eval environments
// It also allows more global constants to be captured by the optimizer

}

if (evalTaming === 'noEval') {
setGlobalObjectEvaluators(
globalThis,
Expand Down
10 changes: 6 additions & 4 deletions packages/ses/src/make-eval-function.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/**
* makeEvalFunction()
* A safe version of the native eval function which relies on
* the safety of safeEvaluate for confinement.
* the safety of safeEvaluate for confinement, unless noEval
* is specified (then a TypeError is thrown).
*
* @param {Function} safeEvaluate
* @param {Function} evaluator
* @param legacyHermesTaming

Check warning on line 8 in packages/ses/src/make-eval-function.js

View workflow job for this annotation

GitHub Actions / lint

@param "legacyHermesTaming" does not match an existing function parameter

Check warning on line 8 in packages/ses/src/make-eval-function.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @param "legacyHermesTaming" type
*/
export const makeEvalFunction = safeEvaluate => {
export const makeEvalFunction = evaluator => {
// We use the concise method syntax to create an eval without a
// [[Construct]] behavior (such that the invocation "new eval()" throws
// TypeError: eval is not a constructor"), but which still accepts a
Expand All @@ -19,7 +21,7 @@
// rule. Track.
return source;
}
return safeEvaluate(source);
return evaluator(source);
},
}.eval;

Expand Down
7 changes: 4 additions & 3 deletions packages/ses/src/make-function-constructor.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ const { Fail } = assert;
/*
* makeFunctionConstructor()
* A safe version of the native Function which relies on
* the safety of safeEvaluate for confinement.
* the safety of safeEvaluate for confinement, unless noEval
* is specified (then a TypeError is thrown).
*/
export const makeFunctionConstructor = safeEvaluate => {
export const makeFunctionConstructor = evaluator => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR our conclusion was, as long as we don't want compartmentalization, the indirect eval used for tamed eval and tamed Function would transparently work and we don't need to throw an error when that's used.

Copy link
Contributor Author

@leotm leotm Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was more a refactor since the arg name and JSDoc both assume safeEvaluate which is incorrect (it could be noEvaluate)

Copy link
Contributor Author

@leotm leotm Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regardless of compartmentalization, the (hermes) indirect eval used for tamed eval (string args only) and tamed Function doesn't transparently work, since safeEval eventually calls makeSafeEvaluator, which throws Hermes' ambiguousUncaught SyntaxError: 2:5:invalid statement encountered, so i think it's better to throw an error that safeEval requires an engine that supports with statement

// Define an unused parameter to ensure Function.length === 1
const newFunction = function Function(_body) {
// Sanitize all parameters at the entry point.
Expand Down Expand Up @@ -54,7 +55,7 @@ export const makeFunctionConstructor = safeEvaluate => {
// TODO: since we create an anonymous function, the 'this' value
// isn't bound to the global object as per specs, but set as undefined.
const src = `(function anonymous(${parameters}\n) {\n${bodyText}\n})`;
return safeEvaluate(src);
return evaluator(src);
};

defineProperties(newFunction, {
Expand Down
1 change: 1 addition & 0 deletions packages/ses/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface RepairOptions {
overrideTaming?: 'moderate' | 'min' | 'severe';
overrideDebug?: Array<string>;
domainTaming?: 'safe' | 'unsafe';
hostEvaluators?: 'all' | 'none' | 'unsafe';
/**
* safe (default): do nothing.
*
Expand Down
Loading