Skip to content

Commit e42c49c

Browse files
committed
fix(security): stop believing forwarded headers when nothing appends them
Walking the chain right to left only means anything if a proxy wrote part of it. docker-compose.prod.yml publishes port 3000 directly and ships no reverse proxy, so on that reference deployment the whole header is caller-authored and every per-IP limit stayed bypassable no matter which hop we read. No parsing rule can recover a real address from a header nobody vouched for, so make it explicit: TRUST_PROXY_HEADERS declares whether a proxy is in front. False, getClientIp reports 'unknown' and per-IP limits collapse into one shared bucket — blunt, and it throttles unrelated callers together, but it fails closed instead of handing out a fresh bucket per request. Defaults to true, preserving behavior for the ingress-fronted chart and hosted deployments. docker-compose.prod.yml defaults it to false, because that file knows it has no proxy; operators flip it when they put one in front. The audit package mirrors the flag: recording a caller-authored address as forensic evidence is worse than recording none.
1 parent 4ce98b7 commit e42c49c

8 files changed

Lines changed: 87 additions & 5 deletions

File tree

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
2020
# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL
2121
# TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins.
2222
# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. When set, Better Auth and Sim's own per-IP throttles both walk x-forwarded-for right to left, skip these hops, and use the first untrusted address as the client IP (the leftmost entry is caller-supplied and would otherwise let anyone mint a fresh rate-limit bucket per request). Unset, the two differ: Better Auth trusts only single-value headers, while Sim's throttles key on the rightmost, proxy-written entry — never spoofable, but a multi-hop chain collapses callers onto the edge addresses. Use your proxies' actual addresses, NOT broad private ranges that also cover clients: a caller whose own address falls inside a trusted range makes the whole chain trusted.
23+
# TRUST_PROXY_HEADERS=false # Optional: set false when the app is exposed directly with NO reverse proxy in front. With nothing appending the peer address, x-forwarded-for/x-real-ip are written entirely by the caller, so believing them lets anyone rotate a header for a fresh per-IP rate-limit bucket per request. While false, getClientIp reports 'unknown' and per-IP limits become one shared bucket (blunt, but fails closed). Defaults to true.
2324

2425
# Chat (Optional)
2526
# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ export const env = createEnv({
508508

509509
// Network / proxy trust
510510
AUTH_TRUSTED_PROXIES: z.string().optional(), // Comma-separated reverse-proxy IPs or CIDR ranges. When set, Better Auth and getClientIp (per-IP rate-limit keys, audit rows) both walk the forwarded-IP chain right to left, skip these trusted hops, and use the first untrusted address as the client IP. Leave unset and the two differ: Better Auth trusts only single-value IP headers (recording no IP for a multi-hop chain), while getClientIp keys on the rightmost, proxy-written entry — never the caller-supplied leftmost one.
511+
TRUST_PROXY_HEADERS: z.boolean().optional(), // Whether x-forwarded-for / x-real-ip may be believed at all. Default true: the app is assumed to sit behind a proxy that appends the peer address. Set false when it is exposed directly (no proxy), where those headers are written entirely by the caller — getClientIp then reports 'unknown' so per-IP limits become one shared bucket instead of a per-request bypass.
511512

512513
// SSO Configuration (for script-based registration)
513514
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality

apps/sim/lib/core/utils/client-ip.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,32 @@
1010
import { beforeEach, describe, expect, it, vi } from 'vitest'
1111

1212
const { mockEnv } = vi.hoisted(() => ({
13-
mockEnv: { AUTH_TRUSTED_PROXIES: undefined as string | undefined },
13+
mockEnv: {
14+
AUTH_TRUSTED_PROXIES: undefined as string | undefined,
15+
TRUST_PROXY_HEADERS: undefined as string | boolean | undefined,
16+
},
1417
}))
1518

16-
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
19+
vi.mock('@/lib/core/config/env', () => ({
20+
env: mockEnv,
21+
isFalsy: (value: string | boolean | number | undefined) =>
22+
value === false || value === 'false' || value === 0 || value === '0',
23+
}))
1724
vi.unmock('@/lib/core/utils/client-ip')
1825

1926
/**
2027
* The module parses the env once at import — that is the behavior under test —
2128
* so each case needs a fresh module instance. This is the deliberate exception
2229
* to the repo's "no `vi.resetModules()` + dynamic import" performance rule
2330
* (`.cursor/rules/sim-testing.mdc`): module-init behavior cannot be observed any
24-
* other way, and the cost here is four imports of a six-line module.
31+
* other way, and the cost here is a handful of imports of a tiny module.
2532
*/
26-
async function loadGetClientIp(trustedProxies: string | undefined) {
33+
async function loadGetClientIp(
34+
trustedProxies: string | undefined,
35+
trustProxyHeaders?: string | boolean
36+
) {
2737
mockEnv.AUTH_TRUSTED_PROXIES = trustedProxies
38+
mockEnv.TRUST_PROXY_HEADERS = trustProxyHeaders
2839
vi.resetModules()
2940
return (await import('@/lib/core/utils/client-ip')).getClientIp
3041
}
@@ -64,4 +75,23 @@ describe('getClientIp', () => {
6475

6576
expect(getClientIp(req({}))).toBe('unknown')
6677
})
78+
79+
it('declines to read forwarded headers when TRUST_PROXY_HEADERS is false', async () => {
80+
// No proxy in front: the whole header is caller-authored, so every caller
81+
// shares one bucket rather than each minting their own.
82+
const getClientIp = await loadGetClientIp(undefined, 'false')
83+
const keys = ['203.0.113.7, 10.0.0.1', '9.9.9.9', '2001:db8::1'].map((value) =>
84+
getClientIp(req({ 'x-forwarded-for': value }))
85+
)
86+
87+
expect(new Set(keys)).toEqual(new Set(['unknown']))
88+
expect(getClientIp(req({ 'x-real-ip': '203.0.113.7' }))).toBe('unknown')
89+
})
90+
91+
it('still reads forwarded headers when TRUST_PROXY_HEADERS is unset or true', async () => {
92+
for (const value of [undefined, 'true'] as const) {
93+
const getClientIp = await loadGetClientIp(undefined, value)
94+
expect(getClientIp(req({ 'x-forwarded-for': '203.0.113.7, 10.0.0.1' }))).toBe('10.0.0.1')
95+
}
96+
})
6797
})

apps/sim/lib/core/utils/client-ip.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import {
22
type ClientIpHeaderSource,
33
parseTrustedProxies,
44
resolveClientIp,
5+
UNKNOWN_CLIENT_IP,
56
} from '@sim/security/client-ip'
6-
import { env } from '@/lib/core/config/env'
7+
import { env, isFalsy } from '@/lib/core/config/env'
78

89
/**
910
* Reverse-proxy hops trusted for forwarded-IP resolution, read from the same
@@ -17,6 +18,17 @@ import { env } from '@/lib/core/config/env'
1718
*/
1819
const trustedProxies = parseTrustedProxies(env.AUTH_TRUSTED_PROXIES)
1920

21+
/**
22+
* Whether forwarded headers may be believed at all.
23+
*
24+
* Every rule about which hop to read presumes a proxy wrote at least one of
25+
* them. Reachable directly — no proxy, port published straight to the internet —
26+
* the entire header is caller-authored and no parsing strategy can recover a
27+
* real address from it. Operators of such a deployment set
28+
* `TRUST_PROXY_HEADERS=false`, which makes {@link getClientIp} decline to guess.
29+
*/
30+
const trustForwardedHeaders = !isFalsy(env.TRUST_PROXY_HEADERS)
31+
2032
/**
2133
* Extract the client IP from a request for logging, audit trails, and — most
2234
* importantly — per-IP rate-limit keys.
@@ -27,7 +39,13 @@ const trustedProxies = parseTrustedProxies(env.AUTH_TRUSTED_PROXIES)
2739
* See {@link resolveClientIp} for why the chain is walked right to left. In
2840
* short: the leftmost `X-Forwarded-For` entry is supplied by the caller, so
2941
* keying a throttle on it lets anyone mint a fresh bucket per request.
42+
*
43+
* With `TRUST_PROXY_HEADERS=false` this returns {@link UNKNOWN_CLIENT_IP} for
44+
* every caller, collapsing per-IP limits to a single shared bucket. That is
45+
* deliberately blunt — it throttles unrelated callers together — but it fails
46+
* closed, which a header nobody vouched for does not.
3047
*/
3148
export function getClientIp(request: ClientIpHeaderSource): string {
49+
if (!trustForwardedHeaders) return UNKNOWN_CLIENT_IP
3250
return resolveClientIp(request, trustedProxies)
3351
}

docker-compose.prod.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ services:
3636
# chain trusted. This all assumes a proxy that appends the peer address —
3737
# an app exposed directly to the internet sees only what the caller wrote.
3838
- AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-}
39+
# TRUST_PROXY_HEADERS: whether x-forwarded-for / x-real-ip may be believed.
40+
# Defaults to FALSE here because this file publishes port 3000 directly and
41+
# ships no reverse proxy — with nothing in front, those headers are written
42+
# entirely by the caller, and believing them would let anyone rotate a
43+
# header to get a fresh per-IP rate-limit bucket on every request. While
44+
# false, per-IP limits collapse into one shared bucket: blunt, but it fails
45+
# closed. Set it to true once a proxy that APPENDS the peer address (nginx,
46+
# Caddy, Traefik, an ALB, Cloudflare) terminates in front of the app, and
47+
# set AUTH_TRUSTED_PROXIES to that proxy's address at the same time.
48+
- TRUST_PROXY_HEADERS=${TRUST_PROXY_HEADERS:-false}
3949
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
4050
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
4151
- API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-}

helm/sim/values.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@
159159
"type": "string",
160160
"description": "Comma-separated reverse-proxy IPs or CIDR ranges in front of the app (e.g. the ingress pods, '10.42.0.0/24'). When set, Better Auth and Sim's per-IP rate limits both walk x-forwarded-for right to left, skip these hops, and use the first untrusted address as the client IP. Leave empty and the two differ: Better Auth trusts only a single-value header, while Sim's throttles key on the rightmost, proxy-written entry. Do not use a range broad enough to also cover client traffic — a caller inside a trusted range makes the whole chain trusted."
161161
},
162+
"TRUST_PROXY_HEADERS": {
163+
"type": "string",
164+
"description": "Whether x-forwarded-for / x-real-ip may be believed at all. Empty (the default) means yes, which is correct behind the chart's ingress. Set to 'false' only when the app is exposed with no proxy appending the peer address, where those headers are entirely caller-written; per-IP rate limits then collapse to one shared bucket rather than being bypassable per request."
165+
},
162166
"SSO_TRUSTED_PROVIDER_IDS": {
163167
"type": "string",
164168
"description": "Comma-separated SSO provider IDs to trust for automatic account linking when an SSO sign-in matches an existing account's email. Only needed for IdPs that do not assert email_verified. Merged into Better Auth accountLinking.trustedProviders."

helm/sim/values.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ app:
9191
# to also cover client traffic: a caller whose own address falls inside a trusted range makes
9292
# the whole chain trusted and can then forge the value Sim keys on.
9393
AUTH_TRUSTED_PROXIES: ""
94+
# TRUST_PROXY_HEADERS: whether x-forwarded-for / x-real-ip may be believed at all. Left empty
95+
# (= true), which is correct here because the chart runs behind an ingress that appends the peer
96+
# address. Set to "false" only if you expose the Service directly with no proxy in front: with
97+
# nothing appending, those headers are written entirely by the caller and believing them lets
98+
# anyone rotate a header for a fresh per-IP rate-limit bucket per request. While false, per-IP
99+
# limits collapse into one shared bucket — blunt, but it fails closed.
100+
TRUST_PROXY_HEADERS: ""
94101
# SOCKET_SERVER_URL: Auto-detected when realtime.enabled=true (uses internal service)
95102
# NEXT_PUBLIC_SOCKET_URL: public WebSocket URL for browsers. Leave empty to default to the
96103
# page's own origin (assumes the ingress/reverse proxy routes /socket.io to the realtime service).

packages/audit/src/log.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type ClientIpHeaderSource,
55
parseTrustedProxies,
66
resolveClientIp,
7+
UNKNOWN_CLIENT_IP,
78
} from '@sim/security/client-ip'
89
import { generateShortId } from '@sim/utils/id'
910
import { eq } from 'drizzle-orm'
@@ -39,12 +40,22 @@ interface AuditLogParams {
3940
*/
4041
const trustedProxies = parseTrustedProxies(process.env.AUTH_TRUSTED_PROXIES)
4142

43+
/**
44+
* Mirrors the app's `TRUST_PROXY_HEADERS`. Recording a caller-authored address
45+
* as forensic evidence is worse than recording none, so a deployment that
46+
* declares it has no proxy in front gets `unknown` rather than a fabrication.
47+
*/
48+
const trustForwardedHeaders = !/^(false|0|no|off)$/i.test(
49+
(process.env.TRUST_PROXY_HEADERS ?? '').trim()
50+
)
51+
4252
/**
4353
* An audit row's `ipAddress` is forensic evidence, so it must not be whatever
4454
* the caller put in the leftmost `X-Forwarded-For` entry. See
4555
* {@link resolveClientIp}.
4656
*/
4757
function getClientIp(request: ClientIpHeaderSource): string {
58+
if (!trustForwardedHeaders) return UNKNOWN_CLIENT_IP
4859
return resolveClientIp(request, trustedProxies)
4960
}
5061

0 commit comments

Comments
 (0)