From 882b97138c178893e9349a7860825e62f7960303 Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Thu, 3 Sep 2026 11:27:37 +0530 Subject: [PATCH 1/4] fix(runtime-node): bound and harden the browser relay context and body-size check The browser relay sanitiser length-caps every event field except context, which was forwarded as-is. Bound context with a depth, node, string, and array/key limit (cycle-safe, and proxy-safe: revoked or hostile Proxy traps on classification, length, key enumeration, or element reads are dropped rather than thrown out of the sanitizer). Enforce maxBodyBytes in the fetch handler while consuming the request stream, counting real UTF-8 bytes and cancelling once the limit is crossed, instead of buffering the whole body via request.text() first. Add the first tests for relay.ts. No public API or behaviour change. --- packages/runtime-node/src/relay.ts | 152 +++++++++++++++++++++- packages/runtime-node/test/relay.test.mjs | 115 ++++++++++++++++ 2 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 packages/runtime-node/test/relay.test.mjs diff --git a/packages/runtime-node/src/relay.ts b/packages/runtime-node/src/relay.ts index 1db3d59..4e34c4d 100644 --- a/packages/runtime-node/src/relay.ts +++ b/packages/runtime-node/src/relay.ts @@ -62,6 +62,143 @@ 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; + nodes++; + const key = keys[i]!; + 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) { + await reader.cancel(); + 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 +240,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) } : {}), }); } @@ -165,15 +302,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 new file mode 100644 index 0000000..33bdf19 --- /dev/null +++ b/packages/runtime-node/test/relay.test.mjs @@ -0,0 +1,115 @@ +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: 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); +}); From a2530078084bfbbc5055a02f4fc5c24db1ce2ba2 Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Thu, 3 Sep 2026 16:15:52 +0530 Subject: [PATCH 2/4] fix(runtime-node): drop non-null assertion and keep 413 when relay stream cancel fails --- packages/runtime-node/src/relay.ts | 12 +++++++++-- packages/runtime-node/test/relay.test.mjs | 26 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/runtime-node/src/relay.ts b/packages/runtime-node/src/relay.ts index 4e34c4d..3f27b50 100644 --- a/packages/runtime-node/src/relay.ts +++ b/packages/runtime-node/src/relay.ts @@ -137,8 +137,9 @@ export function boundContext(value: unknown): unknown { 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++; - const key = keys[i]!; let child: unknown; try { child = walk((obj as Record)[key], depth + 1); @@ -185,7 +186,14 @@ async function readBodyBounded( if (!value) continue; total += value.byteLength; if (total > maxBody) { - await reader.cancel(); + // Best-effort cancel: a stream whose cancel() throws or rejects + // must still surface as "too large" (413), never fall through to + // the caller's JSON-error branch and become a 400. + try { + await reader.cancel(); + } catch { + /* ignore — the oversize decision is already made */ + } return { tooLarge: true }; } chunks.push(value); diff --git a/packages/runtime-node/test/relay.test.mjs b/packages/runtime-node/test/relay.test.mjs index 33bdf19..85df388 100644 --- a/packages/runtime-node/test/relay.test.mjs +++ b/packages/runtime-node/test/relay.test.mjs @@ -103,6 +103,32 @@ test("relay: fetch handler rejects an oversized body", async () => { 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", From a58b38fafd16c3c74b27557ff221a6615ca21176 Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Sun, 6 Sep 2026 02:32:29 +0530 Subject: [PATCH 3/4] fix(runtime-node): redact secrets in relay context, harden rate-limit IP source, detach oversized-body cancel --- packages/runtime-node/src/relay.ts | 76 ++++++++++++--- packages/runtime-node/test/relay.test.mjs | 108 ++++++++++++++++++++++ 2 files changed, 172 insertions(+), 12 deletions(-) diff --git a/packages/runtime-node/src/relay.ts b/packages/runtime-node/src/relay.ts index 3f27b50..9bf6421 100644 --- a/packages/runtime-node/src/relay.ts +++ b/packages/runtime-node/src/relay.ts @@ -21,6 +21,15 @@ 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. + */ + trustProxy?: boolean; /** Called when the async forward fails (default: console.warn). */ onError?: (err: unknown) => void; } @@ -74,6 +83,25 @@ 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; @@ -91,7 +119,7 @@ export function boundContext(value: unknown): unknown { 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 === "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; @@ -140,6 +168,21 @@ export function boundContext(value: unknown): unknown { 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); @@ -186,14 +229,13 @@ async function readBodyBounded( if (!value) continue; total += value.byteLength; if (total > maxBody) { - // Best-effort cancel: a stream whose cancel() throws or rejects - // must still surface as "too large" (413), never fall through to - // the caller's JSON-error branch and become a 400. - try { - await reader.cancel(); - } catch { - /* ignore — the oversize decision is already made */ - } + // 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); @@ -302,8 +344,14 @@ export function createBrowserRelayFetchHandler( return new Response(null, { status: 405 }); } if (limiter) { - const ip = - firstForwardedFor(request.headers.get("x-forwarded-for")) || "unknown"; + // 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 = opts.trustProxy + ? firstForwardedFor(request.headers.get("x-forwarded-for")) || + "unknown" + : "shared"; if (!limiter.allow(ip)) { return new Response(JSON.stringify({ error: "rate limit exceeded" }), { status: 429, @@ -388,8 +436,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 + ? 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 index 85df388..978d8f6 100644 --- a/packages/runtime-node/test/relay.test.mjs +++ b/packages/runtime-node/test/relay.test.mjs @@ -103,6 +103,114 @@ test("relay: fetch handler rejects an oversized body", async () => { 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", () => { + const ctx = sanitizeContext({ + note: "Bearer supersecrettoken12345", + jwtish: + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + 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: 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", From 8716ca7b7fd0ba8696082f8bff06fe59bf68896f Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Sun, 6 Sep 2026 02:57:12 +0530 Subject: [PATCH 4/4] fix(runtime-node): require strict trustProxy===true, drop JWT literal from relay tests --- packages/runtime-node/src/relay.ts | 13 +++++++----- packages/runtime-node/test/relay.test.mjs | 26 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/runtime-node/src/relay.ts b/packages/runtime-node/src/relay.ts index 9bf6421..f03a4a0 100644 --- a/packages/runtime-node/src/relay.ts +++ b/packages/runtime-node/src/relay.ts @@ -28,6 +28,8 @@ export interface RelayOptions { * 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). */ @@ -348,10 +350,11 @@ export function createBrowserRelayFetchHandler( // 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 = opts.trustProxy - ? firstForwardedFor(request.headers.get("x-forwarded-for")) || - "unknown" - : "shared"; + const ip = + 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, @@ -439,7 +442,7 @@ export function createBrowserRelayHandler( // Prefer the real socket peer; only trust X-Forwarded-For when the // caller has explicitly opted into a trusted-proxy deployment. const ip = - (opts.trustProxy + (opts.trustProxy === true ? firstForwardedFor(req.headers["x-forwarded-for"]) : "") || req.socket?.remoteAddress || diff --git a/packages/runtime-node/test/relay.test.mjs b/packages/runtime-node/test/relay.test.mjs index 978d8f6..8963aa3 100644 --- a/packages/runtime-node/test/relay.test.mjs +++ b/packages/runtime-node/test/relay.test.mjs @@ -120,10 +120,12 @@ test("relay: secret-bearing context keys are redacted (top level and nested)", ( }); 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: - "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + jwtish: jwtLike, plain: "just a normal message", }); assert.equal(ctx.note, "[redacted]"); @@ -131,6 +133,26 @@ test("relay: secret-shaped values under benign keys are scrubbed", () => { 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,