diff --git a/packages/runtime-node/src/relay.ts b/packages/runtime-node/src/relay.ts index 3b05be5..d7e5da5 100644 --- a/packages/runtime-node/src/relay.ts +++ b/packages/runtime-node/src/relay.ts @@ -92,6 +92,157 @@ const EVENT_TYPES = new Set([ const SEVERITIES = new Set(["fatal", "error", "warning", "info"]); +// Bound a browser-supplied `context` object so it honors the sanitiser's +// guarantee that a client can't smuggle unbounded cookies/DOM/bodies through +// the relay: like every other field, context is capped — bounded depth, a +// total-node budget, per-string length, and array/key limits. Cycles and +// throwing/revoked Proxy traps fail open (that value is dropped, sanitising +// continues). +const CONTEXT_MAX_DEPTH = 6; +const CONTEXT_MAX_NODES = 256; +const CONTEXT_MAX_STRING = 4000; +const CONTEXT_MAX_ARRAY = 100; +const CONTEXT_MAX_KEYS = 100; + +/** UTF-8 byte length of a string (portable across edge runtimes). */ +export function byteLength(text: string): number { + return new TextEncoder().encode(text).length; +} + +/** + * Bounded, cycle-safe deep copy of an untrusted `context` value. Anything + * past a depth/node/length limit, a cycle, or a hostile/revoked Proxy (whose + * trap throws on classification, `length`, key enumeration, or element reads) + * is dropped. Never throws — returns a plain, bounded object. + */ +export function boundContext(value: unknown): unknown { + let nodes = 0; + const seen = new WeakSet(); + const walk = (v: unknown, depth: number): unknown => { + if (v === null) return null; + const t = typeof v; + if (t === "string") return (v as string).slice(0, CONTEXT_MAX_STRING); + if (t === "number" || t === "boolean") return v; + if (t !== "object") return undefined; + if (depth >= CONTEXT_MAX_DEPTH || nodes >= CONTEXT_MAX_NODES) return undefined; + const obj = v as object; + if (seen.has(obj)) return undefined; + seen.add(obj); + // Array.isArray can throw on a revoked Proxy — guard the classification. + let isArr = false; + try { + isArr = Array.isArray(v); + } catch { + return undefined; + } + if (isArr) { + const arr = v as unknown[]; + const out: unknown[] = []; + // `length` can be a throwing/hostile trap — guard the read. + let len = 0; + try { + len = arr.length; + } catch { + return out; + } + for (let i = 0; i < len && i < CONTEXT_MAX_ARRAY; i++) { + if (nodes >= CONTEXT_MAX_NODES) break; + nodes++; + let el: unknown; + try { + el = walk(arr[i], depth + 1); + } catch { + el = undefined; + } + if (el !== undefined) out.push(el); + } + return out; + } + let keys: string[]; + try { + keys = Object.keys(obj); + } catch { + return undefined; + } + const out: Record = {}; + for (let i = 0; i < keys.length && i < CONTEXT_MAX_KEYS; i++) { + if (nodes >= CONTEXT_MAX_NODES) break; + const key = keys[i]; + if (key === undefined) continue; + nodes++; + let child: unknown; + try { + child = walk((obj as Record)[key], depth + 1); + } catch { + child = undefined; + } + if (child !== undefined) out[key] = child; + } + return out; + }; + let result: unknown; + try { + result = walk(value, 0); + } catch { + result = undefined; + } + return result && typeof result === "object" ? result : {}; +} + +/** + * Read a fetch `Request` body while enforcing `maxBody` as it is consumed, + * counting real UTF-8 bytes from the byte stream (so multibyte payloads are + * measured correctly). An oversized body is rejected as soon as the limit is + * crossed — the stream is cancelled instead of being fully buffered first. + */ +async function readBodyBounded( + request: Request, + maxBody: number, +): Promise<{ tooLarge: true } | { tooLarge: false; text: string }> { + const body = request.body; + if (!body) { + // No readable stream to meter — fall back to a buffered read + byte check. + const text = await request.text(); + return byteLength(text) > maxBody + ? { tooLarge: true } + : { tooLarge: false, text }; + } + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBody) { + // Cancel is fire-and-forget: awaiting a cancel() that throws, + // rejects, or never settles would hang the response (or drop it to + // a 400). The oversize decision is already made — detach the cancel + // and return 413 immediately. + void Promise.resolve() + .then(() => reader.cancel()) + .catch(() => {}); + return { tooLarge: true }; + } + chunks.push(value); + } + const buf = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + buf.set(chunk, offset); + offset += chunk.byteLength; + } + return { tooLarge: false, text: new TextDecoder().decode(buf) }; +} + +// Bound first (cycle- and Proxy-safe), then scrub with the shared redactor. +// Arrays are not valid context and are dropped, as before. +function relayContext(raw: object, redactor: Redactor): { context?: Attributes } { + const bounded = boundContext(raw); + return Array.isArray(bounded) ? {} : { context: redactor(bounded as Attributes) }; +} + // Whitelist sanitiser — anything not listed here is dropped, so a // compromised or buggy client can't smuggle cookies/DOM/bodies through the // relay. Returns null when the payload is structurally invalid. @@ -143,10 +294,8 @@ export function sanitizeBrowserPayload( ...(typeof e.route === "string" ? { route: e.route.split("?")[0]!.slice(0, 1000) } : {}), - ...(typeof e.context === "object" && - e.context !== null && - !Array.isArray(e.context) - ? { context: redactor(e.context as Attributes) } + ...(typeof e.context === "object" && e.context !== null + ? relayContext(e.context, redactor) : {}), }); } @@ -227,15 +376,22 @@ export function createBrowserRelayFetchHandler( }); } } - const text = await request.text(); - if (text.length > maxBody) { + let bounded: { tooLarge: true } | { tooLarge: false; text: string }; + try { + bounded = await readBodyBounded(request, maxBody); + } catch { + return new Response(JSON.stringify({ error: "invalid json" }), { + status: 400, + }); + } + if (bounded.tooLarge) { return new Response(JSON.stringify({ error: "payload too large" }), { status: 413, }); } let raw: unknown; try { - raw = JSON.parse(text); + raw = JSON.parse(bounded.text); } catch { return new Response(JSON.stringify({ error: "invalid json" }), { status: 400, diff --git a/packages/runtime-node/test/relay.test.mjs b/packages/runtime-node/test/relay.test.mjs index 34c7f34..2197afb 100644 --- a/packages/runtime-node/test/relay.test.mjs +++ b/packages/runtime-node/test/relay.test.mjs @@ -1,6 +1,219 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { sanitizeBrowserPayload } from "../dist/index.js"; +import { + sanitizeBrowserPayload, + createBrowserRelayFetchHandler, +} from "../dist/index.js"; + +function sanitizeContext(context) { + const out = sanitizeBrowserPayload({ + version: 1, + service: "svc", + environment: "test", + events: [{ type: "message", timestamp: "t", context }], + }); + assert.ok(out, "payload should be valid"); + return out.events[0].context; +} + +test("relay: deeply nested context is bounded, not passed through raw", () => { + let deep = "leaf"; + for (let i = 0; i < 30; i++) deep = { n: deep }; + const ctx = sanitizeContext(deep); + assert.equal(typeof ctx, "object"); + assert.doesNotThrow(() => JSON.stringify(ctx)); +}); + +test("relay: context strings are length-capped", () => { + const ctx = sanitizeContext({ big: "x".repeat(9000) }); + assert.equal(ctx.big.length, 4000); +}); + +test("relay: context arrays are element-capped", () => { + const ctx = sanitizeContext({ arr: Array.from({ length: 500 }, (_, i) => i) }); + assert.equal(ctx.arr.length, 100); +}); + +test("relay: cyclic context does not hang or throw", () => { + const cyclic = { a: 1 }; + cyclic.self = cyclic; + const ctx = sanitizeContext(cyclic); + assert.equal(ctx.a, 1); + assert.equal(ctx.self, undefined); +}); + +test("relay: non-serialisable context values are dropped", () => { + const ctx = sanitizeContext({ fn: () => 1, keep: 2 }); + assert.equal(ctx.fn, undefined); + assert.equal(ctx.keep, 2); +}); + +test("relay: revoked Proxy context is dropped without throwing", () => { + const { proxy, revoke } = Proxy.revocable({ a: 1 }, {}); + revoke(); + assert.doesNotThrow(() => sanitizeContext(proxy)); +}); + +test("relay: hostile Proxy length trap in context does not throw", () => { + const hostile = new Proxy([], { + get(_t, prop) { + if (prop === "length") throw new Error("boom"); + return undefined; + }, + }); + assert.doesNotThrow(() => sanitizeContext(hostile)); +}); + +test("relay: hostile Proxy element getter in context does not throw", () => { + const hostile = new Proxy([1, 2, 3], { + get(target, prop) { + if (prop === "0") throw new Error("boom"); + return target[prop]; + }, + }); + assert.doesNotThrow(() => sanitizeContext(hostile)); +}); + +test("relay: fetch size limit counts UTF-8 bytes, not code units", async () => { + const handler = createBrowserRelayFetchHandler({ + apiKey: "autter_rt_test", + perIpRateLimit: false, + maxBodyBytes: 10, + }); + const body = "அ".repeat(6); + assert.equal(body.length, 6); + const res = await handler( + new Request("http://localhost/relay", { method: "POST", body }), + ); + assert.equal(res.status, 413); +}); + +test("relay: fetch handler rejects an oversized body", async () => { + const handler = createBrowserRelayFetchHandler({ + apiKey: "autter_rt_test", + perIpRateLimit: false, + maxBodyBytes: 100, + }); + const res = await handler( + new Request("http://localhost/relay", { + method: "POST", + body: "a".repeat(500), + }), + ); + assert.equal(res.status, 413); +}); + +test("relay: secret-bearing context keys are redacted (top level and nested)", () => { + const ctx = sanitizeContext({ + password: "hunter2", + token: "abc", + authorization: "Bearer x", + request: { headers: { cookie: "sid=1", authorization: "Bearer victim" } }, + keep: "ok", + }); + assert.equal(ctx.password, "[redacted]"); + assert.equal(ctx.token, "[redacted]"); + assert.equal(ctx.authorization, "[redacted]"); + assert.equal(ctx.request.headers.cookie, "[redacted]"); + assert.equal(ctx.request.headers.authorization, "[redacted]"); + assert.equal(ctx.keep, "ok"); +}); + +test("relay: secret-shaped values under benign keys are scrubbed", () => { + // Built at runtime so no JWT-shaped literal sits in source (would trip + // secret scanners); the parts are meaningless placeholders. + const jwtLike = ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiJ0ZXN0In0", "0".repeat(22)].join("."); + const ctx = sanitizeContext({ + note: "Bearer supersecrettoken12345", + jwtish: jwtLike, + plain: "just a normal message", + }); + assert.equal(ctx.note, "[redacted]"); + assert.equal(ctx.jwtish, "[redacted]"); + assert.equal(ctx.plain, "just a normal message"); +}); + +test("relay: numeric usage counts under token/session keys are preserved", () => { + const ctx = sanitizeContext({ + input_tokens: 500, + output_tokens: 1200, + total_tokens: 1700, + sessions: 3, + }); + assert.deepEqual(ctx, { + input_tokens: 500, + output_tokens: 1200, + total_tokens: 1700, + sessions: 3, + }); +}); + +test("relay: oversized body returns 413 even when cancel() never settles", async () => { + const handler = createBrowserRelayFetchHandler({ + apiKey: "autter_rt_test", + perIpRateLimit: false, + maxBodyBytes: 10, + }); + const big = new Uint8Array(100); + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(big); + }, + cancel() { + return new Promise(() => {}); // never settles + }, + }); + const res = await Promise.race([ + handler( + new Request("http://localhost/relay", { + method: "POST", + body, + duplex: "half", + }), + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error("handler hung")), 2000), + ), + ]); + assert.equal(res.status, 413); +}); + +test("relay: oversized body stays 413 even when the stream's cancel() rejects", async () => { + const handler = createBrowserRelayFetchHandler({ + apiKey: "autter_rt_test", + perIpRateLimit: false, + maxBodyBytes: 10, + }); + const big = new Uint8Array(100); + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(big); + }, + cancel() { + // a hostile/broken stream whose cancel throws must not downgrade 413 to 400 + throw new Error("hostile cancel"); + }, + }); + const res = await handler( + new Request("http://localhost/relay", { + method: "POST", + body, + duplex: "half", + }), + ); + assert.equal(res.status, 413); +}); + +test("relay: fetch size limit allows a body within the byte budget", async () => { + const handler = createBrowserRelayFetchHandler({ + apiKey: "autter_rt_test", + perIpRateLimit: false, + }); + const res = await handler( + new Request("http://localhost/relay", { method: "POST", body: "{" }), + ); + assert.equal(res.status, 400); +}); const event = { type: "csp_violation",