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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Security

- The browser relay no longer trusts the first `X-Forwarded-For` entry for its per-visitor rate limit (`runtime-node`). Any caller could set that entry to a new value on each request and get a fresh window every time, driving unbounded parsing and forwarding under your ingest key. New option `trustProxy`, default `"auto"`: the relay reads the header right to left and takes the first public address, which is the one your load balancer, CDN or platform appended or wrote, and in Node it ignores the header entirely when the connection comes straight from a public address. On Cloudflare Workers it uses `CF-Connecting-IP`. No setup is needed behind common load balancers and hosting platforms. `trustProxy` also takes a hop count, `true` (the old leftmost behaviour) or `false`, and a new `clientIp(request)` option supplies the address yourself. Ported from the relay hardening by @rajeshaipython-stack that followed #20 and never reached `main`; its context redaction and detached cancel already reached `main` through #26 and #20, with their tests.

## [1.10.0] - 2026-10-24

Packages: `otlp-ingester` 1.10.0, `runtime-edge` 1.3.0; `adapters/python` (copied, not published); `runtime-browser` 1.7.0, `runtime-node` 1.8.0, `runtime-next` 1.8.0 (its `/edge` entry picks up `runtime-edge` 1.3.0 through `^1.2.0`) and `runtime-cli` 1.2.0 are unchanged. Compat feature `edge_request_counts`. Request counts for services the error rate was wrong for: edge functions had errors and no requests, and Python services counted only the requests head sampling kept while every error was retained, so their error rates read about 1/sampleRate too high. Any deploy order works: an ingester before this ignores the new edge attributes (edge requests stay uncounted, as before), an older `runtime-edge` sends no mark (its summaries stay uncounted), and the Python opt-in needs only an ingester from 1.8.0.
Expand Down
5 changes: 5 additions & 0 deletions docs/GETTING-STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,11 @@ clickhouse-client --password dev`.
doesn't cost you debugging context.
- [ ] The relay route keeps its built-in per-IP rate limit (or your WAF
covers it: `perIpRateLimit: false`).
- [ ] The relay's default `trustProxy: "auto"` keys each visitor behind
load balancers, CDNs and hosting platforms. Set a hop count only for a
Node origin behind a public CDN with no load balancer (`trustProxy: 1`
behind Cloudflare), and `trustProxy: false` or `clientIp` for a fetch
handler exposed with no proxy at all.
- [ ] Direct browser ingest: your CSP includes
`connect-src https://your-ingester…`, and you accept that ad-blockers
may drop some events (the relay avoids this).
Expand Down
34 changes: 33 additions & 1 deletion packages/runtime-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,39 @@ export const POST = createBrowserRelayFetchHandler({
```

Options: `endpoint` (default `https://otlp.autter.dev`), `maxBodyBytes`
(default 64 KB), `onError`.
(default 64 KB), `perIpRateLimit` (default 120 requests a minute per client;
`false` when a WAF already limits the route), `trustProxy`, `redact`,
`compatCheck`, `onError`.

**Rate-limit key and `trustProxy`.** The relay route is public, so it limits
each visitor. Any caller can write anything into `X-Forwarded-For`, so by
default (`trustProxy: "auto"`) the relay never takes that header's first entry.
Proxies append the address they saw to the right of the header, or overwrite
it, so the relay reads it right to left and takes the first public address,
skipping hops on your own network. That works behind load balancers, CDNs and
hosting platforms with no setup:

| Where the relay runs | What `"auto"` keys |
| --- | --- |
| Node, reached directly | The connection's own address; the header is ignored |
| Node behind a load balancer or ingress on your network | The address that proxy appended |
| Next.js route handlers, Vercel, Netlify, Deno Deploy and other fetch runtimes | The rightmost public entry, which the platform's edge wrote |
| Cloudflare Workers | `CF-Connecting-IP` |

Other settings:

- `trustProxy: 1` (or any count) when exactly that many proxies you control
sit in front, such as a Node origin behind Cloudflare with no load balancer.
- `trustProxy: true` when every proxy in front strips client values: the
leftmost entry.
- `trustProxy: false` never reads the header: the socket address in Node, one
shared bucket in fetch runtimes.
- `clientIp: (request) => string` to supply the address yourself, for example
Deno's `info.remoteAddr` or Bun's `server.requestIP`.

Strings from environment variables work too (`"auto"`, `"false"`, `"2"`).
A fetch handler exposed straight to the internet with no proxy in front can
still be fooled by a forged header; set `trustProxy: false` or `clientIp` there.

## 2. Server tracker

Expand Down
186 changes: 180 additions & 6 deletions packages/runtime-node/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ export interface RelayOptions {
* `false` to disable (e.g. when a WAF already rate-limits).
*/
perIpRateLimit?: number | false;
/**
* How the per-client rate limit finds the visitor's address. A caller can
* write anything into `X-Forwarded-For`, so the relay never takes its
* first (leftmost) entry unless told to.
*
* - `"auto"` (default): works behind any load balancer, CDN or platform
* proxy without setup. Proxies append the address they saw to the right
* of the header (or overwrite it), so the relay reads it right to left
* and takes the first public address, skipping private and loopback
* hops. The Node handler only does this when the connection itself came
* from a private address (a proxy on your network); a public peer is the
* visitor and its header is ignored. On Cloudflare Workers it uses
* `CF-Connecting-IP`.
* - a number `n`: exactly `n` proxies you control sit in front; use the
* entry `n` from the right (Express `trust proxy` semantics). Use `1`
* for a Node origin behind Cloudflare with no load balancer.
* - `true`: every proxy in front is yours and strips client values; use
* the leftmost entry.
* - `false`: never read the header. The Node handler keys the socket peer;
* the fetch handler keys one shared bucket for the route.
*
* Strings from environment variables are read too (`"auto"`, `"true"`,
* `"false"`, `"0"`, `"2"`); anything else falls back to `"auto"`.
*/
trustProxy?: boolean | "auto" | number | string;
/**
* Return the visitor's address yourself (Deno's `info.remoteAddr`, Bun's
* `server.requestIP`, a header your edge sets). Wins over `trustProxy`
* when it returns a non-empty string.
*/
clientIp?: (request: Request | IncomingMessage) => string | null | undefined;
/** Called when the async forward fails (default: console.warn). */
onError?: (err: unknown) => void;
/**
Expand Down Expand Up @@ -75,6 +106,153 @@ function firstForwardedFor(value: string | string[] | undefined | null): string
return raw ? (raw.split(",")[0] ?? "").trim() : "";
}

// ── Client address for the rate limit ──────────────────────────────────────

type ProxyTrust =
| { mode: "auto" }
| { mode: "never" }
| { mode: "all" }
| { mode: "hops"; hops: number };

const TRUST_AUTO: ProxyTrust = { mode: "auto" };
let warnedTrustProxy = false;

/** Read `trustProxy` (including strings from env vars) into one mode. */
export function resolveTrustProxy(value: unknown): ProxyTrust {
if (value === undefined || value === null || value === "auto") return TRUST_AUTO;
if (value === true) return { mode: "all" };
if (value === false || value === 0) return { mode: "never" };
if (typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100) {
return { mode: "hops", hops: value };
}
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (v === "" || v === "auto") return TRUST_AUTO;
if (v === "false" || v === "0" || v === "no" || v === "off") return { mode: "never" };
if (v === "true" || v === "yes" || v === "on") return { mode: "all" };
if (/^[1-9][0-9]?$/.test(v)) return { mode: "hops", hops: Number(v) };
}
if (!warnedTrustProxy) {
warnedTrustProxy = true;
console.warn(
`[autter-runtime] relay: trustProxy ${JSON.stringify(value)} is not recognised; using "auto".`,
);
}
return TRUST_AUTO;
}

const IPV4_RE = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;

/** One forwarded/peer entry as a bare, lowercased IP, or null when it is not one. */
export function normalizeAddress(entry: string | null | undefined): string | null {
if (!entry) return null;
let a = entry.trim();
if (!a || a.length > 64) return null;
const bracketed = /^\[([0-9A-Fa-f:.]+)\](?::\d{1,5})?$/.exec(a);
if (bracketed) a = bracketed[1] ?? "";
else if (/^[\d.]+:\d{1,5}$/.test(a)) a = a.slice(0, a.lastIndexOf(":"));
a = a.toLowerCase();
if (a.startsWith("::ffff:") && IPV4_RE.test(a.slice(7))) a = a.slice(7);
if (IPV4_RE.test(a)) return a;
if (/^[0-9a-f:]+(:(\d{1,3}\.){3}\d{1,3})?$/.test(a) && a.includes(":") && a.split(":").length <= 9) {
return a;
}
return null;
}

/** Loopback, private, link-local, carrier-grade NAT or unique-local: a hop on your own network. */
export function isPrivateAddress(a: string): boolean {
if (IPV4_RE.test(a)) {
const [x, y] = a.split(".").map(Number) as [number, number];
return (
x === 10 ||
x === 127 ||
x === 0 ||
(x === 169 && y === 254) ||
(x === 172 && y >= 16 && y <= 31) ||
(x === 192 && y === 168) ||
(x === 100 && y >= 64 && y <= 127)
);
}
return a === "::1" || a === "::" || /^f[cd]/.test(a) || /^fe[89ab]/.test(a);
}

function forwardedEntries(value: string | string[] | undefined | null): string[] {
const raw = Array.isArray(value) ? value.join(",") : value;
if (!raw) return [];
const out: string[] = [];
for (const part of raw.split(",")) {
const a = normalizeAddress(part);
if (a) out.push(a);
}
return out;
}

/** Right to left, the first address that is not a hop on your own network. */
function rightmostPublic(entries: string[]): string | null {
for (let i = entries.length - 1; i >= 0; i--) {
const a = entries[i];
if (a && !isPrivateAddress(a)) return a;
}
return null;
}

function fromCustom(
opts: RelayOptions,
request: Request | IncomingMessage,
): string | null {
if (!opts.clientIp) return null;
try {
const v = opts.clientIp(request);
return typeof v === "string" && v.trim() ? v.trim().slice(0, 64) : null;
} catch {
return null;
}
}

/** Rate-limit key for the fetch handler, which has no peer address. */
export function fetchClientKey(request: Request, opts: RelayOptions): string {
const custom = fromCustom(opts, request);
if (custom) return custom;
const trust = resolveTrustProxy(opts.trustProxy);
if (trust.mode === "never") return "shared";
const header = request.headers.get("x-forwarded-for");
if (trust.mode === "all") return firstForwardedFor(header) || "shared";
const entries = forwardedEntries(header);
if (trust.mode === "hops") {
return entries[Math.max(0, entries.length - trust.hops)] ?? "shared";
}
// auto. Cloudflare Workers: `request.cf` is set by the runtime, never by a
// header, so its CF-Connecting-IP is the address Cloudflare accepted.
const cf = (request as unknown as { cf?: unknown }).cf;
if (cf !== null && typeof cf === "object") {
const viaCf = normalizeAddress(request.headers.get("cf-connecting-ip"));
if (viaCf) return viaCf;
}
// Fetch runtimes sit behind the platform's edge, which appends (or
// overwrites) the address it saw: the rightmost public entry.
return rightmostPublic(entries) ?? entries[entries.length - 1] ?? "shared";
}

/** Rate-limit key for the Node handler. */
export function nodeClientKey(req: IncomingMessage, opts: RelayOptions): string {
const custom = fromCustom(opts, req);
if (custom) return custom;
const trust = resolveTrustProxy(opts.trustProxy);
const peer = normalizeAddress(req.socket?.remoteAddress);
if (trust.mode === "never") return peer ?? "unknown";
const header = req.headers["x-forwarded-for"];
if (trust.mode === "all") return firstForwardedFor(header) || peer || "unknown";
const entries = forwardedEntries(header);
if (trust.mode === "hops") {
return entries[Math.max(0, entries.length - trust.hops)] ?? peer ?? "unknown";
}
// auto: a public peer is the visitor itself, whatever header it sent.
if (peer && !isPrivateAddress(peer)) return peer;
// The connection came from a proxy on your network.
return rightmostPublic(entries) ?? peer ?? entries[entries.length - 1] ?? "unknown";
}

const DEFAULT_ENDPOINT = "https://otlp.autter.dev";
const DEFAULT_MAX_BODY = 64 * 1024;
const MAX_EVENTS = 50;
Expand Down Expand Up @@ -411,8 +589,7 @@ export function createBrowserRelayFetchHandler(
return new Response(null, { status: 405 });
}
if (limiter) {
const ip =
firstForwardedFor(request.headers.get("x-forwarded-for")) || "unknown";
const ip = fetchClientKey(request, opts);
if (!limiter.allow(ip)) {
return new Response(JSON.stringify({ error: "rate limit exceeded" }), {
status: 429,
Expand Down Expand Up @@ -501,10 +678,7 @@ export function createBrowserRelayHandler(
return;
}
if (limiter) {
const ip =
firstForwardedFor(req.headers["x-forwarded-for"]) ||
req.socket?.remoteAddress ||
"unknown";
const ip = nodeClientKey(req, opts);
if (!limiter.allow(ip)) {
respond(res, 429, { error: "rate limit exceeded" });
return;
Expand Down
Loading
Loading