diff --git a/packages/runtime-node/src/relay.ts b/packages/runtime-node/src/relay.ts index 1db3d59..f03a4a0 100644 --- a/packages/runtime-node/src/relay.ts +++ b/packages/runtime-node/src/relay.ts @@ -21,6 +21,17 @@ export interface RelayOptions { * `false` to disable (e.g. when a WAF already rate-limits). */ perIpRateLimit?: number | false; + /** + * Trust the client-supplied `X-Forwarded-For` header when keying the per-IP + * rate limit. Off by default: the header is spoofable, so an attacker could + * rotate it to bypass the window and drive unbounded parsing/forwarding + * under the server's ingest key. Enable ONLY behind a proxy/CDN you control + * that overwrites the header. When off, the fetch handler keys a single + * shared bucket, and the Node handler keys the real socket peer address. + * Only a strict boolean `true` enables it — a truthy string such as the + * common `process.env.TRUST_PROXY === "false"` slip stays on the safe path. + */ + trustProxy?: boolean; /** Called when the async forward fails (default: console.warn). */ onError?: (err: unknown) => void; } @@ -62,6 +73,184 @@ 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; + +// Redaction — the relay attaches the server's private ingest key and forwards +// browser-supplied context into privileged telemetry, so context must never +// carry secrets. We redact on two axes, at every nesting level: by KEY NAME +// (authorization, cookie, token, password, *_secret, *_key, session, jwt, …) +// and by secret-shaped VALUE (Bearer/Basic auth strings, JWTs) even under a +// benign/custom key. Numeric/boolean values under a matched key are kept — +// they can never be a credential, and this preserves usage counts such as +// `input_tokens`. +const REDACTED = "[redacted]"; +const SECRET_KEY_RE = + /(password|passwd|pwd|passphrase|passcode|secret|token|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|private[_-]?key|authorization|cookie|session[_-]?id|sessionid|session|credentials?|bearer|jwt|otp|x-api-key|signature)/i; +const SECRET_VALUE_RE = /^\s*(bearer|basic)\s+\S+/i; +const JWT_RE = /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]+/; + +/** Redact a string that looks like a credential (auth header value / JWT). */ +function scrubSecretValue(s: string): string { + return SECRET_VALUE_RE.test(s) || JWT_RE.test(s) ? REDACTED : s; +} + +/** 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 scrubSecretValue((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++; + // Redact secret-bearing keys at any depth. Numeric/boolean values + // can't be credentials and are preserved (e.g. usage counts); any + // other value (string, nested object/array) is dropped entirely. + if (SECRET_KEY_RE.test(key)) { + let raw: unknown; + try { + raw = (obj as Record)[key]; + } catch { + out[key] = REDACTED; + continue; + } + const rt = typeof raw; + out[key] = rt === "number" || rt === "boolean" ? raw : REDACTED; + continue; + } + 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) }; +} + // 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. @@ -103,7 +292,7 @@ export function sanitizeBrowserPayload(raw: unknown): object | null { ? { route: e.route.split("?")[0]!.slice(0, 1000) } : {}), ...(typeof e.context === "object" && e.context !== null - ? { context: e.context } + ? { context: boundContext(e.context) } : {}), }); } @@ -157,23 +346,37 @@ export function createBrowserRelayFetchHandler( return new Response(null, { status: 405 }); } if (limiter) { + // Only honor X-Forwarded-For behind an explicitly trusted proxy — + // otherwise a caller could spoof a fresh IP per request to bypass + // the window. With no trusted peer source in a fetch runtime, fall + // back to one shared bucket (a conservative global limit). const ip = - firstForwardedFor(request.headers.get("x-forwarded-for")) || "unknown"; + opts.trustProxy === true + ? firstForwardedFor(request.headers.get("x-forwarded-for")) || + "unknown" + : "shared"; if (!limiter.allow(ip)) { return new Response(JSON.stringify({ error: "rate limit exceeded" }), { status: 429, }); } } - 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, @@ -236,8 +439,12 @@ export function createBrowserRelayHandler( return; } if (limiter) { + // Prefer the real socket peer; only trust X-Forwarded-For when the + // caller has explicitly opted into a trusted-proxy deployment. const ip = - firstForwardedFor(req.headers["x-forwarded-for"]) || + (opts.trustProxy === true + ? firstForwardedFor(req.headers["x-forwarded-for"]) + : "") || req.socket?.remoteAddress || "unknown"; if (!limiter.allow(ip)) { diff --git a/packages/runtime-node/test/relay.test.mjs b/packages/runtime-node/test/relay.test.mjs new file mode 100644 index 0000000..8963aa3 --- /dev/null +++ b/packages/runtime-node/test/relay.test.mjs @@ -0,0 +1,271 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +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: a truthy but non-true trustProxy stays on the safe shared bucket", async () => { + const handler = createBrowserRelayFetchHandler({ + apiKey: "autter_rt_test", + perIpRateLimit: 1, + // a common config slip: an env string "false" is truthy but must NOT + // enable forwarded-header trust + trustProxy: "false", + }); + const mk = (ip) => + new Request("http://localhost/relay", { + method: "POST", + headers: { "x-forwarded-for": ip }, + body: "{", + }); + const first = await handler(mk("1.1.1.1")); + const second = await handler(mk("2.2.2.2")); + assert.equal(first.status, 400); + assert.equal(second.status, 429); // shared bucket — not fooled by "false" +}); + +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: spoofed X-Forwarded-For cannot bypass the rate limit by default", async () => { + const handler = createBrowserRelayFetchHandler({ + apiKey: "autter_rt_test", + perIpRateLimit: 1, + }); + const mk = (ip) => + new Request("http://localhost/relay", { + method: "POST", + headers: { "x-forwarded-for": ip }, + body: "{", + }); + const first = await handler(mk("1.1.1.1")); + const second = await handler(mk("2.2.2.2")); + assert.equal(first.status, 400); // passed rate limit, then invalid JSON + assert.equal(second.status, 429); // shared bucket — spoofed IP can't bypass +}); + +test("relay: trustProxy honors distinct X-Forwarded-For buckets", async () => { + const handler = createBrowserRelayFetchHandler({ + apiKey: "autter_rt_test", + perIpRateLimit: 1, + trustProxy: true, + }); + const mk = (ip) => + new Request("http://localhost/relay", { + method: "POST", + headers: { "x-forwarded-for": ip }, + body: "{", + }); + const a = await handler(mk("1.1.1.1")); + const b = await handler(mk("2.2.2.2")); + assert.notEqual(a.status, 429); + assert.notEqual(b.status, 429); +}); + +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); +});