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
package/src/flight.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flight.js — React Server Components ("flight") streams: split the stream into
|
|
3
|
+
* rows, resolve the references between rows, and index the rows that carry data.
|
|
4
|
+
*
|
|
5
|
+
* Pure: strings and parsed JSON in, plain objects out. Every walk below is
|
|
6
|
+
* iterative and every output is bounded, because the stream is page content —
|
|
7
|
+
* an attacker can make it arbitrarily deep, long, or self-referential.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Split a concatenated RSC flight stream into its rows.
|
|
12
|
+
*
|
|
13
|
+
* The stream is a sequence of `<hexId>:<payload>\n` rows. Four payload shapes
|
|
14
|
+
* matter:
|
|
15
|
+
* - `T<hexByteLength>,` — a length-prefixed text blob. The length is in
|
|
16
|
+
* UTF-8 BYTES and INCLUDES the row's terminating newline, so the cursor
|
|
17
|
+
* advances exactly that many bytes and no further. (Advancing one extra
|
|
18
|
+
* character for a newline silently eats the first hex digit of the next
|
|
19
|
+
* row id, turning row "14" into row "4" and overwriting an unrelated row —
|
|
20
|
+
* verified against the live Healthgrades capture, where it produced seven
|
|
21
|
+
* colliding ids.)
|
|
22
|
+
* - `I[...]` / `HL[...]` — module and hint references. Not JSON; kept as the
|
|
23
|
+
* raw string so the caller can still see which components a page loads.
|
|
24
|
+
* - `E{...}` — a server error row; parsed to `{ $error: <parsed> }`.
|
|
25
|
+
* - anything else — JSON, parsed.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} stream
|
|
28
|
+
* @returns {Record<string, unknown>} row id -> value
|
|
29
|
+
*/
|
|
30
|
+
export function parseFlightRows(stream) {
|
|
31
|
+
const rows = {};
|
|
32
|
+
let cursor = 0;
|
|
33
|
+
|
|
34
|
+
while (cursor < stream.length) {
|
|
35
|
+
const newline = stream.indexOf('\n', cursor);
|
|
36
|
+
const lineEnd = newline === -1 ? stream.length : newline;
|
|
37
|
+
const header = stream.slice(cursor, lineEnd).match(/^([0-9a-f]+):/i);
|
|
38
|
+
if (!header) {
|
|
39
|
+
// Not a row start: a chunk boundary landed mid-row, or the stream was
|
|
40
|
+
// truncated. Resync on the next line rather than giving up.
|
|
41
|
+
cursor = lineEnd + 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const id = header[1];
|
|
46
|
+
const payloadStart = cursor + header[0].length;
|
|
47
|
+
const payload = stream.slice(payloadStart, lineEnd);
|
|
48
|
+
|
|
49
|
+
const textRow = payload.match(/^T([0-9a-f]+),/i);
|
|
50
|
+
if (textRow) {
|
|
51
|
+
const blobStart = payloadStart + textRow[0].length;
|
|
52
|
+
const byteLen = parseInt(textRow[1], 16);
|
|
53
|
+
// Decode only up to byteLen bytes. Slicing the whole remaining stream on
|
|
54
|
+
// every text row makes this O(N) per row -> O(N^2) for an
|
|
55
|
+
// attacker-controlled stream of many small text rows. byteLen bytes span
|
|
56
|
+
// at most byteLen characters, so bounding the slice to that many chars
|
|
57
|
+
// keeps the work linear and yields the identical decoded blob.
|
|
58
|
+
const text = Buffer.from(stream.slice(blobStart, blobStart + byteLen), 'utf8')
|
|
59
|
+
.subarray(0, byteLen)
|
|
60
|
+
.toString('utf8');
|
|
61
|
+
rows[id] = text;
|
|
62
|
+
cursor = blobStart + text.length;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (payload[0] === 'E') {
|
|
67
|
+
try {
|
|
68
|
+
rows[id] = { $error: JSON.parse(payload.slice(1)) };
|
|
69
|
+
} catch {
|
|
70
|
+
rows[id] = payload;
|
|
71
|
+
}
|
|
72
|
+
cursor = lineEnd + 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
rows[id] = JSON.parse(payload);
|
|
78
|
+
} catch {
|
|
79
|
+
rows[id] = payload;
|
|
80
|
+
}
|
|
81
|
+
cursor = lineEnd + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A model reference: `$<hex>` (outlined model), `$@<hex>` (promise), either
|
|
88
|
+
// with an optional `:prop:prop…` property path into the referenced value.
|
|
89
|
+
// Row ids are lowercase hex (React writes them with toString(16)); the
|
|
90
|
+
// uppercase and non-hex tags — `$L` lazy, `$S` symbol, `$D` date, `$F` server
|
|
91
|
+
// reference, `$n` bigint, `$u`ndefined, … — never match.
|
|
92
|
+
const REF_RE = /^\$@?([0-9a-f]+)((?::[^:]*)*)$/;
|
|
93
|
+
|
|
94
|
+
// React writes an element as ["$", type, key, props] and addresses its parts
|
|
95
|
+
// by the element object's field names. A path walking the raw tuple maps them.
|
|
96
|
+
const ELEMENT_FIELDS = { type: 1, key: 2, props: 3 };
|
|
97
|
+
|
|
98
|
+
const isElement = (value) => Array.isArray(value) && value.length >= 4 && value[0] === '$';
|
|
99
|
+
|
|
100
|
+
const IN_PROGRESS = -1;
|
|
101
|
+
|
|
102
|
+
const scalarBytes = (value) => {
|
|
103
|
+
let json;
|
|
104
|
+
try {
|
|
105
|
+
json = JSON.stringify(value);
|
|
106
|
+
} catch {
|
|
107
|
+
json = undefined;
|
|
108
|
+
}
|
|
109
|
+
return Buffer.byteLength(json ?? 'null');
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The UTF-8 size of a value's JSON serialization, computed without
|
|
114
|
+
* serializing it. `memo` caches every container's size, so a value whose
|
|
115
|
+
* subtrees are shared (as resolved rows are) costs one visit per distinct
|
|
116
|
+
* container — never the size of its expansion. Iterative, so depth is free; a
|
|
117
|
+
* cycle counts as 4 bytes instead of looping.
|
|
118
|
+
*
|
|
119
|
+
* @param {unknown} root
|
|
120
|
+
* @param {WeakMap<object, number>} memo
|
|
121
|
+
* @returns {number}
|
|
122
|
+
*/
|
|
123
|
+
function serializedSize(root, memo) {
|
|
124
|
+
if (root === null || typeof root !== 'object') return scalarBytes(root);
|
|
125
|
+
const stack = [root];
|
|
126
|
+
while (stack.length > 0) {
|
|
127
|
+
const node = stack[stack.length - 1];
|
|
128
|
+
const state = memo.get(node);
|
|
129
|
+
if (state === undefined) {
|
|
130
|
+
memo.set(node, IN_PROGRESS);
|
|
131
|
+
for (const key of Object.keys(node)) {
|
|
132
|
+
const child = node[key];
|
|
133
|
+
if (child !== null && typeof child === 'object' && memo.get(child) === undefined) stack.push(child);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
stack.pop();
|
|
138
|
+
if (state !== IN_PROGRESS) continue;
|
|
139
|
+
|
|
140
|
+
const keys = Object.keys(node);
|
|
141
|
+
const isArray = Array.isArray(node);
|
|
142
|
+
let size = 2 + Math.max(0, keys.length - 1);
|
|
143
|
+
for (const key of keys) {
|
|
144
|
+
const child = node[key];
|
|
145
|
+
if (!isArray) size += Buffer.byteLength(JSON.stringify(key)) + 1;
|
|
146
|
+
if (child !== null && typeof child === 'object') {
|
|
147
|
+
const childSize = memo.get(child);
|
|
148
|
+
size += childSize === IN_PROGRESS ? 4 : childSize;
|
|
149
|
+
} else {
|
|
150
|
+
size += scalarBytes(child);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
memo.set(node, size);
|
|
154
|
+
}
|
|
155
|
+
return memo.get(root);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Own-property write; `dst.__proto__ = v` would set the prototype instead.
|
|
159
|
+
const setOwn = (dst, key, value) => {
|
|
160
|
+
if (key === '__proto__') {
|
|
161
|
+
Object.defineProperty(dst, key, { value, enumerable: true, writable: true, configurable: true });
|
|
162
|
+
} else {
|
|
163
|
+
dst[key] = value;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the model references between flight rows.
|
|
169
|
+
*
|
|
170
|
+
* A string `$<hex>`, `$@<hex>` or `$<hex>:<prop>:…` is replaced by the
|
|
171
|
+
* referenced row's resolved value (or the property it names); `$$…` inside a
|
|
172
|
+
* row is the escaped literal `$…`. Every other `$` token — `$L` lazy
|
|
173
|
+
* components, `$S`, `$undefined`, the element marker `"$"` — is left as is.
|
|
174
|
+
* A reference to an unknown row, or a property path that does not resolve,
|
|
175
|
+
* stays the original string.
|
|
176
|
+
*
|
|
177
|
+
* React deduplicates an object it has already written in the same row as a
|
|
178
|
+
* path reference to its first occurrence (`$10:1:2:props:providerModel` inside
|
|
179
|
+
* row 10). The row is copied in document order, so that first occurrence is
|
|
180
|
+
* complete by the time the reference is read, and it resolves against the
|
|
181
|
+
* copy. A reference to something still being built — a row reachable from
|
|
182
|
+
* itself, or a container that encloses the reference — becomes
|
|
183
|
+
* `{ $ref: "<id>" }`.
|
|
184
|
+
*
|
|
185
|
+
* Inlined values are shared, not copied, so memory stays linear — but their
|
|
186
|
+
* serialization is not. `budgetBytes` caps the bytes inlining may add to the
|
|
187
|
+
* serialized output (default: the serialized size of `rows`, so resolution at
|
|
188
|
+
* most doubles the payload); a reference that would exceed it stays a string
|
|
189
|
+
* and counts in `overBudget`. That is what keeps a `row n = [$n-1, $n-1]`
|
|
190
|
+
* chain from expanding to 2^n.
|
|
191
|
+
*
|
|
192
|
+
* A top-level string row (an `I`/`HL`/`T` row, or a JSON string row) is only
|
|
193
|
+
* resolved when the whole string is a reference — `e:"$9:metadata"` — and is
|
|
194
|
+
* never unescaped, since text rows carry no escaping.
|
|
195
|
+
*
|
|
196
|
+
* @param {Record<string, unknown>} rows row id -> value, from parseFlightRows
|
|
197
|
+
* @param {{ budgetBytes?: number }} [options]
|
|
198
|
+
* @returns {{ rows: Record<string, unknown>, resolved: number, cycles: number, overBudget: number }}
|
|
199
|
+
*/
|
|
200
|
+
export function resolveFlightRows(rows, { budgetBytes } = {}) {
|
|
201
|
+
const out = {};
|
|
202
|
+
const stats = { resolved: 0, cycles: 0, overBudget: 0 };
|
|
203
|
+
if (rows === null || typeof rows !== 'object') return { rows: out, ...stats };
|
|
204
|
+
|
|
205
|
+
const ids = Object.keys(rows);
|
|
206
|
+
const sizes = new WeakMap();
|
|
207
|
+
let budget = typeof budgetBytes === 'number' && budgetBytes >= 0 ? budgetBytes : serializedSize(rows, sizes);
|
|
208
|
+
|
|
209
|
+
const ON_STACK = 1;
|
|
210
|
+
const DONE = 2;
|
|
211
|
+
const state = new Map();
|
|
212
|
+
|
|
213
|
+
const refTarget = (string) => {
|
|
214
|
+
const match = REF_RE.exec(string);
|
|
215
|
+
return match && Object.hasOwn(rows, match[1]) ? match : null;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// The other rows a row references, in document order, deduplicated.
|
|
219
|
+
const dependencies = (id) => {
|
|
220
|
+
const value = rows[id];
|
|
221
|
+
const found = new Set();
|
|
222
|
+
const note = (string) => {
|
|
223
|
+
const match = string[0] === '$' ? refTarget(string) : null;
|
|
224
|
+
if (match && match[1] !== id) found.add(match[1]);
|
|
225
|
+
};
|
|
226
|
+
if (typeof value === 'string') note(value);
|
|
227
|
+
if (value === null || typeof value !== 'object') return [...found];
|
|
228
|
+
const seen = new WeakSet([value]);
|
|
229
|
+
const stack = [value];
|
|
230
|
+
while (stack.length > 0) {
|
|
231
|
+
const node = stack.pop();
|
|
232
|
+
for (const key of Object.keys(node)) {
|
|
233
|
+
const child = node[key];
|
|
234
|
+
if (typeof child === 'string') {
|
|
235
|
+
note(child);
|
|
236
|
+
} else if (child !== null && typeof child === 'object' && !seen.has(child)) {
|
|
237
|
+
seen.add(child);
|
|
238
|
+
stack.push(child);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...found];
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} string
|
|
247
|
+
* @param {{ id: string, root: object, open: Set<object> } | null} row the
|
|
248
|
+
* row being copied, when the string sits inside one
|
|
249
|
+
*/
|
|
250
|
+
const resolveString = (string, row) => {
|
|
251
|
+
if (string[0] !== '$') return string;
|
|
252
|
+
if (row && string[1] === '$') return string.slice(1);
|
|
253
|
+
const match = refTarget(string);
|
|
254
|
+
if (!match) return string;
|
|
255
|
+
const id = match[1];
|
|
256
|
+
// A string row is markup, not model data: a `T` text blob (Healthgrades
|
|
257
|
+
// inlines two of ~340 KB each through dangerouslySetInnerHTML), an `I`/`HL`
|
|
258
|
+
// module reference or `$Sreact.fragment`. Inlining them spent the whole
|
|
259
|
+
// budget before any data reference was reached, so they stay references.
|
|
260
|
+
if (typeof rows[id] === 'string') return string;
|
|
261
|
+
|
|
262
|
+
let value;
|
|
263
|
+
if (row && id === row.id) {
|
|
264
|
+
value = row.root;
|
|
265
|
+
} else if (state.get(id) === DONE) {
|
|
266
|
+
value = out[id];
|
|
267
|
+
} else {
|
|
268
|
+
// Every dependency finishes before its dependant, so a target that is
|
|
269
|
+
// not done yet is an ancestor on the DFS stack: a cycle.
|
|
270
|
+
stats.cycles++;
|
|
271
|
+
return { $ref: id };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (match[2]) {
|
|
275
|
+
for (const prop of match[2].slice(1).split(':')) {
|
|
276
|
+
const key = isElement(value) && Object.hasOwn(ELEMENT_FIELDS, prop) ? ELEMENT_FIELDS[prop] : prop;
|
|
277
|
+
if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)) return string;
|
|
278
|
+
value = value[key];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (row && row.open.has(value)) {
|
|
282
|
+
stats.cycles++;
|
|
283
|
+
return { $ref: id };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const added = serializedSize(value, sizes) - Buffer.byteLength(JSON.stringify(string));
|
|
287
|
+
if (added > budget) {
|
|
288
|
+
stats.overBudget++;
|
|
289
|
+
return string;
|
|
290
|
+
}
|
|
291
|
+
if (added > 0) budget -= added;
|
|
292
|
+
stats.resolved++;
|
|
293
|
+
return value;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Copy a row in document order (pre-order), resolving references as they
|
|
297
|
+
// are met. `open` holds the containers whose subtree is not finished yet; a
|
|
298
|
+
// reference landing on one of them would enclose itself.
|
|
299
|
+
const resolveRow = (id) => {
|
|
300
|
+
const raw = rows[id];
|
|
301
|
+
if (typeof raw === 'string') return resolveString(raw, null);
|
|
302
|
+
if (raw === null || typeof raw !== 'object') return raw;
|
|
303
|
+
|
|
304
|
+
const root = Array.isArray(raw) ? [] : {};
|
|
305
|
+
const row = { id, root, open: new Set([root]) };
|
|
306
|
+
const copies = new Map([[raw, root]]);
|
|
307
|
+
const stack = [{ close: root }];
|
|
308
|
+
const pushChildren = (src, dst) => {
|
|
309
|
+
const keys = Object.keys(src);
|
|
310
|
+
for (let i = keys.length - 1; i >= 0; i--) stack.push({ src: src[keys[i]], dst, key: keys[i] });
|
|
311
|
+
};
|
|
312
|
+
pushChildren(raw, root);
|
|
313
|
+
|
|
314
|
+
while (stack.length > 0) {
|
|
315
|
+
const entry = stack.pop();
|
|
316
|
+
if (entry.close) {
|
|
317
|
+
row.open.delete(entry.close);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const { src, dst, key } = entry;
|
|
321
|
+
if (typeof src === 'string') {
|
|
322
|
+
setOwn(dst, key, resolveString(src, row));
|
|
323
|
+
} else if (src !== null && typeof src === 'object') {
|
|
324
|
+
let copy = copies.get(src);
|
|
325
|
+
if (copy === undefined) {
|
|
326
|
+
copy = Array.isArray(src) ? [] : {};
|
|
327
|
+
copies.set(src, copy);
|
|
328
|
+
row.open.add(copy);
|
|
329
|
+
stack.push({ close: copy });
|
|
330
|
+
pushChildren(src, copy);
|
|
331
|
+
} else if (row.open.has(copy)) {
|
|
332
|
+
// Only a cyclic in-memory input gets here; JSON never does.
|
|
333
|
+
stats.cycles++;
|
|
334
|
+
copy = { $ref: id };
|
|
335
|
+
}
|
|
336
|
+
setOwn(dst, key, copy);
|
|
337
|
+
} else {
|
|
338
|
+
setOwn(dst, key, src);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return root;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Keep the input's key order in the output.
|
|
345
|
+
for (const id of ids) setOwn(out, id, undefined);
|
|
346
|
+
|
|
347
|
+
// Iterative post-order DFS over the reference graph: a row is resolved once
|
|
348
|
+
// every row it references is, so a 100k-long chain needs no call stack.
|
|
349
|
+
for (const start of ids) {
|
|
350
|
+
if (state.has(start)) continue;
|
|
351
|
+
state.set(start, ON_STACK);
|
|
352
|
+
const stack = [{ id: start, deps: dependencies(start), next: 0 }];
|
|
353
|
+
while (stack.length > 0) {
|
|
354
|
+
const frame = stack[stack.length - 1];
|
|
355
|
+
if (frame.next < frame.deps.length) {
|
|
356
|
+
const dep = frame.deps[frame.next++];
|
|
357
|
+
if (!state.has(dep)) {
|
|
358
|
+
state.set(dep, ON_STACK);
|
|
359
|
+
stack.push({ id: dep, deps: dependencies(dep), next: 0 });
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
stack.pop();
|
|
364
|
+
setOwn(out, frame.id, resolveRow(frame.id));
|
|
365
|
+
state.set(frame.id, DONE);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { rows: out, ...stats };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Index the rows that carry data rather than markup: object or array rows that
|
|
374
|
+
* are not a React element tree (`["$", type, key, props]`) nor a list of
|
|
375
|
+
* element children, ranked by serialized size. String rows (`I`/`HL`/`T`) are
|
|
376
|
+
* not data rows. An index, not a copy — a caller reads a row with the path
|
|
377
|
+
* `next_f.<id>`.
|
|
378
|
+
*
|
|
379
|
+
* An array counts as element children when every item is an element or a
|
|
380
|
+
* primitive (a `$L` lazy reference, a text node, `false`, `null`) and at least
|
|
381
|
+
* one item is an element — the shape of a fragment's children.
|
|
382
|
+
*
|
|
383
|
+
* @param {Record<string, unknown>} rows
|
|
384
|
+
* @returns {Array<{ id: string, bytes: number, keys: string[] | null, length: number | null }>}
|
|
385
|
+
*/
|
|
386
|
+
export function flightDataRows(rows) {
|
|
387
|
+
const out = [];
|
|
388
|
+
if (rows === null || typeof rows !== 'object') return out;
|
|
389
|
+
const sizes = new WeakMap();
|
|
390
|
+
|
|
391
|
+
const isChildren = (array) => {
|
|
392
|
+
let elements = 0;
|
|
393
|
+
for (const item of array) {
|
|
394
|
+
if (isElement(item)) elements++;
|
|
395
|
+
else if (item !== null && typeof item === 'object') return false;
|
|
396
|
+
}
|
|
397
|
+
return elements > 0;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
for (const id of Object.keys(rows)) {
|
|
401
|
+
const value = rows[id];
|
|
402
|
+
if (value === null || typeof value !== 'object') continue;
|
|
403
|
+
if (Array.isArray(value)) {
|
|
404
|
+
if (isElement(value) || isChildren(value)) continue;
|
|
405
|
+
out.push({ id, bytes: serializedSize(value, sizes), keys: null, length: value.length });
|
|
406
|
+
} else {
|
|
407
|
+
out.push({ id, bytes: serializedSize(value, sizes), keys: Object.keys(value).slice(0, 20), length: null });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return out.sort((a, b) => b.bytes - a.bytes);
|
|
412
|
+
}
|