imprint-mcp 0.6.2 → 0.6.4

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.
@@ -15,11 +15,44 @@
15
15
  import { splitSetCookieHeader } from './cookie-jar.ts';
16
16
  import type { Replacement } from './credential-extract.ts';
17
17
  import { hasFreeformRedactionHint, redactFreeformText } from './freeform-redact.ts';
18
+ import {
19
+ MAX_REACT_FLIGHT_JSON_NODES,
20
+ MAX_REACT_FLIGHT_ROWS,
21
+ boundedJsonNodeCount,
22
+ } from './react-flight-limits.ts';
18
23
  import { isAlwaysSecretHeader, isSensitiveHeader, isSensitiveKey } from './sensitive-keys.ts';
19
24
  import type { CapturedRequest, Session } from './types.ts';
20
25
 
21
26
  const USER_INTERACTION_TYPES = new Set(['click', 'input', 'change', 'submit']);
22
27
  const MULTI_VALUE_HEADERS = new Set(['cookie', 'set-cookie']);
28
+ const MAX_FLIGHT_JSON_NESTING_DEPTH = 256;
29
+
30
+ /** Cheap, string-aware guard against making JSON.parse plus recursive
31
+ * redaction traverse an adversarially deep Flight row. JSON.parse remains the
32
+ * authority for syntax; this only rejects nesting beyond the safe budget. */
33
+ function hasBoundedJsonNesting(value: string, maxDepth = MAX_FLIGHT_JSON_NESTING_DEPTH): boolean {
34
+ let depth = 0;
35
+ let inString = false;
36
+ let escaped = false;
37
+ for (let i = 0; i < value.length; i++) {
38
+ const char = value.charCodeAt(i);
39
+ if (inString) {
40
+ if (escaped) escaped = false;
41
+ else if (char === 0x5c) escaped = true;
42
+ else if (char === 0x22) inString = false;
43
+ continue;
44
+ }
45
+ if (char === 0x22) {
46
+ inString = true;
47
+ } else if (char === 0x5b || char === 0x7b) {
48
+ depth++;
49
+ if (depth > maxDepth) return false;
50
+ } else if (char === 0x5d || char === 0x7d) {
51
+ depth--;
52
+ }
53
+ }
54
+ return true;
55
+ }
23
56
 
24
57
  /**
25
58
  * Well-known XSSI (cross-site script inclusion) guards that sites prepend to
@@ -188,7 +221,15 @@ export function redactJsonBody(
188
221
  placeholderByPath?: Map<string, string>,
189
222
  freeform = true,
190
223
  markerContext?: RedactionMarkerContext,
224
+ maxNestingDepth?: number,
225
+ jsonNodeBudget?: { remaining: number },
226
+ countRoot = true,
191
227
  ): BodyRedaction {
228
+ if (jsonNodeBudget && countRoot) {
229
+ const nodes = boundedJsonNodeCount(body, jsonNodeBudget.remaining);
230
+ if (nodes === null) throw new Error('JSON width exceeds redaction budget');
231
+ jsonNodeBudget.remaining -= nodes;
232
+ }
192
233
  let parsed: unknown;
193
234
  try {
194
235
  parsed = JSON.parse(body);
@@ -199,9 +240,48 @@ export function redactJsonBody(
199
240
  let count = 0;
200
241
  let placeholders = 0;
201
242
  let freeformCount = 0;
202
- const visit = (node: unknown, pathSoFar: string[]): unknown => {
243
+ function visit(node: unknown, pathSoFar: string[], traversalDepth = 0): unknown {
244
+ if (maxNestingDepth !== undefined && traversalDepth > maxNestingDepth) {
245
+ throw new Error('JSON traversal depth exceeds redaction budget');
246
+ }
247
+ if (typeof node === 'string') {
248
+ const jsonCandidate = node.trimStart();
249
+ if (jsonCandidate.length > 1 && (jsonCandidate[0] === '{' || jsonCandidate[0] === '[')) {
250
+ // JSON-in-JSON may appear at an object property, an array index, or the
251
+ // root. Apply the same bounded structural redaction in every position.
252
+ if (
253
+ maxNestingDepth !== undefined &&
254
+ !hasBoundedJsonNesting(jsonCandidate, maxNestingDepth)
255
+ ) {
256
+ throw new Error('JSON-in-JSON nesting exceeds redaction budget');
257
+ }
258
+ if (jsonNodeBudget) {
259
+ const nodes = boundedJsonNodeCount(jsonCandidate, jsonNodeBudget.remaining);
260
+ if (nodes === null) throw new Error('JSON-in-JSON width exceeds redaction budget');
261
+ jsonNodeBudget.remaining -= nodes;
262
+ }
263
+ let inner: unknown;
264
+ try {
265
+ inner = JSON.parse(jsonCandidate);
266
+ } catch {
267
+ const fallback =
268
+ freeform && !looksLikeRpcEnvelope(node)
269
+ ? redactFreeformText(node)
270
+ : { redacted: node, redactionsCount: 0 };
271
+ freeformCount += fallback.redactionsCount;
272
+ return fallback.redacted;
273
+ }
274
+ return JSON.stringify(visit(inner, pathSoFar, traversalDepth + 1));
275
+ }
276
+ if (freeform && !looksLikeRpcEnvelope(node)) {
277
+ const redacted = redactFreeformText(node);
278
+ freeformCount += redacted.redactionsCount;
279
+ return redacted.redacted;
280
+ }
281
+ return node;
282
+ }
203
283
  if (Array.isArray(node)) {
204
- return node.map((v, i) => visit(v, [...pathSoFar, String(i)]));
284
+ return node.map((v, i) => visit(v, [...pathSoFar, String(i)], traversalDepth + 1));
205
285
  }
206
286
  if (node && typeof node === 'object') {
207
287
  const out: Record<string, unknown> = {};
@@ -214,34 +294,14 @@ export function redactJsonBody(
214
294
  } else if (isSensitiveKey(k) && (typeof v === 'string' || typeof v === 'number')) {
215
295
  count++;
216
296
  out[k] = markerFor(String(v), markerContext);
217
- } else if (typeof v === 'string' && v.length > 1 && (v[0] === '{' || v[0] === '[')) {
218
- // JSON-in-JSON: try to parse and redact the nested string.
219
- try {
220
- const inner = JSON.parse(v);
221
- const visited = visit(inner, [...pathSoFar, k]);
222
- out[k] = JSON.stringify(visited);
223
- } catch {
224
- // Nested string that isn't parseable JSON: scan it as free text,
225
- // unless it's a structured RPC envelope (flat-scanning corrupts it).
226
- const r =
227
- freeform && !looksLikeRpcEnvelope(v)
228
- ? redactFreeformText(v)
229
- : { redacted: v, redactionsCount: 0 };
230
- freeformCount += r.redactionsCount;
231
- out[k] = r.redacted;
232
- }
233
- } else if (typeof v === 'string' && freeform) {
234
- const r = redactFreeformText(v);
235
- freeformCount += r.redactionsCount;
236
- out[k] = r.redacted;
237
297
  } else {
238
- out[k] = visit(v, [...pathSoFar, k]);
298
+ out[k] = visit(v, [...pathSoFar, k], traversalDepth + 1);
239
299
  }
240
300
  }
241
301
  return out;
242
302
  }
243
303
  return node;
244
- };
304
+ }
245
305
  const redacted = JSON.stringify(visit(parsed, []));
246
306
  return {
247
307
  redacted,
@@ -251,6 +311,147 @@ export function redactJsonBody(
251
311
  };
252
312
  }
253
313
 
314
+ function isAsciiHex(byte: number | undefined): boolean {
315
+ return (
316
+ byte !== undefined &&
317
+ ((byte >= 0x30 && byte <= 0x39) ||
318
+ (byte >= 0x41 && byte <= 0x46) ||
319
+ (byte >= 0x61 && byte <= 0x66))
320
+ );
321
+ }
322
+
323
+ /** Redact JSON rows inside a React Flight body without changing its framing. */
324
+ function redactFlightBody(
325
+ body: string,
326
+ freeform = false,
327
+ markerContext?: RedactionMarkerContext,
328
+ ): BodyRedaction {
329
+ const failClosed = (): BodyRedaction => ({
330
+ redacted: `[REDACTED:UNSAFE_FLIGHT_BODY:${Buffer.byteLength(body, 'utf8')}]`,
331
+ redactionsCount: 1,
332
+ placeholdersInjected: 0,
333
+ freeformRedactions: 0,
334
+ });
335
+ if (!body.endsWith('\n')) return failClosed();
336
+
337
+ const bytes = Buffer.from(body, 'utf8');
338
+ const output: Buffer[] = [];
339
+ let offset = 0;
340
+ let redactionsCount = 0;
341
+ let freeformRedactions = 0;
342
+ let rowCount = 0;
343
+ const jsonNodeBudget = { remaining: MAX_REACT_FLIGHT_JSON_NODES };
344
+
345
+ const redactJsonPayload = (json: string): BodyRedaction | null => {
346
+ try {
347
+ if (!hasBoundedJsonNesting(json)) return null;
348
+ const nodes = boundedJsonNodeCount(json, jsonNodeBudget.remaining);
349
+ if (nodes === null) return null;
350
+ jsonNodeBudget.remaining -= nodes;
351
+ JSON.parse(json);
352
+ return redactJsonBody(
353
+ json,
354
+ undefined,
355
+ freeform,
356
+ markerContext,
357
+ MAX_FLIGHT_JSON_NESTING_DEPTH,
358
+ jsonNodeBudget,
359
+ false,
360
+ );
361
+ } catch {
362
+ return null;
363
+ }
364
+ };
365
+
366
+ while (offset < bytes.length) {
367
+ if (++rowCount > MAX_REACT_FLIGHT_ROWS) return failClosed();
368
+ const lineEnd = bytes.indexOf(0x0a, offset);
369
+ if (lineEnd < 0 || lineEnd === offset) return failClosed();
370
+ if (bytes[offset] === 0x3a) {
371
+ const hint = bytes.toString('utf8', offset, lineEnd);
372
+ const match = /^:([A-Z]+)(.+)$/.exec(hint);
373
+ const redacted = match?.[2] ? redactJsonPayload(match[2]) : null;
374
+ if (!match?.[1] || !redacted) return failClosed();
375
+ redactionsCount += redacted.redactionsCount;
376
+ freeformRedactions += redacted.freeformRedactions;
377
+ output.push(Buffer.from(`:${match[1]}${redacted.redacted}\n`, 'utf8'));
378
+ offset = lineEnd + 1;
379
+ continue;
380
+ }
381
+
382
+ let idStart = offset;
383
+ if (bytes[idStart] === 0x23) idStart++;
384
+ let colon = idStart;
385
+ while (colon < bytes.length && isAsciiHex(bytes[colon])) colon++;
386
+ if (colon === idStart || bytes[colon] !== 0x3a) return failClosed();
387
+ const payloadStart = colon + 1;
388
+ if (payloadStart >= bytes.length) return failClosed();
389
+
390
+ if (bytes[payloadStart] === 0x54) {
391
+ let comma = payloadStart + 1;
392
+ while (comma < bytes.length && isAsciiHex(bytes[comma])) comma++;
393
+ if (comma === payloadStart + 1 || bytes[comma] !== 0x2c) return failClosed();
394
+ const length = Number.parseInt(bytes.toString('ascii', payloadStart + 1, comma), 16);
395
+ if (!Number.isSafeInteger(length)) return failClosed();
396
+ const textEnd = comma + 1 + length;
397
+ if (textEnd > bytes.length) return failClosed();
398
+ const text = bytes.toString('utf8', comma + 1, textEnd);
399
+ const redactedText = freeform
400
+ ? redactFreeformText(text)
401
+ : { redacted: text, redactionsCount: 0 };
402
+ freeformRedactions += redactedText.redactionsCount;
403
+ output.push(bytes.subarray(offset, payloadStart));
404
+ output.push(
405
+ Buffer.from(
406
+ `T${Buffer.byteLength(redactedText.redacted, 'utf8').toString(16)},${redactedText.redacted}`,
407
+ 'utf8',
408
+ ),
409
+ );
410
+ offset = textEnd;
411
+ continue;
412
+ }
413
+
414
+ const payload = bytes.toString('utf8', payloadStart, lineEnd);
415
+ if (/^[RrXx]$/.test(payload)) {
416
+ output.push(bytes.subarray(offset, lineEnd + 1));
417
+ offset = lineEnd + 1;
418
+ continue;
419
+ }
420
+ if (payload.startsWith('C')) {
421
+ if (payload === 'C') {
422
+ output.push(bytes.subarray(offset, lineEnd + 1));
423
+ offset = lineEnd + 1;
424
+ continue;
425
+ }
426
+ const redacted = redactJsonPayload(payload.slice(1));
427
+ if (!redacted) return failClosed();
428
+ redactionsCount += redacted.redactionsCount;
429
+ freeformRedactions += redacted.freeformRedactions;
430
+ output.push(bytes.subarray(offset, payloadStart));
431
+ output.push(Buffer.from(`C${redacted.redacted}\n`, 'utf8'));
432
+ offset = lineEnd + 1;
433
+ continue;
434
+ }
435
+ const tagged = /^([A-Z]+)(?=[\[{\"\d\-ntf])(.*)$/.exec(payload);
436
+ const tag = tagged?.[1] ?? '';
437
+ const json = tagged?.[2] ?? payload;
438
+ const redacted = redactJsonPayload(json);
439
+ if (!redacted) return failClosed();
440
+ redactionsCount += redacted.redactionsCount;
441
+ freeformRedactions += redacted.freeformRedactions;
442
+ output.push(bytes.subarray(offset, payloadStart));
443
+ output.push(Buffer.from(`${tag}${redacted.redacted}\n`, 'utf8'));
444
+ offset = lineEnd + 1;
445
+ }
446
+
447
+ return {
448
+ redacted: Buffer.concat(output).toString('utf8'),
449
+ redactionsCount,
450
+ placeholdersInjected: 0,
451
+ freeformRedactions,
452
+ };
453
+ }
454
+
254
455
  /** Redact a request body of unknown content-type. Tries JSON first, falls back to form. */
255
456
  export function redactBody(
256
457
  body: string,
@@ -264,6 +465,9 @@ export function redactBody(
264
465
  if (ct.includes('urlencoded')) {
265
466
  return redactFormBody(body, formPlaceholders, markerContext);
266
467
  }
468
+ if (ct.includes('text/x-component')) {
469
+ return redactFlightBody(body, freeform, markerContext);
470
+ }
267
471
  // Try JSON first — many APIs send JSON as text/plain or with no content-type.
268
472
  const jsonR = redactJsonBody(body, jsonPlaceholders, freeform, markerContext);
269
473
  if (jsonR.redactionsCount > 0 || jsonR.placeholdersInjected > 0 || jsonR.freeformRedactions > 0) {
@@ -509,16 +713,22 @@ export function redactSession(
509
713
  touched += respHeadersR.redactionsCount;
510
714
  let respBody = response.body;
511
715
  if (respBody) {
716
+ const responseContentType =
717
+ response.mimeType ||
718
+ Object.entries(response.headers).find(
719
+ ([name]) => name.toLowerCase() === 'content-type',
720
+ )?.[1];
721
+ const isFlight =
722
+ (responseContentType ?? '').toLowerCase().split(';', 1)[0]?.trim() === 'text/x-component';
512
723
  const respBodyR = redactBody(
513
724
  respBody,
514
- response.mimeType,
725
+ responseContentType,
515
726
  undefined,
516
727
  undefined,
517
- // Responses are key-based only: never value-pattern (freeform) scan a
518
- // server body. Keeps redaction focused on real secrets (post-login
519
- // cookies + user-entered PII) and avoids corrupting structured RPC
520
- // envelopes whose payloads are doubly-encoded JSON.
521
- false,
728
+ // Flight rows have a framing-aware redactor, so rendered free-form
729
+ // PII can be scrubbed without corrupting JSON or T-row byte lengths.
730
+ // Other response envelopes remain key-based only.
731
+ isFlight ? useFreeform : false,
522
732
  markerContext,
523
733
  );
524
734
  respBody = respBodyR.redacted;
@@ -0,0 +1,73 @@
1
+ import { RSC_USER_INTENT_WINDOW_MS, matchesIntendedRoute } from './response-body-stream.ts';
2
+
3
+ interface UserIntent {
4
+ atMs: number;
5
+ url: string;
6
+ activation: boolean;
7
+ }
8
+
9
+ const MAX_TRACKED_INTENTS = 256;
10
+ const MAX_INTENT_URL_CHARS = 4_096;
11
+
12
+ export type ResponseBodyIntentKind = 'pointerover' | 'pointerdown' | 'focusin' | 'click';
13
+
14
+ function isActivation(kind: ResponseBodyIntentKind | undefined): boolean {
15
+ return kind === 'pointerdown' || kind === 'click';
16
+ }
17
+
18
+ /**
19
+ * Correlates browser-timestamped DOM intent with requests independently of the
20
+ * order in which Runtime and Network domain events reach the CDP client.
21
+ */
22
+ export class ResponseBodyIntentTracker {
23
+ readonly #intents: UserIntent[] = [];
24
+
25
+ record(atMs: number, url: string | undefined, kind?: ResponseBodyIntentKind): void {
26
+ if (!Number.isFinite(atMs) || !url || url.length > MAX_INTENT_URL_CHARS) return;
27
+ const intent = { atMs, url, activation: isActivation(kind) };
28
+ let low = 0;
29
+ let high = this.#intents.length;
30
+ while (low < high) {
31
+ const middle = (low + high) >>> 1;
32
+ if ((this.#intents[middle]?.atMs ?? atMs) <= atMs) low = middle + 1;
33
+ else high = middle;
34
+ }
35
+ this.#intents.splice(low, 0, intent);
36
+
37
+ const newest = this.#intents.at(-1)?.atMs ?? atMs;
38
+ const cutoff = newest - RSC_USER_INTENT_WINDOW_MS * 2;
39
+ while ((this.#intents[0]?.atMs ?? newest) < cutoff) this.#intents.shift();
40
+ if (this.#intents.length > MAX_TRACKED_INTENTS) {
41
+ this.#intents.splice(0, this.#intents.length - MAX_TRACKED_INTENTS);
42
+ }
43
+ }
44
+
45
+ match(requestUrl: string, requestAtMs: number): string | undefined {
46
+ return this.#match(requestUrl, requestAtMs, false);
47
+ }
48
+
49
+ matchActivation(requestUrl: string, requestAtMs: number): string | undefined {
50
+ return this.#match(requestUrl, requestAtMs, true);
51
+ }
52
+
53
+ #match(requestUrl: string, requestAtMs: number, requireActivation: boolean): string | undefined {
54
+ for (let index = this.#intents.length - 1; index >= 0; index--) {
55
+ const intent = this.#intents[index];
56
+ if (!intent) continue;
57
+ if (intent.atMs > requestAtMs) continue;
58
+ if (requestAtMs - intent.atMs > RSC_USER_INTENT_WINDOW_MS) break;
59
+ if (requireActivation && !intent.activation) continue;
60
+ if (matchesIntendedRoute(requestUrl, intent.url)) return intent.url;
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ hasMatchingActivationSince(requestUrl: string, requestAtMs: number): boolean {
66
+ return this.#intents.some(
67
+ (intent) =>
68
+ intent.activation &&
69
+ intent.atMs >= requestAtMs &&
70
+ matchesIntendedRoute(requestUrl, intent.url),
71
+ );
72
+ }
73
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Coordinates terminal events with response-body work after the 30-second
3
+ * loading wait has expired. A terminal event must not invalidate a request
4
+ * generation while its final `getResponseBody` call is still in flight.
5
+ */
6
+ export class RequestBodyLifecycleTracker {
7
+ readonly #active = new Set<string>();
8
+ readonly #terminal = new Set<string>();
9
+
10
+ #key(requestId: string, generation: number): string {
11
+ return `${requestId}\0${generation}`;
12
+ }
13
+
14
+ begin(requestId: string, generation: number): void {
15
+ this.#active.add(this.#key(requestId, generation));
16
+ }
17
+
18
+ /** Returns true when the generation can be deleted immediately. */
19
+ markTerminal(requestId: string, generation: number): boolean {
20
+ const key = this.#key(requestId, generation);
21
+ if (!this.#active.has(key)) return true;
22
+ this.#terminal.add(key);
23
+ return false;
24
+ }
25
+
26
+ /** Returns true when a terminal event arrived while the body work was active. */
27
+ finish(requestId: string, generation: number): boolean {
28
+ const key = this.#key(requestId, generation);
29
+ this.#active.delete(key);
30
+ return this.#terminal.delete(key);
31
+ }
32
+ }