Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 163 additions & 7 deletions packages/runtime-node/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,157 @@

const SEVERITIES = new Set(["fatal", "error", "warning", "info"]);

// Bound a browser-supplied `context` object so it honors the sanitiser's

Check notice on line 95 in packages/runtime-node/src/relay.ts

View check run for this annotation

Autter.dev / autter/review-gate

🟡 Low · Missing linked tracker issue

The PR description does not reference a tracker issue. This change affects `boundContext` and `sanitizeBrowserPayload`, which prepare browser context for forwarding through the runtime-node relay. Blast radius — skipping this guardrail cascades to the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`. **References:** - https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue — # Linking a pull request to an issue You can link a pull request or branch to an issue to show that a fix is in progress and to automatically close the issue when the pull request - https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/helping-others-review-your-changes — # Helping others review your changes You can use pull requests to provide clear context for your changes and keep your team informed, improving collaboration and the quality of rev Suggested fix: Add a reference to the relevant GitHub, Jira, or Linear issue in the PR description. Blast radius — skipping this guardrail cascades to the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`.
// 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 {

Check notice on line 108 in packages/runtime-node/src/relay.ts

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · Dead export (no callers)

`byteLength` is only used internally by `readBodyBounded`, and the newly exported `boundContext` is only used internally by `relayContext`; exposing these helpers adds public API surface without an apparent caller. This affects the `@autter/runtime-node` package API, including `byteLength` and `boundContext`. Blast radius — if this hygiene issue is left in it makes the downstream usage that depends on this file harder to change safely: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`. Suggested fix: Make `byteLength` and `boundContext` module-private unless they are intentionally supported public APIs. Keep the existing internal uses in `readBodyBounded` and `relayContext` working. Blast radius — if this hygiene issue is left in it makes the downstream usage that depends on this file harder to change safely: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`.
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 => {

Check notice on line 121 in packages/runtime-node/src/relay.ts

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · Context walker has high cyclomatic complexity

The nested `walk` function in `boundContext` has many branches: type checks, depth/node limits, cycle detection, and separate try/catch guards for `Array.isArray`, `length`, `Object.keys`, and element reads. These guards are intentional fail-open protections against hostile Proxies. Consider splitting array and object handling into small helpers so the sanitizer stays easier to audit. Suggested fix: Refactor `walk` into smaller helpers for primitive handling, array traversal, and object traversal. Keep the shared depth/node budget and ensure each helper preserves the current bounds and fail-open behavior.
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [heuristic] Generic placeholder identifier in production logic — Risk: 45/100

Identifier obj is a generic placeholder; production logic should name the actual concept. Blast radius — if this AI-generated slop ships it cascades to the downstream usage that depends on this file: functions sanitizeBrowserPayload, forward, firstForwardedFor, IpWindow.allow, respond, createBrowserRelayFetchHandler, createBrowserRelayHandler; scopes @autter/runtime-node; dependent files node:http.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Functions/symbols: sanitizeBrowserPayload, forward, firstForwardedFor, IpWindow.allow, respond, createBrowserRelayFetchHandler, createBrowserRelayHandler
  • Dependent files: node:http
  • Scopes: @autter/runtime-node
🛠 AI fix prompt (copy & paste into your coding agent)
In `packages/runtime-node/src/relay.ts` around line 98, this identifier uses a generic placeholder name (`data`/`result`/`handler`/`temp`/`item`) in production logic. Rename it to describe what it actually contains. Placeholder names are a hallmark of AI-generated scaffolding that was never edited for context. Blast radius — if this AI-generated slop ships it cascades to the downstream usage that depends on this file: functions `sanitizeBrowserPayload`, `forward`, `firstForwardedFor`, `IpWindow.allow`, `respond`, `createBrowserRelayFetchHandler`, `createBrowserRelayHandler`; scopes `@autter/runtime-node`; dependent files `node:http`.

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;

Check notice on line 146 in packages/runtime-node/src/relay.ts

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · Throwing `length` trap yields `[]` instead of dropping the value as documented

The `boundContext` doc comment says a hostile or revoked Proxy whose trap throws on `length` is dropped. However, the catch around `arr.length` returns `out` (an empty array) rather than `undefined`. A nested array whose `length` read throws is therefore kept in the forwarded context as `[]`, which is inconsistent with the `Array.isArray` catch just above. No data leaks, but either return `undefined` here or update the comment. Suggested fix: In the `catch` around `len = arr.length` in `boundContext`, return `undefined` instead of `out`, so the whole value is dropped as the comment states. Alternatively, update the doc comment to say that arrays with an unreadable length become empty arrays. Keep the test for the hostile `length` trap passing. Blast radius — if this AI-generated slop ships it cascades to the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`.
}
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++;
let child: unknown;
try {
child = walk((obj as Record<string, unknown>)[key], depth + 1);
} catch {
child = undefined;
}
if (child !== undefined) out[key] = child;

Check notice on line 179 in packages/runtime-node/src/relay.ts

View check run for this annotation

Autter.dev / autter/review-gate

🟡 Low · Code correctness issue

`boundContext` copies context property names verbatim (`out[key] = child`) without applying `CONTEXT_MAX_STRING` or any key-length limit, so a long key bypasses the per-string cap. Impact is bounded by the overall request body limit (`maxBodyBytes`), but consider truncating or dropping keys longer than a reasonable limit. Suggested fix: Cap context property-name length before adding each property to `out` (or drop keys exceeding the limit). Add a test showing that an overlong context key is truncated or omitted by `sanitizeBrowserPayload`. Blast radius — if this defect reaches production it can fail the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [ai] Code correctness issue — Risk: 30/100

boundContext copies context property names verbatim (out[key] = child) without applying CONTEXT_MAX_STRING or any key-length limit, so a long key bypasses the per-string cap. Impact is bounded by the overall request body limit (maxBodyBytes), but consider truncating or dropping keys longer than a reasonable limit.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Functions/symbols: byteLength, boundContext, sanitizeBrowserPayload, createBrowserRelayFetchHandler, readBodyBounded, relayContext
  • Dependent files: ../../otlp-ingester/src/compat.js, ./compat.js, ./redact.js, @opentelemetry/api, node:http
  • Scopes: @autter/runtime-node
🛠 AI fix prompt (copy & paste into your coding agent)
Cap context property-name length before adding each property to `out` (or drop keys exceeding the limit). Add a test showing that an overlong context key is truncated or omitted by `sanitizeBrowserPayload`. Blast radius — if this defect reaches production it can fail the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`.

Flagged by Autter security & observability checks.

}
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) };

Check warning on line 243 in packages/runtime-node/src/relay.ts

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Browser context can still forward opaque secrets

This public relay forwards arbitrary context fields after applying the shared redactor. Values that do not match its known secret patterns—such as an opaque password or session secret under a benign key like `note`—remain intact and are forwarded with the server’s ingest credentials. The relay cannot reliably identify arbitrary secret strings by value alone. Blast radius — if this issue ships it degrades the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`. _Flagged by the Release Notes Curator agent._ Suggested fix: Prevent arbitrary browser-supplied context from crossing the privileged forwarding boundary: drop context or restrict it to an explicit safe allowlist. Add tests showing opaque secret values under benign nested keys are absent from the sanitized payload and forwarded request.
}

// 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.
Expand Down Expand Up @@ -143,10 +294,8 @@
...(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

Check notice on line 297 in packages/runtime-node/src/relay.ts

View check run for this annotation

Autter.dev / autter/review-gate

🟡 Low · Missing security-team review on sensitive path

The change modifies the public browser relay's context handling before it is forwarded with the server ingest key; no approving security-team review is recorded. A flaw in `sanitizeBrowserPayload` or `relayContext` could expose client-supplied data to downstream telemetry. Blast radius — skipping this guardrail cascades to the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`. **References:** - https://cheatsheetseries.owasp.org/cheatsheets/Secure_Code_Review_Cheat_Sheet.html — Secure Code Review - OWASP Cheat Sheet Series ## Introduction¶ Secure Code Review is the process of manually examining source code to identify security vulnerabilities that automat - https://owasp.org/projects/code-review-guide — ### Licensing ### Project Resources Classification ️ Other Suggested fix: Request and obtain approval from the configured security team for the relay changes before merging. Blast radius — skipping this guardrail cascades to the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `sanitizeBrowserPayload`, `createBrowserRelayFetchHandler`, `readBodyBounded`, `relayContext`; scopes `@autter/runtime-node`; dependent files `../../otlp-ingester/src/compat.js`, `./compat.js`, `./redact.js`, `@opentelemetry/api`, `node:http`.
? relayContext(e.context, redactor)
: {}),
});
}
Expand Down Expand Up @@ -227,15 +376,22 @@
});
}
}
const text = await request.text();
if (text.length > maxBody) {
let bounded: { tooLarge: true } | { tooLarge: false; text: string };
try {
bounded = await readBodyBounded(request, maxBody);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 total > maxBody, await reader.cancel() can reject. The outer catch then returns 400 { error: "invalid json" } even though the size limit was exceeded. Treat cancellation as best-effort (for example, catch its rejection locally) and return { tooLarge: true } so oversized requests consistently receive 413. Consider distinguishing stream-read failures from JSON parse failures as well.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Functions/symbols: sanitizeBrowserPayload, forward, firstForwardedFor, IpWindow.allow, respond, createBrowserRelayFetchHandler, createBrowserRelayHandler
  • Dependent files: node:http
  • Scopes: @autter/runtime-node
🛠 AI fix prompt (copy & paste into your coding agent)
Differentiate body-read failures from JSON parse failures. Catch `readBodyBounded` errors separately and return the appropriate non-JSON error response (or rethrow into the existing transport error handling), while keeping `JSON.parse` failures mapped to 400 invalid json. Add a test for a request body stream that rejects or errors mid-read. Blast radius — if this defect reaches production it can fail the downstream usage that depends on this file: functions `sanitizeBrowserPayload`, `forward`, `firstForwardedFor`, `IpWindow.allow`, `respond`, `createBrowserRelayFetchHandler`, `createBrowserRelayHandler`; scopes `@autter/runtime-node`; dependent files `node:http`.

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,
Expand Down
Loading
Loading