-
Notifications
You must be signed in to change notification settings - Fork 1
fix(runtime-node): bound browser relay context and count body size in bytes #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
882b971
a253007
a58b38f
8716ca7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,9 +18,20 @@ | |
| /** | ||
| * The relay route is necessarily public (browsers must reach it), so it | ||
| * ships with a per-IP fixed-window limit. Default 120 req/min; set | ||
| * `false` to disable (e.g. when a WAF already rate-limits). | ||
|
Check warning on line 21 in packages/runtime-node/src/relay.ts
|
||
| */ | ||
| perIpRateLimit?: number | false; | ||
| /** | ||
|
Check warning on line 24 in packages/runtime-node/src/relay.ts
|
||
| * 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 [ai] Non-boolean Both relay handlers use
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| /** Called when the async forward fails (default: console.warn). */ | ||
| onError?: (err: unknown) => void; | ||
| } | ||
|
|
@@ -62,6 +73,184 @@ | |
|
|
||
| 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 = | ||
|
Check warning on line 97 in packages/runtime-node/src/relay.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Browser relay retains API-key-shaped values under benign context keys — Risk: 57/100
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| /(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<object>(); | ||
| 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)); | ||
|
Check failure on line 124 in packages/runtime-node/src/relay.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Secret values under benign context keys still reach privileged telemetry — Risk: 78/100
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [ai] Browser context still forwards credential formats under benign keys — Risk: 88/100 This public relay copies every string at a non-matching context key and only calls
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [ai] Public relay still forwards credential-shaped values under benign context keys — Risk: 84/100 The relay route is deliberately unauthenticated, yet every accepted event is forwarded with the server ingest key.
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Relay leaves recognized credential formats under benign context keys — Risk: 78/100 This path only redacts Bearer/Basic-prefixed strings and JWT-shaped values. A public relay caller can put an OpenAI key (
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 [heuristic] Generic placeholder identifier in production logic — Risk: 45/100 Identifier
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| 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<string, unknown> = {}; | ||
| 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<string, unknown>)[key]; | ||
| } catch { | ||
| out[key] = REDACTED; | ||
| continue; | ||
| } | ||
| const rt = typeof raw; | ||
| out[key] = rt === "number" || rt === "boolean" ? raw : REDACTED; | ||
|
Check failure on line 185 in packages/runtime-node/src/relay.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Numeric credentials are intentionally forwarded from browser context — Risk: 74/100
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Sensitive numeric context values are forwarded intact — Risk: 78/100 For every key matched by
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Public relay preserves numeric credentials under sensitive context keys — Risk: 77/100 The browser relay is public and attaches the private ingest key when it forwards accepted payloads, but the sensitive-key branch explicitly preserves all number and boolean values. An unauthenticated caller can submit a numeric OTP, session ID, recovery code, or passcode in context (for example
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Sensitive numeric context values are explicitly forwarded — Risk: 74/100 For every key matched by SECRET_KEY_RE, this branch preserves any numeric or boolean value rather than redacting it. The public handlers pass object-valued event.context to boundContext, so
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| continue; | ||
| } | ||
| let child: unknown; | ||
| try { | ||
| child = walk((obj as Record<string, unknown>)[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 @@ | |
| ? { route: e.route.split("?")[0]!.slice(0, 1000) } | ||
| : {}), | ||
| ...(typeof e.context === "object" && e.context !== null | ||
| ? { context: e.context } | ||
| ? { context: boundContext(e.context) } | ||
|
Check failure on line 295 in packages/runtime-node/src/relay.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Browser relay forwards arbitrary secret-bearing context fields — Risk: 68/100
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Sensitive data in logs — Risk: 86/100
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Browser relay forwards unredacted client-supplied context — Risk: 41/100 The relay accepts arbitrary object-valued
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [ai] Nested credentials in public relay context are forwarded without redaction — Risk: 82/100 The public same-origin relay accepts arbitrary object-valued event.context and the new boundContext copy preserves every key and string value at every permitted nesting level. An unauthenticated browser caller can therefore submit a nested credential such as {context:{request:{authorization:"Bearer victim-token"}}}; the relay attaches the server's private ingest key and forwards it. The receiving /v1/browser handler authenticates that key and stores the payload. Its scrubContext only tests each top-level context key, so it preserves the nested request.authorization value. This creates a credential disclosure path into telemetry storage and the downstream sink under the server key, rather than dropping or masking browser-supplied auth material before privileged forwarding.
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Sensitive data in logs — Risk: 88/100
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [ai] Browser relay preserves opaque secret values under benign context keys — Risk: 80/100 Although
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| : {}), | ||
| }); | ||
| } | ||
|
|
@@ -157,23 +346,37 @@ | |
| 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 | ||
|
Check failure on line 354 in packages/runtime-node/src/relay.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [ai] trustProxy makes the public relay limiter key attacker-controlled — Risk: 82/100 The intentionally public relay forwards accepted payloads with the server's private ingest key, so its local limiter is the guard before body parsing and privileged forwarding. Setting the newly introduced
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [ai] trustProxy directly re-enables a requester-spoofable rate-limit key — Risk: 82/100 Setting the new boolean to true makes both relay handlers derive the limiter key from the first X-Forwarded-For value without validating the immediate peer or proxy chain. Any deployment that enables the documented proxy mode behind a proxy which appends or preserves client headers permits an unauthenticated caller to rotate that leading value and acquire unlimited fresh 120/min buckets. The local relay still parses each body and initiates privileged forwards under the shared server ingest key before downstream limits apply, so this does not meet the intended abuse guard.
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] trustProxy enables request-controlled IP rotation on the public relay — Risk: 70/100 The public relay uses this limiter before parsing and privileged forwarding, but setting
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| ? firstForwardedFor(request.headers.get("x-forwarded-for")) || | ||
|
Check failure on line 355 in packages/runtime-node/src/relay.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] trustProxy enables requester-controlled rate-limit keys without proxy verification — Risk: 76/100 Setting the new boolean to true makes the public fetch relay use the first
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| "unknown" | ||
| : "shared"; | ||
|
Check failure on line 357 in packages/runtime-node/src/relay.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Default fetch relay globally rate-limits all browser clients — Risk: 76/100 The documented Next adapter passes RelayOptions through unchanged, so its standard integration leaves
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Default Next relay collapses all browser clients into one rate-limit bucket — Risk: 76/100 Without trustProxy, the fetch handler unconditionally keys its 120/min limiter as
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 [ai] Failed stream cancellation can turn an oversized body into a 400 response — Risk: 35/100 After
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| } 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 @@ | |
| 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 | ||
|
Check warning on line 445 in packages/runtime-node/src/relay.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 When
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| ? firstForwardedFor(req.headers["x-forwarded-for"]) | ||
| : "") || | ||
| req.socket?.remoteAddress || | ||
| "unknown"; | ||
| if (!limiter.allow(ip)) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 Rate limiting not detected — Risk: 55/100
RelayOptions.trustProxymakes the public browser relay rate limit depend onX-Forwarded-Foragain:createBrowserRelayFetchHandler/createBrowserRelayHandlernow keyIpWindow.allowfrom a requester-controlled header when this flag is enabled. If a deployment flips it on without a trusted proxy that overwrites the header, an attacker can rotate the first forwarded IP per request and bypass the relay-side window, driving unbounded body parsing and authenticated forwards throughforwardunder the server ingest key. Blast radius — abusing this cascades to the downstream usage that depends on this file: functionssanitizeBrowserPayload,forward,IpWindow.allow,firstForwardedFor,respond,createBrowserRelayFetchHandler,createBrowserRelayHandler,RelayOptions; scopes@autter/runtime-node; dependent filesnode:http.sanitizeBrowserPayload,forward,IpWindow.allow,firstForwardedFor,respond,createBrowserRelayFetchHandler,createBrowserRelayHandler,RelayOptionsnode:http@autter/runtime-node🛠 AI fix prompt (copy & paste into your coding agent)
Flagged by Autter security & observability checks.