Files
paperclip/server/src/__tests__/trust-proxy.test.ts
T
Jannes Stubbemann 937fe62d10 feat(server): TRUST_PROXY supports CIDR list + named subnets (supersedes #3729) (#5872)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Express looks at incoming `X-Forwarded-For` headers only when
`app.set("trust proxy", …)` says it should, and uses that resolved
client IP downstream for rate-limiting, audit logging, and any
auth/abuse signal that ties back to source IP
> - The original PR #3729 added `TRUST_PROXY` accepting only `"true"` or
a positive integer, which forces operators to pick between two unsafe
defaults: hop-count (brittle if topology changes) or boolean-true (any
client can spoof `X-Forwarded-For` and bypass rate-limits or pollute
audit logs)
> - `trust proxy: true` is one of the most common Express
misconfigurations and trivially exploitable for IP-spoofing-based
rate-limit bypass; the safest config — trust only the LB's actual CIDR
or only loopback — was unreachable with the previous parser
> - This pull request replaces the parser with full Express 5 support —
unset / `false` / `0` (Express default), positive integer hop count,
comma-separated CIDR list, named subnets (`loopback`, `linklocal`,
`uniquelocal`) — and emits a startup error naming the offending token on
invalid input
> - The benefit is that operators can now trust *only* their actual
ingress and close the spoofing window without leaking client-IP
integrity to downstream layers, while preserving every
previously-working config as a strict superset

## Linked Issues or Issue Description

Refs #1690 — login returns 500 behind a reverse proxy because Express
`trust proxy` is not enabled; this PR ships the configuration surface
(`TRUST_PROXY` with CIDR lists and named subnets) that lets operators
enable it safely. It does not change the default, so #1690 still
requires the operator to set `TRUST_PROXY` — hence Refs, not Fixes.

No other existing issue covers this directly — remaining problem
described in-PR:

- The original `TRUST_PROXY` parser (PR #3729, which this PR supersedes)
accepted only `"true"` or a hop count, forcing operators to choose
between brittle hop-counting and the spoofable `trust proxy: true`.
- `trust proxy: true` lets any client spoof `X-Forwarded-For` and bypass
rate limits or pollute audit logs; the safest config — trusting only the
LB's actual CIDR or only loopback — was unreachable with the previous
parser.

Duplicate-PR search: #1854 / #1714 are earlier minimal trust-proxy
enablement PRs; this PR supersedes #3729 and generalizes beyond a
boolean enable (CIDR lists + named subnets).

## What Changed

- **`server/src/middleware/trust-proxy.ts`** — new helper exposing
`parseTrustProxyEnv` (testable) and `applyTrustProxy(app)` (one-call
boot wiring). Surface:
- Unset / `""` / `false` / `0` → no `app.set("trust proxy", …)` (Express
default: trust nothing).
- `true` → `app.set("trust proxy", true)`. Documented as unsafe in
untrusted-LB deployments.
- Positive integer (e.g. `"2"`) → hop count. Strict parse: rejects
`"01"`, leading/trailing whitespace.
- Comma-separated list of CIDRs and/or named subnets (e.g.
`"loopback,uniquelocal,10.0.0.0/8,fd00::/8"`) → array passed to
`app.set("trust proxy", [...])`.
  - Anything else → startup error naming the offending token.
- **`server/src/app.ts`** — one import + one call to
`applyTrustProxy(app)`.
- **`server/src/__tests__/trust-proxy.test.ts`** — 12 cases: unset,
`"true"`, `"0"`, `"2"`, `"01"` rejected, `" 2 "` rejected, `"loopback"`,
`"loopback,uniquelocal"`, `"10.0.0.0/8"`, `"10.0.0.0/8,fd00::/8"`,
`"bogus"` rejected (error names the bad token), mixed-list with one bad
token rejected (error names the offending token specifically).

## Verification

- `pnpm --filter @paperclipai/server run typecheck` — clean.
- `npx vitest run trust-proxy` — 12/12 pass.

## Risks

- **No new required env vars.** Unset means default Express behavior
(trust nothing). Pure superset of #3729's surface — anything that worked
under #3729 still works here.
- **Strict parse.** `"01"` and `" 2 "` are rejected on purpose so
configuration mistakes surface at startup, not as silently-degraded
auth/rate-limit behavior. The error message names the offending token.
- **No runtime cost** — the parse runs once at boot. The downstream
`trust proxy` setting is internal to Express.
- Single-tenant local-first deploys unaffected by default.

## Model Used

Claude Opus 4.7 (1M context), extended thinking mode.

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Checked ROADMAP.md — not in conflict with planned core work
- [x] Tests run locally and pass (`trust-proxy` 12/12)
- [x] Added boundary cases (leading-zero, whitespace, unknown token,
mixed-list-with-bad-token)
- [x] No UI changes
- [x] Documented risks above
- [x] Will address all Greptile and reviewer comments before merge

Closes #3729.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 10:37:55 -07:00

100 lines
3.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import express from "express";
import { applyTrustProxy, parseTrustProxyEnv } from "../middleware/trust-proxy.js";
function appWithEnv(raw: string | undefined): express.Express {
const app = express();
applyTrustProxy(app, parseTrustProxyEnv(raw));
return app;
}
describe("parseTrustProxyEnv", () => {
it("unset leaves Express at its safe default (trust nothing)", () => {
// Express's default trust-proxy setting is `false`. We verify the
// setting is unchanged by comparing against a vanilla express()
// instance that never had `applyTrustProxy` called on it.
const baseline = express().get("trust proxy");
const app = appWithEnv(undefined);
expect(parseTrustProxyEnv(undefined)).toBeUndefined();
expect(app.get("trust proxy")).toBe(baseline);
});
it("empty / false / 0 are treated as unset", () => {
expect(parseTrustProxyEnv("")).toBeUndefined();
expect(parseTrustProxyEnv("false")).toBeUndefined();
expect(parseTrustProxyEnv("0")).toBeUndefined();
const baseline = express().get("trust proxy");
expect(appWithEnv("0").get("trust proxy")).toBe(baseline);
});
it("'true' yields boolean true and sets app accordingly", () => {
expect(parseTrustProxyEnv("true")).toBe(true);
expect(appWithEnv("true").get("trust proxy")).toBe(true);
});
it("positive integer is parsed as a number", () => {
expect(parseTrustProxyEnv("2")).toBe(2);
expect(appWithEnv("2").get("trust proxy")).toBe(2);
});
it("'01' throws (strict integer, no leading zeros)", () => {
expect(() => parseTrustProxyEnv("01")).toThrow(/invalid integer/);
});
it("integer with internal whitespace throws", () => {
// A value like "1 2" (digits + whitespace + digits) is clearly not
// a single int and not a subnet list either — must be rejected.
// This is distinct from " 2 " (surrounding whitespace), which the
// outer `raw.trim()` accepts; see the next test for that contract.
// The parser happens to reach the subnet-token path for "1 2"
// (the inner-whitespace integer guard only fires when the whole
// string is `^\s*\d+\s*$`), so we match the unrecognized-token
// error rather than the "invalid integer" branch.
expect(() => parseTrustProxyEnv("1 2")).toThrow(/unrecognized token "1 2"/);
});
it("integer with surrounding whitespace is accepted (trimmed)", () => {
// The parser intentionally trims the *outer* value before matching,
// so " 2 " is equivalent to "2". Locking this in so the contract
// doesn't drift relative to the "internal whitespace throws" case.
expect(parseTrustProxyEnv(" 2 ")).toBe(2);
expect(appWithEnv(" 2 ").get("trust proxy")).toBe(2);
});
it("'loopback' yields a single-element array", () => {
const v = parseTrustProxyEnv("loopback");
expect(v).toEqual(["loopback"]);
const app = appWithEnv("loopback");
expect(app.get("trust proxy")).toEqual(["loopback"]);
});
it("'loopback,uniquelocal' yields a 2-element array", () => {
expect(parseTrustProxyEnv("loopback,uniquelocal")).toEqual([
"loopback",
"uniquelocal",
]);
});
it("IPv4 CIDR is accepted", () => {
expect(parseTrustProxyEnv("10.0.0.0/8")).toEqual(["10.0.0.0/8"]);
});
it("mixed IPv4 + IPv6 CIDR list is accepted with whitespace tolerance", () => {
expect(parseTrustProxyEnv(" 10.0.0.0/8 , fd00::/8 ")).toEqual([
"10.0.0.0/8",
"fd00::/8",
]);
});
it("'bogus' throws with a helpful message naming the bad token", () => {
expect(() => parseTrustProxyEnv("bogus")).toThrow(/bogus/);
expect(() => parseTrustProxyEnv("bogus")).toThrow(/loopback/);
});
it("partial-garbage list throws on the bad token, not silently dropped", () => {
expect(() => parseTrustProxyEnv("loopback,not-a-cidr")).toThrow(
/not-a-cidr/,
);
});
});