feat(ses): Censorship error messages may now contain the source name
ADD
packages/ses/ERRORS.md
+
+## SES1: Possible eval expression reject
+
+The SES shim cannot virtualize dynamic eval.
+
+```js
+const line = await console.readLine();
+const value = eval(line); // Whoa!
+```
+
+JavaScript interprets a direct eval, as in `eval(line)` as an operator, not a
+function call.
+This is necessary to give the code read and write access to the lexical scope
+of the caller, a *dynamic scope*.
+
+```js
+'use strict';
+let meaning;
+eval('meaning = 42');
+console.log(meaning); // 42
+```
+
+In sloppy mode, direct eval can also configure the caller scope with new
+variables.
+
+```js
+eval('var meaning = 42');
+console.log(meaning); // 42
+```
+
+Because this kind of `eval` is syntax, the SES shim has no way to intercept and
+sandbox these expressions.
+If client code could perform a direct `eval`, they could escape the SES
+sandbox.
+
+The SES shim could perform a full lexical analysis of the source to forbid
+direct `eval`, but that would entrain a very large and volatile dependency in
+the runtime.
+Instead, the SES shim forbids any substring of the source containing what could
+be a direct eval.
+
+The SES shim does support *indirect eval*.
+When client code captures `eval` in an expression, they get a reference
+to a global `eval` function.
+The SES shim is able to intercept the `eval` function in lexical scope and
+provide a sandboxed version instead.
+
+```js
+globalThis.meaning = 42;
+const globalEval = eval;
+globalEval('meaning'); // 42
+```
+
+So, to evade censorship of *direct eval*, you may be able to use an `eval`
+expression, if you do not depend on the ability to reach dynamic scope.
+
+```js
+// Before
+eval(code);
+// After
+(0, eval)(code);
+```
+
+## SES1: Possible import expression rejected
+
+Calling `import` as function, like direct `eval`, is JavaScript syntax
+and doesn't depend on `import` being in the lexical scope.
+Since the SES shim cannot intercept access to `import` in lexical scope,
+the SES shim forbids any text that appears to call `import`, even in a comment,
+even as a method of another object.
+
+Client code can be refactored to avoid `import` censorship, with many
+techniques.
+
+To call `import` in lexical scope, capture it in an expression.
+
+```js
+const globalImport = import;
+globalImport(specifier);
+```
+
+```js
+(0, import)(specifier);
+```
+
+At time of writing, the SES shim does not implement dynamic import in a
+compartment, even in lexical scope, but compartments implement a similar
+`import` method.
+
+Import methods can be called using a computed property.
+
+```js
+compartment['import'](specifier);
+```
+
+Using parentheses could also work, but unnecessary parentheses are
+unlikely to survive a round trip through a minifier, so be sure to
+also use a comma expression.
+
+```js
+(0, compartment.import)(specifier);
+```
+
+## SES2: Possible HTML comment rejected
+
+When web browsers first introduced `<script>` tags and JavaScript, earlier
+versions of the browser would how the script instead of running it.
+To facilitate the transition, JavaScript tolerated HTML comments in
+its own grammar, such that a script could be embedded and would
+be ignored in legacy browsers.
+
+```js
+<script><!--
+alert("There wasn't a console.");
+// --></script>
+```
+
+HTML comments are still valid JavaScript, and in combination with dynamic eval
+or dynamic import, could be used to escape the SES language sandbox.
+
+A SES machine with a full lexer or parser for the language could handle these
+comments safely.
+But, in order to avoid entraining a large dependency in the SES shim, the shim
+instead forbids the HTML comment tokens anywhere in the text.
+
+To manually evade this censorship, the substrings `<!--` and `-->` must be
+avoided.
+
+```js
+// <!- SES forbids HTML comments, even in comments ->
+const commentStart = "<!" + "--";
+```

MODIFY
packages/ses/NEWS.md
packages/ses/NEWS.md
@@ -2,7 +2,12 @@ User-visible changes in SES:
## Next release
-* No changes yet.
+* Modules and evaluated code that contains the censored substrings
+ for dynamic eval, dynamic import, and HTML comments will now
+ throw errors that contain the name of the originating source or merely
+ `<unknown>`.
+ The `compartment.evaluate` method gains a `name` option to thread
+ the name of the evaluated source for these errors.
## Release 0.11.0 (3-November-2020)

MODIFY
packages/ses/src/compartment-shim.js
packages/ses/src/compartment-shim.js
@@ -48,6 +48,7 @@ export const CompartmentPrototype = {
transforms = [],
sloppyGlobalsMode = false,
__moduleShimLexicals__ = undefined,
+ name = '<unknown>',
} = options;
const localTransforms = [...transforms];
@@ -78,6 +79,7 @@ export const CompartmentPrototype = {
globalTransforms,
localTransforms,
sloppyGlobalsMode,
+ name,
});
},

MODIFY
packages/ses/src/evaluate.js
packages/ses/src/evaluate.js
@@ -23,15 +23,16 @@ export function performEval(
localTransforms = [],
globalTransforms = [],
sloppyGlobalsMode = false,
+ name = '<unknown>',
} = {},
) {
// Execute the mandatory transforms last to ensure that any rewritten code
// meets those mandatory requirements.
- source = applyTransforms(source, [
- ...localTransforms,
- ...globalTransforms,
- mandatoryTransforms,
- ]);
+ source = applyTransforms(
+ source,
+ [...localTransforms, ...globalTransforms, mandatoryTransforms],
+ name,
+ );
const scopeHandler = createScopeHandler(globalObject, localObject, {
sloppyGlobalsMode,

MODIFY
packages/ses/src/module-instance.js
packages/ses/src/module-instance.js
@@ -25,7 +25,7 @@ export const makeModuleInstance = (
liveExportMap,
exportAlls,
} = moduleAnalysis;
- const { compartment, moduleSpecifier } = moduleRecord;
+ const { compartment, moduleSpecifier, moduleLocation } = moduleRecord;
const compartmentFields = privateFields.get(compartment);
@@ -322,6 +322,7 @@ export const makeModuleInstance = (
globalObject,
transforms: __shimTransforms__,
__moduleShimLexicals__: localLexicals,
+ name: moduleLocation,
});
let didThrow = false;
let thrownError;

MODIFY
packages/ses/src/transforms.js
packages/ses/src/transforms.js
@@ -32,13 +32,13 @@ function getLineNumber(src, pattern) {
const htmlCommentPattern = new RegExp(`(?:${'<'}!--|--${'>'})`);
-export function rejectHtmlComments(src) {
- const linenum = getLineNumber(src, htmlCommentPattern);
- if (linenum < 0) {
+export function rejectHtmlComments(src, name) {
+ const lineNumber = getLineNumber(src, htmlCommentPattern);
+ if (lineNumber < 0) {
return src;
}
throw new SyntaxError(
- `possible html comment syntax rejected around line ${linenum}`,
+ `SES3: Possible HTML comment rejected at ${name}:${lineNumber}`,
);
}
@@ -66,13 +66,13 @@ export function rejectHtmlComments(src) {
const importPattern = new RegExp('\\bimport\\s*(?:\\(|/[/*])');
-export function rejectImportExpressions(src) {
- const linenum = getLineNumber(src, importPattern);
- if (linenum < 0) {
+export function rejectImportExpressions(src, name) {
+ const lineNumber = getLineNumber(src, importPattern);
+ if (lineNumber < 0) {
return src;
}
throw new SyntaxError(
- `possible import expression rejected around line ${linenum}`,
+ `SES2: Possible import expression rejected at ${name}:${lineNumber}`,
);
}
@@ -96,26 +96,26 @@ export function rejectImportExpressions(src) {
const someDirectEvalPattern = new RegExp('\\beval\\s*(?:\\(|/[/*])');
// Exported for unit tests.
-export function rejectSomeDirectEvalExpressions(src) {
- const linenum = getLineNumber(src, someDirectEvalPattern);
- if (linenum < 0) {
+export function rejectSomeDirectEvalExpressions(src, name) {
+ const lineNumber = getLineNumber(src, someDirectEvalPattern);
+ if (lineNumber < 0) {
return src;
}
throw new SyntaxError(
- `possible direct eval expression rejected around line ${linenum}`,
+ `SES1: Possible direct eval expression rejected at ${name}:${lineNumber}`,
);
}
-export function mandatoryTransforms(source) {
- source = rejectHtmlComments(source);
- source = rejectImportExpressions(source);
- source = rejectSomeDirectEvalExpressions(source);
+export function mandatoryTransforms(source, name) {
+ source = rejectHtmlComments(source, name);
+ source = rejectImportExpressions(source, name);
+ source = rejectSomeDirectEvalExpressions(source, name);
return source;
}
-export function applyTransforms(source, transforms) {
+export function applyTransforms(source, transforms, name) {
for (const transform of transforms) {
- source = transform(source);
+ source = transform(source, name);
}
return source;
}

MODIFY
packages/ses/test/reject-direct-eval.test.js
packages/ses/test/reject-direct-eval.test.js
@@ -104,3 +104,32 @@ test('reject direct eval expressions in Function', t => {
sinon.restore();
});
+
+test('reject direct eval expressions with name', t => {
+ t.plan(2);
+
+ const c = new Compartment();
+ const code = 'eval("evil")';
+
+ t.throws(
+ () => c.evaluate(code),
+ {
+ name: 'SyntaxError',
+ message: 'SES1: Possible direct eval expression rejected at <unknown>:1',
+ },
+ 'newline with name',
+ );
+
+ t.throws(
+ () =>
+ c.evaluate(code, {
+ name: 'contrived://example',
+ }),
+ {
+ name: 'SyntaxError',
+ message:
+ 'SES1: Possible direct eval expression rejected at contrived://example:1',
+ },
+ 'newline with name',
+ );
+});

MODIFY
packages/ses/test/reject-html-comment.test.js
packages/ses/test/reject-html-comment.test.js
@@ -5,7 +5,7 @@ import stubFunctionConstructors from './stub-function-constructors.js';
const { test } = tap;
-test('reject html comment expressions in evaluate', t => {
+test('reject HTML comment expressions in evaluate', t => {
t.plan(6);
// Mimic repairFunctions.
@@ -64,7 +64,7 @@ test('reject html comment expressions in evaluate', t => {
sinon.restore();
});
-test('reject html comment expressions in Function', t => {
+test('reject HTML comment expressions in Function', t => {
t.plan(6);
// Mimic repairFunctions.
@@ -118,3 +118,31 @@ test('reject html comment expressions in Function', t => {
sinon.restore();
});
+
+test('reject HTML comment expressions with name', t => {
+ t.plan(2);
+
+ const c = new Compartment();
+ const code = '<!-- -->';
+
+ t.throws(
+ () => c.evaluate(code),
+ {
+ name: 'SyntaxError',
+ message: 'SES3: Possible HTML comment rejected at <unknown>:1',
+ },
+ 'htmlCloseComment without name',
+ );
+
+ t.throws(
+ () =>
+ c.evaluate(code, {
+ name: 'bogus://contrived',
+ }),
+ {
+ name: 'SyntaxError',
+ message: 'SES3: Possible HTML comment rejected at bogus://contrived:1',
+ },
+ 'htmlCloseComment with name',
+ );
+});

MODIFY
packages/ses/test/reject-import-expression.test.js
packages/ses/test/reject-import-expression.test.js
@@ -88,3 +88,30 @@ test('reject import expressions in Function', t => {
sinon.restore();
});
+
+test('reject import expressions with error messages', t => {
+ t.plan(2);
+
+ const c = new Compartment();
+ const code = 'import("attack?exfiltration")';
+ t.throws(
+ () => c.evaluate(code),
+ {
+ name: 'SyntaxError',
+ message: 'SES2: Possible import expression rejected at <unknown>:1',
+ },
+ 'without name',
+ );
+
+ t.throws(
+ () =>
+ c.evaluate(code, {
+ name: 'never://land',
+ }),
+ {
+ name: 'SyntaxError',
+ message: 'SES2: Possible import expression rejected at never://land:1',
+ },
+ 'with name',
+ );
+});

feat(ses): Censorship error messages may now contain the source name (#515)