Skip to content

Commit a675d8e

Browse files
authored
fix(ses): enablements grow monotonically (#3129)
Closes: #XXXX Refs: #XXXX ## Description Problem originally reported by @boneskull . `overrideTaming: 'moderate'` includes `overrideTaming: 'min'`. Previously `overrideTaming: 'min'` correctly enabled `Iterator.prototype.constructor` to be overridden by assignment, but due to an oversight, `overrideTaming: 'moderate'` did not. Now it does. To make such mistakes less likely, this PR also adopts a style where all records within larger enablements triple-dot the corresponding record from a smaller enablement, if present. ### Security Considerations Beyond fixing an observable bug, none. Everything is equally safe before and after this PR. ### Scaling Considerations none ### Documentation Considerations none ### Testing Considerations Tested. Tests also modified to follow enablements being tested. New test that the larger enablements are relaxations (as defined in packages/ses/test/enable-property-overrides-relaxation.test.js) of smaller enablements. ### Compatibility Considerations Iterator appears starting in Node 22. In my first attempt, tests broke in Node 22 because of this. ### Upgrade Considerations none.
1 parent 05cdb5f commit a675d8e

File tree

6 files changed

+170
-32
lines changed

6 files changed

+170
-32
lines changed

.changeset/yummy-mangos-behave.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'ses': minor
3+
---
4+
5+
`overrideTaming: 'moderate'` includes `overrideTaming: 'min'`.
6+
7+
Previously `overrideTaming: 'min'` correctly enabled `Iterator.prototype.constructor` to be overridden by assignment, but due to an oversight, `overrideTaming: 'moderate'` did not. Now it does.
8+
9+
To make such mistakes less likely, this PR also adopts a style where all records within larger enablements triple-dot the corresponding record from a smaller enablement, if present.

packages/ses/src/enablements.js

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -95,38 +95,39 @@ export const moderateEnablements = {
9595
...minEnablements,
9696

9797
'%ObjectPrototype%': {
98-
toString: true,
98+
...minEnablements['%ObjectPrototype%'],
9999
valueOf: true,
100100
},
101101

102-
'%ArrayPrototype%': {
103-
toString: true,
104-
push: true, // set by "Google Analytics"
105-
concat: true, // set by mobx generated code (old TS compiler?)
106-
[iteratorSymbol]: true, // set by mobx generated code (old TS compiler?)
107-
},
108-
109-
'%IteratorPrototype%': {
110-
[iteratorSymbol]: true, // is sometimes used in custom iterators and generators implementations eg. @rive-app/canvas
111-
},
112-
113102
// Function.prototype has no 'prototype' property to enable.
114103
// Function instances have their own 'name' and 'length' properties
115104
// which are configurable and non-writable. Thus, they are already
116105
// non-assignable anyway.
117106
'%FunctionPrototype%': {
107+
...minEnablements['%FunctionPrototype%'],
118108
constructor: true, // set by "regenerator-runtime"
119109
bind: true, // set by "underscore", "express"
120-
toString: true, // set by "rollup"
121110
},
122111

123112
'%ErrorPrototype%': {
113+
...minEnablements['%ErrorPrototype%'],
124114
constructor: true, // set by "fast-json-patch", "node-fetch"
125115
message: true,
126-
name: true, // set by "precond", "ava", "node-fetch", "node 14"
127116
toString: true, // set by "bluebird"
128117
},
129118

119+
'%IteratorPrototype%': {
120+
...minEnablements['%IteratorPrototype%'],
121+
[iteratorSymbol]: true, // is sometimes used in custom iterators and generators implementations eg. @rive-app/canvas
122+
},
123+
124+
'%ArrayPrototype%': {
125+
toString: true,
126+
push: true, // set by "Google Analytics"
127+
concat: true, // set by mobx generated code (old TS compiler?)
128+
[iteratorSymbol]: true, // set by mobx generated code (old TS compiler?)
129+
},
130+
130131
'%TypeErrorPrototype%': {
131132
constructor: true, // set by "readable-stream"
132133
message: true, // set by "tape"

packages/ses/test/enable-property-overrides-default-unsafeError.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ lockdown({
1010

1111
test('property overrides default with unsafe errorTaming', t => {
1212
overrideTester(t, 'Error', Error(), [
13+
'name',
14+
'stack',
1315
'constructor',
1416
'message',
15-
'name',
1617
'toString',
17-
'stack',
1818
]);
1919
overrideTester(t, 'TypeError', TypeError(), [
2020
'constructor',

packages/ses/test/enable-property-overrides-default.test.js

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,35 @@ lockdown({
1010

1111
test('enablePropertyOverrides - on', t => {
1212
overrideTester(t, 'Object', {}, ['toString', 'valueOf']);
13-
// We allow 'length' *not* because it is in enablements; it is not;
14-
// but because each array instance has its own.
15-
overrideTester(
16-
t,
17-
'Array',
18-
[],
19-
['toString', 'length', 'push', 'concat', Symbol.iterator],
20-
);
2113
// eslint-disable-next-line func-names, prefer-arrow-callback
2214
overrideTester(t, 'Function', function () {}, [
15+
'toString',
2316
'constructor',
2417
'bind',
25-
'toString',
2618
]);
2719
overrideTester(t, 'Error', Error(), [
20+
'name',
21+
'stack',
2822
'constructor',
2923
'message',
30-
'name',
3124
'toString',
32-
'stack',
3325
]);
26+
if (typeof Iterator !== 'undefined') {
27+
overrideTester(t, 'Iterator', Object.create(Iterator.prototype), [
28+
'toString',
29+
'constructor',
30+
Symbol.toStringTag,
31+
Symbol.iterator,
32+
]);
33+
}
34+
// We allow 'length' *not* because it is in enablements; it is not;
35+
// but because each array instance has its own.
36+
overrideTester(
37+
t,
38+
'Array',
39+
[],
40+
['toString', 'length', 'push', 'concat', Symbol.iterator],
41+
);
3442
overrideTester(t, 'TypeError', TypeError(), [
3543
'constructor',
3644
'message',
@@ -39,9 +47,4 @@ test('enablePropertyOverrides - on', t => {
3947
// eslint-disable-next-line func-names, prefer-arrow-callback
4048
overrideTester(t, 'Promise', new Promise(function () {}), ['constructor']);
4149
overrideTester(t, 'JSON', JSON);
42-
if (typeof Iterator !== 'undefined') {
43-
overrideTester(t, 'Iterator', Object.create(Iterator.prototype), [
44-
Symbol.iterator,
45-
]);
46-
}
4750
});

packages/ses/test/enable-property-overrides-min.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,15 @@ test('enablePropertyOverrides - on', t => {
1212
overrideTester(t, 'Object', {}, ['toString']);
1313
overrideTester(t, 'Function', () => {}, ['toString']);
1414
overrideTester(t, 'Error', Error(), ['name', 'stack']);
15+
if (typeof Iterator !== 'undefined') {
16+
overrideTester(t, 'Iterator', Object.create(Iterator.prototype), [
17+
'toString',
18+
'constructor',
19+
Symbol.toStringTag,
20+
]);
21+
}
22+
23+
// We allow 'length' *not* because it is in enablements; it is not;
24+
// but because each array instance has its own.
25+
overrideTester(t, 'Array', [], ['length']);
1526
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/* eslint-disable no-nested-ternary */
2+
3+
import '../index.js';
4+
import test from 'ava';
5+
import {
6+
minEnablements,
7+
moderateEnablements,
8+
severeEnablements,
9+
} from '../src/enablements.js';
10+
11+
/**
12+
* @import {ExecutionContext} from 'ava';
13+
*/
14+
15+
/**
16+
* Matches any non-empty string that consists exclusively of ASCII
17+
* letter/digit/underscore/percent sign and does not start with a digit.
18+
* Percent sign is allowed for unnamed primordials such as `%ObjectPrototype%`.
19+
*/
20+
const identifierLikePatt = /^[a-zA-Z_%][a-zA-Z_%0-9]*$/i;
21+
22+
const builtinSymbols = /** @type {Map<symbol, string>} */ (
23+
new Map(
24+
Reflect.ownKeys(Symbol).flatMap(key => {
25+
const value = Symbol[key];
26+
return typeof value === 'symbol' ? [[value, key]] : [];
27+
}),
28+
)
29+
);
30+
31+
/** @type {(symbol: symbol) => string} */
32+
const stringifySymbol = symbol => {
33+
const builtinSymbolName = builtinSymbols.get(symbol);
34+
if (builtinSymbolName) {
35+
return builtinSymbolName.match(identifierLikePatt)
36+
? `Symbol.${builtinSymbolName}`
37+
: `Symbol[${JSON.stringify(builtinSymbolName)}]`;
38+
}
39+
const key = Symbol.keyFor(symbol);
40+
return key !== undefined
41+
? `Symbol.for(${JSON.stringify(key)})`
42+
: `Symbol(${JSON.stringify(symbol.description)})`;
43+
};
44+
45+
/**
46+
* Assert that some enablement value is a valid relaxation of a base enablement.
47+
* `true` may be relaxed only to `true`, "*" may be relaxed to `true` or "*",
48+
* and a base record may be relaxed to `true`, "*", or a record that includes a
49+
* superset of the base properties in which each property of the relaxation is
50+
* either absent from the base element or is (recursively) a valid
51+
* relaxation of the corresponding base enablement.
52+
*
53+
* @param {ExecutionContext} t
54+
* @param {any} base
55+
* @param {any} relaxation
56+
* @param {string} [path]
57+
*/
58+
const assertEnablementsRelaxation = (t, base, relaxation, path = '') => {
59+
// Relaxing to `true` is always acceptable.
60+
if (relaxation === true) return;
61+
62+
// Otherwise, relaxation must either preserve `true`/"*" or be recursively
63+
// acceptable.
64+
if (base === true || base === '*') {
65+
t.is(
66+
relaxation,
67+
base,
68+
`relaxation must preserve ${JSON.stringify(base)} at ${path || 'top-level'}`,
69+
);
70+
return;
71+
}
72+
73+
t.is(
74+
base === null ? 'null' : typeof base,
75+
'object',
76+
`base enablement at ${path || 'top-level'} must be \`true\`, "*", or a record`,
77+
);
78+
if (relaxation === '*') return;
79+
t.is(
80+
relaxation === null ? 'null' : typeof relaxation,
81+
'object',
82+
`relaxed enablement at ${path || 'top-level'} must be \`true\`, "*", or a record`,
83+
);
84+
85+
const baseKeys = Reflect.ownKeys(base);
86+
const relaxationKeys = Reflect.ownKeys(relaxation);
87+
const missingKeys = baseKeys.filter(k => !relaxationKeys.includes(k));
88+
t.deepEqual(
89+
missingKeys,
90+
[],
91+
`relaxation must not omit base properties at ${path || 'top-level'}`,
92+
);
93+
for (const key of baseKeys) {
94+
const pathSuffix =
95+
typeof key === 'symbol'
96+
? `[${stringifySymbol(key)}]`
97+
: key.match(identifierLikePatt)
98+
? `.${key}`
99+
: `[${JSON.stringify(key)}]`;
100+
const subPath = `${path}${pathSuffix}`.replace(/^[.]/, '');
101+
const relaxationEnablement = Object.hasOwn(relaxation, key)
102+
? relaxation[key]
103+
: undefined;
104+
assertEnablementsRelaxation(t, base[key], relaxationEnablement, subPath);
105+
}
106+
};
107+
108+
test('moderateEnablements relaxes minEnablements', t => {
109+
assertEnablementsRelaxation(t, minEnablements, moderateEnablements);
110+
});
111+
112+
test('severeEnablements relaxes moderateEnablements', t => {
113+
assertEnablementsRelaxation(t, moderateEnablements, severeEnablements);
114+
});

0 commit comments

Comments
 (0)