crawlforge-extractors 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -10
- package/index.d.ts +35 -3
- package/index.js +2 -0
- package/package.json +4 -2
- package/src/blockedPage.js +22 -0
- package/src/carriers.js +374 -0
- package/src/embeddedState.js +161 -98
- package/src/flight.js +412 -0
- package/src/frameworkData.js +323 -0
- package/src/jsLiteral.js +266 -0
- package/src/jsonFind.js +150 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frameworkData.js — decode the framework payloads a JSON parse cannot read.
|
|
3
|
+
*
|
|
4
|
+
* Nuxt 3 ships its state as a devalue `stringify` payload in
|
|
5
|
+
* <script type="application/json" id="__NUXT_DATA__" data-nuxt-data="…">: a
|
|
6
|
+
* flat array where every value is an index into the array, with Vue
|
|
7
|
+
* reactivity wrappers tagged as ["Reactive", i], ["ShallowRef", i], …. Parsed
|
|
8
|
+
* as plain JSON it is a list of numbers; decoded, it is the page's state.
|
|
9
|
+
*
|
|
10
|
+
* SvelteKit ships its load() data inline in the boot script as devalue
|
|
11
|
+
* `uneval` output — a JavaScript literal, read here with jsLiteral.js.
|
|
12
|
+
*
|
|
13
|
+
* Both readers return JSON-safe trees and never throw: anything they cannot
|
|
14
|
+
* decode comes back as a warning.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { unflatten } from 'devalue';
|
|
18
|
+
import { parseExpressionAt } from 'acorn';
|
|
19
|
+
|
|
20
|
+
import { evaluateLiteralNode } from './jsLiteral.js';
|
|
21
|
+
|
|
22
|
+
// Nuxt's EmptyRef/EmptyShallowRef payload: '_' is undefined, '0n' is a zero
|
|
23
|
+
// bigint, anything else is JSON (Nuxt reads it with destr, a forgiving
|
|
24
|
+
// JSON.parse that falls back to the string).
|
|
25
|
+
function reviveEmptyRef(data) {
|
|
26
|
+
if (data === '_') return undefined;
|
|
27
|
+
if (data === '0n') return 0;
|
|
28
|
+
if (typeof data !== 'string') return data;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(data);
|
|
31
|
+
} catch {
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const identity = (data) => data;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The revivers Nuxt 3 registers (app/plugins/revive-payload.client), minus
|
|
40
|
+
* Vue: a ref or reactive wrapper is just its value once it is data. Only
|
|
41
|
+
* Reactive, ShallowReactive and the devalue built-in Set were seen live on
|
|
42
|
+
* nuxt.com (2026-10-03); the rest follow Nuxt's source.
|
|
43
|
+
*/
|
|
44
|
+
const NUXT_REVIVERS = {
|
|
45
|
+
NuxtError: identity,
|
|
46
|
+
EmptyShallowRef: reviveEmptyRef,
|
|
47
|
+
EmptyRef: reviveEmptyRef,
|
|
48
|
+
ShallowRef: identity,
|
|
49
|
+
ShallowReactive: identity,
|
|
50
|
+
Ref: identity,
|
|
51
|
+
Reactive: identity
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Type tags devalue 6 decodes itself. Any other tag throws "Unknown type" in
|
|
55
|
+
// unflatten, so it gets an identity reviver and a warning instead.
|
|
56
|
+
const DEVALUE_BUILTIN_TYPES = new Set([
|
|
57
|
+
'Date', 'Set', 'Map', 'RegExp', 'Object', 'BigInt', 'null',
|
|
58
|
+
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
|
|
59
|
+
'Float16Array', 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
|
|
60
|
+
'BigInt64Array', 'BigUint64Array', 'DataView', 'ArrayBuffer',
|
|
61
|
+
'URL', 'URLSearchParams', 'Temporal.Duration', 'Temporal.Instant',
|
|
62
|
+
'Temporal.PlainDate', 'Temporal.PlainTime', 'Temporal.PlainDateTime',
|
|
63
|
+
'Temporal.PlainMonthDay', 'Temporal.PlainYearMonth', 'Temporal.ZonedDateTime'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Construction overrides that build JSON-safe leaves directly: no Date, URL,
|
|
67
|
+
// Temporal or RegExp object is ever constructed from page input, and
|
|
68
|
+
// Temporal — absent from Node — cannot fail the decode.
|
|
69
|
+
const PARSE_OPERATIONS = {
|
|
70
|
+
fromPrimitive: (value) => {
|
|
71
|
+
if (typeof value === 'bigint') return value.toString();
|
|
72
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return null;
|
|
73
|
+
return value === undefined ? null : value;
|
|
74
|
+
},
|
|
75
|
+
fromISOString: (iso) => (typeof iso === 'string' ? iso : null),
|
|
76
|
+
fromStringValue: (tag, text) => (typeof text === 'string' ? text : null),
|
|
77
|
+
fromRegExpInfo: (source, flags) => `/${source}/${flags ?? ''}`,
|
|
78
|
+
box: (value) => value
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// The JSON-safe conversion is a recursive walk. Real Nuxt state is a few
|
|
82
|
+
// dozen levels deep; past this, a branch is cut and reported.
|
|
83
|
+
const MAX_DEPTH = 512;
|
|
84
|
+
// Output cost: one unit per value plus one per 16 characters of string. The
|
|
85
|
+
// devalue format shares every repeated value by index, so a small payload can
|
|
86
|
+
// reference one large object or string a million times; written out, that
|
|
87
|
+
// would expand without bound. Past this cost a repeated object is written as
|
|
88
|
+
// a $ref and anything else as null.
|
|
89
|
+
const MAX_OUTPUT_COST = 5_000_000;
|
|
90
|
+
const CHARS_PER_COST_UNIT = 16;
|
|
91
|
+
|
|
92
|
+
/** Append a key to a jsonPath.js-style path ("state.items[0]"). */
|
|
93
|
+
const childPath = (path, key) =>
|
|
94
|
+
typeof key === 'number' ? `${path}[${key}]` : path === '' ? key : `${path}.${key}`;
|
|
95
|
+
|
|
96
|
+
/** Copy entries onto a fresh object as own data properties ("__proto__" too). */
|
|
97
|
+
function dataObject(entries) {
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const [key, value] of entries) {
|
|
100
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Turn a decoded devalue graph into a JSON-safe tree.
|
|
107
|
+
*
|
|
108
|
+
* Map → plain object when every key is a string, else an array of [key,
|
|
109
|
+
* value] pairs; Set → array; typed arrays and ArrayBuffer → arrays of numbers;
|
|
110
|
+
* undefined / NaN / ±Infinity → null; bigint → string.
|
|
111
|
+
*
|
|
112
|
+
* Cycles cannot be represented in JSON, so a value reached again while it is
|
|
113
|
+
* still being written (an ancestor of itself) becomes { $ref: "<path>" },
|
|
114
|
+
* where <path> is its first occurrence in jsonPath.js syntax ("" is the
|
|
115
|
+
* root). A shared object reached a second time from elsewhere is written out
|
|
116
|
+
* again — until MAX_OUTPUT_COST is spent, after which it too is a $ref.
|
|
117
|
+
*
|
|
118
|
+
* @param {unknown} root
|
|
119
|
+
* @param {string[]} warnings
|
|
120
|
+
* @returns {unknown}
|
|
121
|
+
*/
|
|
122
|
+
function toJsonSafe(root, warnings) {
|
|
123
|
+
const firstPath = new Map();
|
|
124
|
+
const onStack = new Set();
|
|
125
|
+
let cost = 0;
|
|
126
|
+
let depthCut = false;
|
|
127
|
+
let overBudget = false;
|
|
128
|
+
|
|
129
|
+
function convert(value, path, depth) {
|
|
130
|
+
cost += typeof value === 'string' ? 1 + Math.floor(value.length / CHARS_PER_COST_UNIT) : 1;
|
|
131
|
+
if (value === undefined || value === null) return null;
|
|
132
|
+
const isObject = typeof value === 'object';
|
|
133
|
+
if (isObject && onStack.has(value)) return { $ref: firstPath.get(value) };
|
|
134
|
+
if (cost > MAX_OUTPUT_COST) {
|
|
135
|
+
overBudget = true;
|
|
136
|
+
return isObject && firstPath.has(value) ? { $ref: firstPath.get(value) } : null;
|
|
137
|
+
}
|
|
138
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
139
|
+
if (typeof value === 'bigint') return value.toString();
|
|
140
|
+
if (!isObject) return typeof value === 'string' || typeof value === 'boolean' ? value : null;
|
|
141
|
+
if (depth > MAX_DEPTH) {
|
|
142
|
+
depthCut = true;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (!firstPath.has(value)) firstPath.set(value, path);
|
|
146
|
+
|
|
147
|
+
onStack.add(value);
|
|
148
|
+
const next = depth + 1;
|
|
149
|
+
let out;
|
|
150
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
151
|
+
const view = value instanceof ArrayBuffer || value instanceof DataView
|
|
152
|
+
? new Uint8Array(value instanceof ArrayBuffer ? value : value.buffer,
|
|
153
|
+
value.byteOffset ?? 0, value.byteLength)
|
|
154
|
+
: value;
|
|
155
|
+
out = Array.from(view, (n) => (typeof n === 'bigint' ? n.toString() : n));
|
|
156
|
+
cost += out.length;
|
|
157
|
+
} else if (value instanceof Set) {
|
|
158
|
+
out = [];
|
|
159
|
+
for (const item of value) out.push(convert(item, childPath(path, out.length), next));
|
|
160
|
+
} else if (value instanceof Map) {
|
|
161
|
+
const entries = [...value];
|
|
162
|
+
out = entries.every(([key]) => typeof key === 'string')
|
|
163
|
+
? dataObject(entries.map(([key, item]) => [key, convert(item, childPath(path, key), next)]))
|
|
164
|
+
: entries.map(([key, item], i) => [
|
|
165
|
+
convert(key, childPath(childPath(path, i), 0), next),
|
|
166
|
+
convert(item, childPath(childPath(path, i), 1), next)
|
|
167
|
+
]);
|
|
168
|
+
} else if (Array.isArray(value) && value.length > MAX_OUTPUT_COST) {
|
|
169
|
+
// A sparse array can declare a length of 2^32-1 around two entries;
|
|
170
|
+
// walking every slot would hang, so only populated indices are kept.
|
|
171
|
+
warnings.push(`A sparse array of declared length ${value.length} at "${path}" was read as an object of its populated indices.`);
|
|
172
|
+
out = dataObject(Object.keys(value).map((key) => [key, convert(value[key], childPath(path, key), next)]));
|
|
173
|
+
} else if (Array.isArray(value)) {
|
|
174
|
+
out = new Array(value.length);
|
|
175
|
+
for (let i = 0; i < value.length; i++) out[i] = convert(value[i], childPath(path, i), next);
|
|
176
|
+
} else {
|
|
177
|
+
// Plain or null-prototype object.
|
|
178
|
+
out = dataObject(Object.keys(value).map((key) => [key, convert(value[key], childPath(path, key), next)]));
|
|
179
|
+
}
|
|
180
|
+
onStack.delete(value);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const result = convert(root, '', 0);
|
|
185
|
+
if (depthCut) warnings.push(`Branches nested deeper than ${MAX_DEPTH} levels were cut and read as null.`);
|
|
186
|
+
if (overBudget) {
|
|
187
|
+
warnings.push(`The decoded payload passed its output budget (${MAX_OUTPUT_COST} units); past it, repeated objects are written as { $ref } and other values as null.`);
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Decode a parsed __NUXT_DATA__ block (Nuxt 3/4, devalue `stringify` format).
|
|
194
|
+
*
|
|
195
|
+
* @param {unknown} parsed the block's JSON.parse output
|
|
196
|
+
* @returns {{ value: unknown, warnings: string[] }} value is null when the
|
|
197
|
+
* payload cannot be decoded; the reason is in warnings
|
|
198
|
+
*/
|
|
199
|
+
export function decodeNuxtData(parsed) {
|
|
200
|
+
const warnings = [];
|
|
201
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
202
|
+
return { value: null, warnings: ['__NUXT_DATA__ is not a devalue payload (expected a non-empty array); not decoded.'] };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const revivers = { ...NUXT_REVIVERS };
|
|
206
|
+
const unknown = new Set();
|
|
207
|
+
for (const entry of parsed) {
|
|
208
|
+
if (Array.isArray(entry) && typeof entry[0] === 'string'
|
|
209
|
+
&& !DEVALUE_BUILTIN_TYPES.has(entry[0]) && !Object.hasOwn(revivers, entry[0])) {
|
|
210
|
+
unknown.add(entry[0]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const type of unknown) revivers[type] = identity;
|
|
214
|
+
if (unknown.size > 0) {
|
|
215
|
+
warnings.push(`__NUXT_DATA__ uses reviver type(s) this reader does not know (${[...unknown].join(', ')}); each was read as its plain value.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let decoded;
|
|
219
|
+
try {
|
|
220
|
+
// A copy: unflatten appends to the array it is given when a custom
|
|
221
|
+
// reviver wraps a non-index payload, and the caller may still report the
|
|
222
|
+
// raw block.
|
|
223
|
+
decoded = unflatten(parsed.slice(), revivers, { operations: PARSE_OPERATIONS });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// Malformed indexes, a __proto__ key, a stack overflow on a pathological
|
|
226
|
+
// chain — all hostile-input failures, all reported rather than thrown.
|
|
227
|
+
warnings.push(`__NUXT_DATA__ could not be decoded: ${error instanceof Error ? error.message : String(error)}.`);
|
|
228
|
+
return { value: null, warnings };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
return { value: toJsonSafe(decoded, warnings), warnings };
|
|
233
|
+
} catch (error) {
|
|
234
|
+
warnings.push(`__NUXT_DATA__ decoded but could not be converted to JSON: ${error instanceof Error ? error.message : String(error)}.`);
|
|
235
|
+
return { value: null, warnings };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const SCRIPT_RE = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
|
|
240
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
241
|
+
const SVELTEKIT_GLOBAL_RE = /\b__sveltekit_[a-z0-9]+\s*=\s*\{/i;
|
|
242
|
+
const KIT_START_RE = /\bkit\s*\.\s*start\s*\(/g;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Find the `data` property of the options object in
|
|
246
|
+
* `kit.start(app, element, { node_ids, data, form, error })`.
|
|
247
|
+
* @param {string} body the boot script's text
|
|
248
|
+
* @returns {object|null} the acorn node of the data value
|
|
249
|
+
*/
|
|
250
|
+
function findStartData(body) {
|
|
251
|
+
KIT_START_RE.lastIndex = 0;
|
|
252
|
+
let match;
|
|
253
|
+
while ((match = KIT_START_RE.exec(body)) !== null) {
|
|
254
|
+
let call;
|
|
255
|
+
try {
|
|
256
|
+
call = parseExpressionAt(body, match.index, { ecmaVersion: 'latest' });
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const options = call.type === 'CallExpression' ? call.arguments[2] : null;
|
|
261
|
+
if (!options || options.type !== 'ObjectExpression') continue;
|
|
262
|
+
const prop = options.properties.find((p) => p.type === 'Property' && !p.computed
|
|
263
|
+
&& (p.key.type === 'Identifier' ? p.key.name : p.key.value) === 'data');
|
|
264
|
+
if (prop) return prop.value;
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Read SvelteKit's hydration data out of its inline boot script:
|
|
271
|
+
*
|
|
272
|
+
* __sveltekit_8abmph = { base: …, version: "…" };
|
|
273
|
+
* const element = document.currentScript.parentElement;
|
|
274
|
+
* import("…/start.js").then(async (kit) => {
|
|
275
|
+
* kit.init(__sveltekit_8abmph);
|
|
276
|
+
* const app = await import("…/app.js");
|
|
277
|
+
* kit.start(app, element, { node_ids: [0, 5], data: [ … ], form: null, error: null });
|
|
278
|
+
* });
|
|
279
|
+
*
|
|
280
|
+
* (svelte.dev, 2026-10-03; older SvelteKit uses Promise.all([...]).then(
|
|
281
|
+
* ([kit, app]) => kit.start(…)), which reads the same.) Only `data` is
|
|
282
|
+
* evaluated — the other arguments are identifiers. Each data entry is one
|
|
283
|
+
* route node's load() output; an entry the literal evaluator refuses is null,
|
|
284
|
+
* with a warning naming its index.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} html raw HTML
|
|
287
|
+
* @returns {{ value: unknown, warnings: string[] } | null} null when the page
|
|
288
|
+
* has no SvelteKit boot script
|
|
289
|
+
*/
|
|
290
|
+
export function readSvelteKitData(html) {
|
|
291
|
+
const source = html.replace(HTML_COMMENT_RE, '');
|
|
292
|
+
SCRIPT_RE.lastIndex = 0;
|
|
293
|
+
let script;
|
|
294
|
+
while ((script = SCRIPT_RE.exec(source)) !== null) {
|
|
295
|
+
const body = script[1];
|
|
296
|
+
if (!SVELTEKIT_GLOBAL_RE.test(body)) continue;
|
|
297
|
+
|
|
298
|
+
const warnings = [];
|
|
299
|
+
const data = findStartData(body);
|
|
300
|
+
if (data === null) {
|
|
301
|
+
return { value: null, warnings: ['A SvelteKit boot script is present but its kit.start(…) data could not be located.'] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// One budget across entries, so a page cannot multiply the evaluator's
|
|
305
|
+
// cap by splitting its payload into many entries.
|
|
306
|
+
const budget = { cost: 0 };
|
|
307
|
+
if (data.type !== 'ArrayExpression') {
|
|
308
|
+
const result = evaluateLiteralNode(data, budget);
|
|
309
|
+
if (!result.ok) warnings.push(`SvelteKit data was not evaluated (${result.reason}).`);
|
|
310
|
+
return { value: result.ok ? result.value : null, warnings };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const value = data.elements.map((element, i) => {
|
|
314
|
+
if (element === null) return null;
|
|
315
|
+
const result = evaluateLiteralNode(element, budget);
|
|
316
|
+
if (result.ok) return result.value;
|
|
317
|
+
warnings.push(`SvelteKit data[${i}] was not evaluated (${result.reason}); read as null.`);
|
|
318
|
+
return null;
|
|
319
|
+
});
|
|
320
|
+
return { value, warnings };
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
package/src/jsLiteral.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsLiteral.js — read a JavaScript literal without running it.
|
|
3
|
+
*
|
|
4
|
+
* Some pages ship their state as a JS expression rather than JSON: unquoted
|
|
5
|
+
* keys, `void 0`, `!0`, and the IIFE wrapper that Nuxt 2 and devalue.uneval
|
|
6
|
+
* (SvelteKit) emit to share repeated values:
|
|
7
|
+
*
|
|
8
|
+
* window.__NUXT__=(function(a,b){return {x:a,y:[b]}}(1,"z"))
|
|
9
|
+
*
|
|
10
|
+
* This is static evaluation over an acorn AST. No code executes: there is no
|
|
11
|
+
* eval, no vm, no Function, and no property or global lookup. The IIFE form is
|
|
12
|
+
* read by binding each parameter name to its argument's literal value — a
|
|
13
|
+
* name → literal environment — and evaluating the single `return` expression
|
|
14
|
+
* against it. An identifier resolves only to a bound parameter (or the
|
|
15
|
+
* literal names undefined/NaN/Infinity); everything else is refused.
|
|
16
|
+
*
|
|
17
|
+
* Allowed, and nothing more:
|
|
18
|
+
* - string/number/boolean/null literals; a bigint becomes its decimal string
|
|
19
|
+
* - arrays (holes read as null) and objects with plain, non-computed keys
|
|
20
|
+
* - unary - and + on numbers, !0 / !1, and void <literal> (undefined)
|
|
21
|
+
* - template literals with no ${} expressions
|
|
22
|
+
* - string + string
|
|
23
|
+
* - (function(a,b){return <expr>}(<args>)) with simple identifier params
|
|
24
|
+
* - new Date(<number|string>) and new RegExp(<string>[, <string>]), the
|
|
25
|
+
* two constructors devalue.uneval emits for SvelteKit load data
|
|
26
|
+
* (svelte.dev, 2026-10-03). Read as an ISO string and a "/src/flags"
|
|
27
|
+
* string — the same JSON-safe forms frameworkData.js gives them.
|
|
28
|
+
*
|
|
29
|
+
* Output is JSON-safe: undefined, NaN and ±Infinity become null wherever they
|
|
30
|
+
* land in an array or object, and at the top level.
|
|
31
|
+
*
|
|
32
|
+
* The source is attacker-controlled, so the walk is bounded: nesting depth is
|
|
33
|
+
* capped, and a cost budget caps total work. A reference to an IIFE parameter
|
|
34
|
+
* is charged the full cost of its argument, because that is what the reader
|
|
35
|
+
* receives once the result is serialized — `(function(a){return [a,a,a]}(…))`
|
|
36
|
+
* nested a few levels deep would otherwise expand exponentially.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { parseExpressionAt } from 'acorn';
|
|
40
|
+
|
|
41
|
+
const MAX_DEPTH = 512;
|
|
42
|
+
|
|
43
|
+
// One unit per AST node, plus one per 16 characters of every string produced.
|
|
44
|
+
// Generous for real pages (a 20 MB state blob costs ~2.5M), and it bounds the
|
|
45
|
+
// output an IIFE can amplify a small source into.
|
|
46
|
+
const MAX_COST = 5_000_000;
|
|
47
|
+
const CHARS_PER_COST_UNIT = 16;
|
|
48
|
+
|
|
49
|
+
class Refusal extends Error {}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {{ cost: number }} budget
|
|
53
|
+
* @param {number} units
|
|
54
|
+
*/
|
|
55
|
+
function charge(budget, units) {
|
|
56
|
+
budget.cost += units;
|
|
57
|
+
if (budget.cost > MAX_COST) {
|
|
58
|
+
throw new Refusal(`the literal exceeds the evaluation budget of ${MAX_COST} units`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {{ cost: number }} budget @param {string} text */
|
|
63
|
+
function chargeString(budget, text) {
|
|
64
|
+
charge(budget, Math.ceil(text.length / CHARS_PER_COST_UNIT));
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** undefined, NaN and ±Infinity are not JSON; store them as null. */
|
|
69
|
+
function jsonSafe(value) {
|
|
70
|
+
if (value === undefined) return null;
|
|
71
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return null;
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Look a name up in the IIFE parameter scope chain.
|
|
77
|
+
* @param {{ vars: Map<string, { value: unknown, cost: number }>, parent: object|null }|null} scope
|
|
78
|
+
* @param {string} name
|
|
79
|
+
*/
|
|
80
|
+
function lookup(scope, name) {
|
|
81
|
+
for (let s = scope; s !== null; s = s.parent) {
|
|
82
|
+
if (s.vars.has(name)) return s.vars.get(name);
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The narrow IIFE shape: a plain function expression, simple identifier
|
|
89
|
+
* params, a body that is exactly one `return <expr>`.
|
|
90
|
+
* @param {any} callee
|
|
91
|
+
*/
|
|
92
|
+
function isLiteralIife(callee) {
|
|
93
|
+
return callee.type === 'FunctionExpression'
|
|
94
|
+
&& !callee.async
|
|
95
|
+
&& !callee.generator
|
|
96
|
+
&& callee.params.every((param) => param.type === 'Identifier')
|
|
97
|
+
&& callee.body.body.length === 1
|
|
98
|
+
&& callee.body.body[0].type === 'ReturnStatement'
|
|
99
|
+
&& callee.body.body[0].argument !== null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Evaluate one node. Values are raw JS here (undefined, NaN allowed) so that
|
|
104
|
+
* `-Infinity` and `void 0` compose; containers store them JSON-safe.
|
|
105
|
+
* @returns {unknown}
|
|
106
|
+
*/
|
|
107
|
+
function evaluate(node, scope, depth, budget) {
|
|
108
|
+
if (depth > MAX_DEPTH) throw new Refusal(`nesting deeper than ${MAX_DEPTH} levels`);
|
|
109
|
+
charge(budget, 1);
|
|
110
|
+
|
|
111
|
+
switch (node.type) {
|
|
112
|
+
case 'Literal': {
|
|
113
|
+
if (node.regex) throw new Refusal('a regular expression literal');
|
|
114
|
+
if (typeof node.value === 'bigint') return node.value.toString();
|
|
115
|
+
if (typeof node.value === 'string') return chargeString(budget, node.value);
|
|
116
|
+
return node.value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'ArrayExpression':
|
|
120
|
+
return node.elements.map((element) => {
|
|
121
|
+
if (element === null) return null;
|
|
122
|
+
if (element.type === 'SpreadElement') throw new Refusal('a spread element');
|
|
123
|
+
return jsonSafe(evaluate(element, scope, depth + 1, budget));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
case 'ObjectExpression': {
|
|
127
|
+
const object = {};
|
|
128
|
+
for (const prop of node.properties) {
|
|
129
|
+
if (prop.type !== 'Property') throw new Refusal('a spread property');
|
|
130
|
+
if (prop.computed) throw new Refusal('a computed key');
|
|
131
|
+
if (prop.kind !== 'init' || prop.method) throw new Refusal('a method, getter or setter');
|
|
132
|
+
if (prop.shorthand) throw new Refusal(`a shorthand property "${prop.key.name}"`);
|
|
133
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
|
|
134
|
+
// defineProperty, not assignment: a "__proto__" key becomes an own
|
|
135
|
+
// data property instead of replacing the object's prototype. (In JS
|
|
136
|
+
// the literal would set the prototype; as data it is safer kept as a
|
|
137
|
+
// plain key.)
|
|
138
|
+
Object.defineProperty(object, key, {
|
|
139
|
+
value: jsonSafe(evaluate(prop.value, scope, depth + 1, budget)),
|
|
140
|
+
enumerable: true,
|
|
141
|
+
writable: true,
|
|
142
|
+
configurable: true
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return object;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case 'UnaryExpression': {
|
|
149
|
+
const value = evaluate(node.argument, scope, depth + 1, budget);
|
|
150
|
+
if (node.operator === 'void') return undefined;
|
|
151
|
+
if ((node.operator === '-' || node.operator === '+') && typeof value === 'number') {
|
|
152
|
+
return node.operator === '-' ? -value : value;
|
|
153
|
+
}
|
|
154
|
+
if (node.operator === '!' && (typeof value === 'number' || typeof value === 'boolean')) {
|
|
155
|
+
return !value;
|
|
156
|
+
}
|
|
157
|
+
throw new Refusal(`the unary operator "${node.operator}" on a ${typeof value}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'Identifier': {
|
|
161
|
+
const binding = lookup(scope, node.name);
|
|
162
|
+
if (binding !== undefined) {
|
|
163
|
+
charge(budget, binding.cost);
|
|
164
|
+
return binding.value;
|
|
165
|
+
}
|
|
166
|
+
if (node.name === 'undefined') return undefined;
|
|
167
|
+
if (node.name === 'NaN') return NaN;
|
|
168
|
+
if (node.name === 'Infinity') return Infinity;
|
|
169
|
+
throw new Refusal(`the identifier "${node.name}"`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'TemplateLiteral': {
|
|
173
|
+
const cooked = node.expressions.length === 0 ? node.quasis[0].value.cooked : null;
|
|
174
|
+
if (typeof cooked !== 'string') throw new Refusal('a template literal with expressions');
|
|
175
|
+
return chargeString(budget, cooked);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'BinaryExpression': {
|
|
179
|
+
if (node.operator !== '+') throw new Refusal(`the binary operator "${node.operator}"`);
|
|
180
|
+
const left = evaluate(node.left, scope, depth + 1, budget);
|
|
181
|
+
const right = evaluate(node.right, scope, depth + 1, budget);
|
|
182
|
+
if (typeof left !== 'string' || typeof right !== 'string') {
|
|
183
|
+
throw new Refusal('a "+" that is not string concatenation');
|
|
184
|
+
}
|
|
185
|
+
return chargeString(budget, left + right);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'NewExpression': {
|
|
189
|
+
const name = node.callee.type === 'Identifier' ? node.callee.name : null;
|
|
190
|
+
if ((name === 'Date' || name === 'RegExp') && lookup(scope, name) === undefined) {
|
|
191
|
+
const args = node.arguments.map((arg) => {
|
|
192
|
+
if (arg.type === 'SpreadElement') throw new Refusal('a spread argument');
|
|
193
|
+
return evaluate(arg, scope, depth + 1, budget);
|
|
194
|
+
});
|
|
195
|
+
if (name === 'Date' && args.length === 1
|
|
196
|
+
&& (typeof args[0] === 'number' || typeof args[0] === 'string')) {
|
|
197
|
+
const date = new Date(args[0]);
|
|
198
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
199
|
+
}
|
|
200
|
+
if (name === 'RegExp' && args.length >= 1 && args.length <= 2
|
|
201
|
+
&& args.every((arg) => typeof arg === 'string')) {
|
|
202
|
+
return chargeString(budget, `/${args[0]}/${args[1] ?? ''}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new Refusal('a constructor call');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'CallExpression': {
|
|
209
|
+
if (node.optional || !isLiteralIife(node.callee)) throw new Refusal('a function call');
|
|
210
|
+
// Arguments are evaluated in the caller's scope; each records what it
|
|
211
|
+
// cost so every later reference to it is charged the same.
|
|
212
|
+
const args = node.arguments.map((arg) => {
|
|
213
|
+
if (arg.type === 'SpreadElement') throw new Refusal('a spread argument');
|
|
214
|
+
const before = budget.cost;
|
|
215
|
+
const value = evaluate(arg, scope, depth + 1, budget);
|
|
216
|
+
return { value, cost: budget.cost - before };
|
|
217
|
+
});
|
|
218
|
+
const vars = new Map();
|
|
219
|
+
node.callee.params.forEach((param, i) => {
|
|
220
|
+
vars.set(param.name, args[i] ?? { value: undefined, cost: 0 });
|
|
221
|
+
});
|
|
222
|
+
return evaluate(node.callee.body.body[0].argument, { vars, parent: scope }, depth + 1, budget);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
default:
|
|
226
|
+
throw new Refusal(`a ${node.type}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Statically evaluate an acorn ESTree expression node made only of literals.
|
|
232
|
+
*
|
|
233
|
+
* @param {object} node an acorn expression node
|
|
234
|
+
* @param {{ cost: number }} [budget] pass one object to several calls to make
|
|
235
|
+
* them share one cost cap (readSvelteKitData evaluates entry by entry)
|
|
236
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, reason: string }}
|
|
237
|
+
*/
|
|
238
|
+
export function evaluateLiteralNode(node, budget = { cost: 0 }) {
|
|
239
|
+
try {
|
|
240
|
+
return { ok: true, value: jsonSafe(evaluate(node, null, 0, budget)) };
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error instanceof Refusal) return { ok: false, reason: `not a literal: ${error.message}` };
|
|
243
|
+
// A stack overflow before MAX_DEPTH (a small stack) is still a refusal.
|
|
244
|
+
return { ok: false, reason: `not evaluated: ${error instanceof Error ? error.message : String(error)}` };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Parse the expression that starts at `start` in `text` and evaluate it.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} text
|
|
252
|
+
* @param {number} [start]
|
|
253
|
+
* @returns {{ ok: true, value: unknown, end: number } | { ok: false, reason: string }}
|
|
254
|
+
*/
|
|
255
|
+
export function evaluateLiteralSource(text, start = 0) {
|
|
256
|
+
let node;
|
|
257
|
+
try {
|
|
258
|
+
node = parseExpressionAt(text, start, { ecmaVersion: 'latest' });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// acorn reports pathological nesting as a SyntaxError ("Not enough stack
|
|
261
|
+
// space"); a RangeError is possible too. Either way: refuse, never throw.
|
|
262
|
+
return { ok: false, reason: `not parseable: ${error instanceof Error ? error.message : String(error)}` };
|
|
263
|
+
}
|
|
264
|
+
const result = evaluateLiteralNode(node);
|
|
265
|
+
return result.ok ? { ok: true, value: result.value, end: node.end } : result;
|
|
266
|
+
}
|