Skip to content

Commit d6d4180

Browse files
committed
fix(security): resolve the client IP from the trusted proxy chain, not the leftmost header
The leftmost x-forwarded-for token is written by the client, so every IP-derived decision was steerable by sending your own header: per-IP rate limits could be reset per request, audit rows recorded an attacker-chosen address, and per-webhook IP allowlists could be satisfied outright. Resolution now walks the chain right to left against AUTH_TRUSTED_PROXIES and returns null when no address can be trusted, delegating to Better Auth's own resolver so this agrees by construction with the ipAddress on every session row. Callers split on that null deliberately: rate limits share one strict bucket, the webhook allowlist denies, audit records blank.
1 parent e6485f5 commit d6d4180

35 files changed

Lines changed: 551 additions & 117 deletions

File tree

apps/docs/content/docs/en/platform/self-hosting/authentication.mdx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,21 @@ Both SSO flags are needed: the server-side one grants access, and the `NEXT_PUBL
109109

110110
## Behind a load balancer
111111

112-
Tell Better Auth which forwarding hops to trust when resolving the client IP:
112+
Tell Sim which forwarding hops to trust when resolving the client IP:
113113

114114
```bash
115115
AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10
116116
```
117117

118-
Better Auth walks `X-Forwarded-For` right to left, skips these hops, and uses the first untrusted address as the client IP for session records and its own IP-based checks. Use your proxies' actual addresses — a broad private range that also covers client traffic defeats the purpose. See [Security](/platform/self-hosting/security).
118+
The forwarded chain is walked right to left, skipping these hops, and the first untrusted address becomes the client IP. Use your proxies' actual addresses — a broad private range that also covers client traffic defeats the purpose, and `0.0.0.0/0` matches every hop, so the walk finds no client at all.
119+
120+
This one variable governs every IP-derived behavior in Sim: session records, per-IP rate limits on public endpoints, the `ipAddress` column on audit log entries, and per-webhook IP allowlists.
121+
122+
<Callout type="warn">
123+
Leave it unset behind a proxy and Sim resolves no client IP at all. Nothing breaks outright — per-IP rate limits fall back to one shared bucket per endpoint (stricter, not weaker), audit rows record a blank IP, and any webhook configured with an IP allowlist rejects every delivery. Sim will not read the leftmost `X-Forwarded-For` value as a fallback, because a client can set that header itself.
124+
</Callout>
125+
126+
Sim reads `X-Forwarded-For` first and falls back to `X-Real-IP`. If your ingress sets only `X-Real-IP` (nginx's `proxy_set_header X-Real-IP $remote_addr`), that is enough — but make sure the header is *overwritten* at the edge rather than passed through from the client. See [Security](/platform/self-hosting/security).
119127

120128
## Disabling authentication entirely
121129

apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { Callout } from 'fumadocs-ui/components/callout'
3030
| `REDIS_TLS_SERVERNAME` | TLS SNI override. Required when `REDIS_URL` uses `rediss://` with a bare IP, or the app throws at startup |
3131
| `NEXT_PUBLIC_SOCKET_URL` | WebSocket URL — defaults to the page origin; set only if realtime is on a separate host |
3232
| `TRUSTED_ORIGINS` | Comma-separated additional origins to trust for auth (apex + `www`, alias domains) |
33-
| `AUTH_TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs so the client IP cannot be forged through `X-Forwarded-For` |
33+
| `AUTH_TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs so the client IP cannot be forged through `X-Forwarded-For`. Governs sessions, per-IP rate limits, audit log IPs, and webhook IP allowlists — behind a proxy, leaving it unset means no client IP resolves at all |
3434
| `INTERNAL_API_BASE_URL` | Internal URL for server-side self-calls, e.g. `http://sim-app.simstudio.svc.cluster.local:3000`. Required for PII log redaction; defaults to `NEXT_PUBLIC_APP_URL` |
3535
| `DATABASE_REPLICA_URL` | Read-replica connection string for log listing, audit logs, and dashboard aggregations. Falls back to the primary when unset |
3636

apps/docs/content/docs/en/platform/self-hosting/security.mdx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,22 @@ ALLOW_PRIVATE_DATABASE_HOSTS=true
148148

149149
## Client IP and forwarded headers
150150

151-
Behind a load balancer, `X-Forwarded-For` is client-controllable. Set `AUTH_TRUSTED_PROXIES` to your proxies' actual addresses so Better Auth resolves the real client IP, and `TRUSTED_ORIGINS` if users reach Sim from more than one origin. Both are covered in [Authentication](/platform/self-hosting/authentication#behind-a-load-balancer).
151+
`X-Forwarded-For` is appended to by each hop, and the leftmost value is written by the client — so behind a load balancer it is attacker-controlled. Sim never reads it directly. It walks the chain right to left, skips the hops you declare in `AUTH_TRUSTED_PROXIES`, and takes the first address it does not recognize as a proxy. A chain it cannot verify resolves to no IP at all rather than to a guess.
152+
153+
Set `AUTH_TRUSTED_PROXIES` to your proxies' actual addresses, and `TRUSTED_ORIGINS` if users reach Sim from more than one origin. Both are covered in [Authentication](/platform/self-hosting/authentication#behind-a-load-balancer).
154+
155+
What depends on it:
156+
157+
| Surface | With it set | Without it, behind a proxy |
158+
|---|---|---|
159+
| Session records | Real client IP on the session row | Blank |
160+
| Per-IP rate limits on public endpoints | One bucket per client | One shared bucket per endpoint — stricter, and not amplifiable |
161+
| Audit log `ipAddress` | Real client IP | Blank |
162+
| Per-webhook IP allowlists | Enforced against the real client | Every delivery rejected |
163+
164+
<Callout type="warn">
165+
Do not set it to `0.0.0.0/0` (or `::/0`) to "just make it work". Every hop then counts as a trusted proxy, the walk runs past the end of the chain, and *no* request resolves an IP. Sim logs an error at startup if you do, and likewise if an entry is not a valid IP or CIDR — a typo silently drops that entry.
166+
</Callout>
152167

153168
## Restricting who can use the instance
154169

@@ -179,7 +194,7 @@ The service bundles ~2.2 GB of spaCy models, so first start takes around three m
179194
- [ ] Images pinned to an explicit tag or digest on app, realtime, and migrations
180195
- [ ] TLS terminating at the ingress; HTTP redirected or disabled
181196
- [ ] `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` set to the real public origin
182-
- [ ] `AUTH_TRUSTED_PROXIES` set if behind a load balancer
197+
- [ ] `AUTH_TRUSTED_PROXIES` set to the ingress addresses if behind a load balancer (not `0.0.0.0/0`)
183198
- [ ] Signup restricted (`DISABLE_REGISTRATION` or `ALLOWED_LOGIN_DOMAINS`)
184199
- [ ] `DISABLE_AUTH` **not** set
185200
- [ ] NetworkPolicy enabled and `ingressFrom` scoped to the ingress controller

apps/sim/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ BETTER_AUTH_URL=http://localhost:3000
1919
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.
22-
# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients.
22+
# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Sim walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Governs session IPs, per-IP rate limits, audit log IPs, and webhook IP allowlists. Use your proxies' actual addressesnot broad private ranges that also cover clients, and never 0.0.0.0/0 (matches every hop, so nothing resolves).
2323

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

apps/sim/app/api/chat/[identifier]/otp/route.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ describe('Chat OTP API Route', () => {
201201
}))
202202

203203
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123')
204-
requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4')
204+
requestUtilsMockFns.mockResolveClientIp.mockReturnValue('1.2.3.4')
205205

206206
mockCheckRateLimitDirect.mockResolvedValue({
207207
allowed: true,
@@ -342,8 +342,8 @@ describe('Chat OTP API Route', () => {
342342
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
343343
})
344344

345-
it('folds spoofed `unknown` client IPs into a single shared bucket', async () => {
346-
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown')
345+
it('folds untrustworthy client IPs into a single shared bucket', async () => {
346+
requestUtilsMockFns.mockResolveClientIp.mockReturnValueOnce(null)
347347
queueDeployment(emailDeployment)
348348

349349
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
@@ -355,7 +355,7 @@ describe('Chat OTP API Route', () => {
355355

356356
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2)
357357
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
358-
expect.stringMatching(/^chat-otp:ip:.*:unknown$/),
358+
expect.stringMatching(/^chat-otp:ip:.*:unresolved$/),
359359
expect.any(Object)
360360
)
361361
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(

apps/sim/app/api/chat/[identifier]/otp/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
OTP_IP_RATE_LIMIT,
2020
storeOTP,
2121
} from '@/lib/core/security/otp'
22-
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
22+
import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
2323
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2424
import { sendEmail } from '@/lib/messaging/email/mailer'
2525
import { setChatAuthCookie } from '@/app/api/chat/utils'
@@ -35,7 +35,7 @@ export const POST = withRouteHandler(
3535
const requestId = generateRequestId()
3636

3737
try {
38-
const ip = getClientIp(request)
38+
const ip = getRateLimitIpKey(request)
3939
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
4040
`chat-otp:ip:${identifier}:${ip}`,
4141
OTP_IP_RATE_LIMIT

apps/sim/app/api/chat/[identifier]/sso/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { parseRequest } from '@/lib/api/server'
88
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
99
import { RateLimiter } from '@/lib/core/rate-limiter'
1010
import { isEmailAllowed } from '@/lib/core/security/deployment'
11-
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
11+
import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1414

@@ -29,7 +29,7 @@ export const POST = withRouteHandler(
2929
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
3030
const requestId = generateRequestId()
3131

32-
const ip = getClientIp(request)
32+
const ip = getRateLimitIpKey(request)
3333
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
3434
`chat-sso:ip:${ip}`,
3535
SSO_IP_RATE_LIMIT

apps/sim/app/api/chat/utils.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ describe('Chat API Utils', () => {
197197

198198
const mockRequest = {
199199
method: 'POST',
200+
headers: new Headers(),
200201
cookies: {
201202
get: vi.fn().mockReturnValue(null),
202203
},
@@ -221,6 +222,7 @@ describe('Chat API Utils', () => {
221222

222223
const mockRequest = {
223224
method: 'POST',
225+
headers: new Headers(),
224226
cookies: {
225227
get: vi.fn().mockReturnValue(null),
226228
},
@@ -247,6 +249,7 @@ describe('Chat API Utils', () => {
247249

248250
const mockRequest = {
249251
method: 'POST',
252+
headers: new Headers(),
250253
cookies: {
251254
get: vi.fn().mockReturnValue(null),
252255
},
@@ -291,6 +294,7 @@ describe('Chat API Utils', () => {
291294

292295
const mockRequest = {
293296
method: 'POST',
297+
headers: new Headers(),
294298
cookies: {
295299
get: vi.fn().mockReturnValue(null),
296300
},

apps/sim/app/api/contact/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { env } from '@/lib/core/config/env'
1111
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
1212
import { RateLimiter } from '@/lib/core/rate-limiter'
1313
import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile'
14-
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
14+
import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
1515
import { getEmailDomain } from '@/lib/core/utils/urls'
1616
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1717
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -53,7 +53,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
5353
const requestId = generateRequestId()
5454

5555
try {
56-
const ip = getClientIp(req)
56+
const ip = getRateLimitIpKey(req)
5757
const storageKey = `public:contact:${ip}`
5858

5959
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(

apps/sim/app/api/demo-requests/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { parseRequest } from '@/lib/api/server'
88
import { env } from '@/lib/core/config/env'
99
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
1010
import { RateLimiter } from '@/lib/core/rate-limiter'
11-
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
11+
import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
1212
import { getEmailDomain } from '@/lib/core/utils/urls'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -27,7 +27,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
2727
const requestId = generateRequestId()
2828

2929
try {
30-
const ip = getClientIp(req)
30+
const ip = getRateLimitIpKey(req)
3131
const storageKey = `public:demo-request:${ip}`
3232

3333
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(

0 commit comments

Comments
 (0)