diff --git a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx
index 15bcd34c7f7..739b9cf30e9 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx
@@ -109,13 +109,21 @@ Both SSO flags are needed: the server-side one grants access, and the `NEXT_PUBL
## Behind a load balancer
-Tell Better Auth which forwarding hops to trust when resolving the client IP:
+Tell Sim which forwarding hops to trust when resolving the client IP:
```bash
AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10
```
-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).
+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.
+
+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.
+
+
+ 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.
+
+
+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).
## Disabling authentication entirely
diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
index 8a2f01bf27f..287a2faae7c 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
@@ -30,7 +30,7 @@ import { Callout } from 'fumadocs-ui/components/callout'
| `REDIS_TLS_SERVERNAME` | TLS SNI override. Required when `REDIS_URL` uses `rediss://` with a bare IP, or the app throws at startup |
| `NEXT_PUBLIC_SOCKET_URL` | WebSocket URL — defaults to the page origin; set only if realtime is on a separate host |
| `TRUSTED_ORIGINS` | Comma-separated additional origins to trust for auth (apex + `www`, alias domains) |
-| `AUTH_TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs so the client IP cannot be forged through `X-Forwarded-For` |
+| `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 |
| `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` |
| `DATABASE_REPLICA_URL` | Read-replica connection string for log listing, audit logs, and dashboard aggregations. Falls back to the primary when unset |
diff --git a/apps/docs/content/docs/en/platform/self-hosting/security.mdx b/apps/docs/content/docs/en/platform/self-hosting/security.mdx
index 359cb3557c4..a170aaa9198 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/security.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/security.mdx
@@ -148,7 +148,22 @@ ALLOW_PRIVATE_DATABASE_HOSTS=true
## Client IP and forwarded headers
-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).
+`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.
+
+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).
+
+What depends on it:
+
+| Surface | With it set | Without it, behind a proxy |
+|---|---|---|
+| Session records | Real client IP on the session row | Blank |
+| Per-IP rate limits on public endpoints | One bucket per client | One shared bucket per endpoint — stricter, and not amplifiable |
+| Audit log `ipAddress` | Real client IP | Blank |
+| Per-webhook IP allowlists | Enforced against the real client | Every delivery rejected |
+
+
+ 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.
+
## Restricting who can use the instance
@@ -179,7 +194,7 @@ The service bundles ~2.2 GB of spaCy models, so first start takes around three m
- [ ] Images pinned to an explicit tag or digest on app, realtime, and migrations
- [ ] TLS terminating at the ingress; HTTP redirected or disabled
- [ ] `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` set to the real public origin
-- [ ] `AUTH_TRUSTED_PROXIES` set if behind a load balancer
+- [ ] `AUTH_TRUSTED_PROXIES` set to the ingress addresses if behind a load balancer (not `0.0.0.0/0`)
- [ ] Signup restricted (`DISABLE_REGISTRATION` or `ALLOWED_LOGIN_DOMAINS`)
- [ ] `DISABLE_AUTH` **not** set
- [ ] NetworkPolicy enabled and `ingressFrom` scoped to the ingress controller
diff --git a/apps/sim/.env.example b/apps/sim/.env.example
index 4b3c6dc81c5..bb1eee13d84 100644
--- a/apps/sim/.env.example
+++ b/apps/sim/.env.example
@@ -19,7 +19,7 @@ BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
# 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
# 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.
-# 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.
+# 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 addresses — not broad private ranges that also cover clients, and never 0.0.0.0/0 (matches every hop, so nothing resolves).
# Chat (Optional)
# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run
diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts
index 1c60db08fa3..ea3c752d235 100644
--- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts
+++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts
@@ -201,7 +201,7 @@ describe('Chat OTP API Route', () => {
}))
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123')
- requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4')
+ requestUtilsMockFns.mockResolveClientIp.mockReturnValue('1.2.3.4')
mockCheckRateLimitDirect.mockResolvedValue({
allowed: true,
@@ -342,8 +342,8 @@ describe('Chat OTP API Route', () => {
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
})
- it('folds spoofed `unknown` client IPs into a single shared bucket', async () => {
- requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown')
+ it('folds untrustworthy client IPs into a single shared bucket', async () => {
+ requestUtilsMockFns.mockResolveClientIp.mockReturnValueOnce(null)
queueDeployment(emailDeployment)
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
@@ -355,7 +355,7 @@ describe('Chat OTP API Route', () => {
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2)
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
- expect.stringMatching(/^chat-otp:ip:.*:unknown$/),
+ expect.stringMatching(/^chat-otp:ip:.*:unresolved$/),
expect.any(Object)
)
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts
index 9f7fffd741f..e113f853f95 100644
--- a/apps/sim/app/api/chat/[identifier]/otp/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts
@@ -19,7 +19,7 @@ import {
OTP_IP_RATE_LIMIT,
storeOTP,
} from '@/lib/core/security/otp'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { setChatAuthCookie } from '@/app/api/chat/utils'
@@ -35,7 +35,7 @@ export const POST = withRouteHandler(
const requestId = generateRequestId()
try {
- const ip = getClientIp(request)
+ const ip = getRateLimitIpKey(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-otp:ip:${identifier}:${ip}`,
OTP_IP_RATE_LIMIT
diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts
index c6ab98cfe94..730b78c98f1 100644
--- a/apps/sim/app/api/chat/[identifier]/sso/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts
@@ -8,7 +8,7 @@ import { parseRequest } from '@/lib/api/server'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isEmailAllowed } from '@/lib/core/security/deployment'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -29,7 +29,7 @@ export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
const requestId = generateRequestId()
- const ip = getClientIp(request)
+ const ip = getRateLimitIpKey(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-sso:ip:${ip}`,
SSO_IP_RATE_LIMIT
diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts
index 6c41eeb21cc..9d91cdb7db2 100644
--- a/apps/sim/app/api/chat/utils.test.ts
+++ b/apps/sim/app/api/chat/utils.test.ts
@@ -197,6 +197,7 @@ describe('Chat API Utils', () => {
const mockRequest = {
method: 'POST',
+ headers: new Headers(),
cookies: {
get: vi.fn().mockReturnValue(null),
},
@@ -221,6 +222,7 @@ describe('Chat API Utils', () => {
const mockRequest = {
method: 'POST',
+ headers: new Headers(),
cookies: {
get: vi.fn().mockReturnValue(null),
},
@@ -247,6 +249,7 @@ describe('Chat API Utils', () => {
const mockRequest = {
method: 'POST',
+ headers: new Headers(),
cookies: {
get: vi.fn().mockReturnValue(null),
},
@@ -291,6 +294,7 @@ describe('Chat API Utils', () => {
const mockRequest = {
method: 'POST',
+ headers: new Headers(),
cookies: {
get: vi.fn().mockReturnValue(null),
},
diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts
index 2b610ec2114..0aaa6434b1b 100644
--- a/apps/sim/app/api/contact/route.ts
+++ b/apps/sim/app/api/contact/route.ts
@@ -11,7 +11,7 @@ import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -53,7 +53,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
try {
- const ip = getClientIp(req)
+ const ip = getRateLimitIpKey(req)
const storageKey = `public:contact:${ip}`
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts
index 7553239e7b2..6d05e6b1aa8 100644
--- a/apps/sim/app/api/demo-requests/route.ts
+++ b/apps/sim/app/api/demo-requests/route.ts
@@ -8,7 +8,7 @@ import { parseRequest } from '@/lib/api/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -27,7 +27,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
try {
- const ip = getClientIp(req)
+ const ip = getRateLimitIpKey(req)
const storageKey = `public:demo-request:${ip}`
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts
index 0dd240788fd..2894b13fe59 100644
--- a/apps/sim/app/api/files/public/[token]/otp/route.ts
+++ b/apps/sim/app/api/files/public/[token]/otp/route.ts
@@ -21,7 +21,7 @@ import {
OTP_IP_RATE_LIMIT,
storeOTP,
} from '@/lib/core/security/otp'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
@@ -57,7 +57,7 @@ export const POST = withRouteHandler(
const requestId = generateRequestId()
try {
- const ip = getClientIp(request)
+ const ip = getRateLimitIpKey(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`file-otp:ip:${ip}`,
OTP_IP_RATE_LIMIT
diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts
index b5185149440..3f084a5462f 100644
--- a/apps/sim/app/api/files/public/[token]/sso/route.ts
+++ b/apps/sim/app/api/files/public/[token]/sso/route.ts
@@ -7,7 +7,7 @@ import { parseRequest } from '@/lib/api/server'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isEmailAllowed } from '@/lib/core/security/deployment'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
@@ -33,7 +33,7 @@ export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
const requestId = generateRequestId()
- const ip = getClientIp(request)
+ const ip = getRateLimitIpKey(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`file-sso:ip:${ip}`,
SSO_IP_RATE_LIMIT
diff --git a/apps/sim/app/api/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts
index 6a8faf682b6..82b62f81f57 100644
--- a/apps/sim/app/api/help/integration-request/route.ts
+++ b/apps/sim/app/api/help/integration-request/route.ts
@@ -5,7 +5,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { generateRequestId, getRateLimitIpKey } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -25,7 +25,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
try {
- const ip = getClientIp(req)
+ const ip = getRateLimitIpKey(req)
const storageKey = `public:integration-request:${ip}`
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts
index ff8c568e14d..29e67a2132a 100644
--- a/apps/sim/lib/analytics/profound.ts
+++ b/apps/sim/lib/analytics/profound.ts
@@ -8,7 +8,7 @@
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
-import { getClientIp } from '@/lib/core/utils/request'
+import { resolveClientIp } from '@/lib/core/utils/request'
import { getBaseDomain } from '@/lib/core/utils/urls'
const logger = createLogger('ProfoundAnalytics')
@@ -102,10 +102,7 @@ export function sendToProfound(request: Request, statusCode: number): void {
host: getBaseDomain(),
path: url.pathname,
status_code: statusCode,
- ip: (() => {
- const resolved = getClientIp(request)
- return resolved === 'unknown' ? '0.0.0.0' : resolved
- })(),
+ ip: resolveClientIp(request) ?? '0.0.0.0',
user_agent: request.headers.get('user-agent') || '',
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
...(request.headers.get('referer') && { referer: request.headers.get('referer')! }),
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index dbf25fdaf02..d2edb33ca18 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -4,6 +4,12 @@ import { stripe } from '@better-auth/stripe'
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
+import {
+ CLIENT_IP_HEADERS,
+ findMalformedTrustedProxies,
+ isAllTrustingProxyEntry,
+ parseTrustedProxies,
+} from '@sim/security/client-ip'
import { toError } from '@sim/utils/errors'
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
@@ -141,15 +147,31 @@ if (validStripeKey) {
}
/**
- * Reverse-proxy hops trusted for forwarded-IP resolution. When configured,
- * Better Auth walks the x-forwarded-for chain right to left, skips these
- * hops, and records the first untrusted address as the session client IP —
- * preventing header spoofing behind multi-hop proxies.
+ * Reverse-proxy hops trusted for forwarded-IP resolution: the chain is walked
+ * right to left, these hops are skipped, and the first untrusted address is the
+ * client. Parsed with the same helper `resolveClientIp` uses so the session's
+ * recorded IP and the one every other caller resolves cannot diverge.
+ */
+const trustedProxies = parseTrustedProxies(env.AUTH_TRUSTED_PROXIES)
+
+/**
+ * Both misconfigurations below leave every request resolving no IP at all, and
+ * neither throws — an operator can only discover them if we say so.
*/
-const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '')
- .split(',')
- .map((entry) => entry.trim())
- .filter(Boolean)
+const malformedTrustedProxies = findMalformedTrustedProxies(trustedProxies)
+if (malformedTrustedProxies.length > 0) {
+ logger.error('AUTH_TRUSTED_PROXIES contains entries that are not an IP or CIDR range', {
+ malformedTrustedProxies,
+ })
+}
+
+const allTrustingProxies = trustedProxies.filter(isAllTrustingProxyEntry)
+if (allTrustingProxies.length > 0) {
+ logger.error(
+ 'AUTH_TRUSTED_PROXIES trusts every hop, so no request will resolve a client IP — scope it to your ingress ranges',
+ { allTrustingProxies }
+ )
+}
export const auth = betterAuth({
baseURL: getBaseUrl(),
@@ -198,6 +220,9 @@ export const auth = betterAuth({
},
advanced: {
ipAddress: {
+ // The same header list and proxy set `resolveClientIp` uses, so the
+ // address recorded on a session row is the one every other caller sees.
+ ipAddressHeaders: [...CLIENT_IP_HEADERS],
...(trustedProxies.length > 0 ? { trustedProxies } : {}),
},
},
diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts
index 1880efee202..c10de356420 100644
--- a/apps/sim/lib/auth/internal.ts
+++ b/apps/sim/lib/auth/internal.ts
@@ -3,7 +3,7 @@ import { safeCompare } from '@sim/security/compare'
import { jwtVerify, SignJWT } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
-import { getClientIp } from '@/lib/core/utils/request'
+import { resolveClientIp } from '@/lib/core/utils/request'
const logger = createLogger('CronAuth')
@@ -101,7 +101,7 @@ export function verifyCronAuth(request: NextRequest, context?: string): NextResp
if (!env.CRON_SECRET) {
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`CRON endpoint accessed but CRON_SECRET is not configured${contextInfo}`, {
- ip: getClientIp(request),
+ ip: resolveClientIp(request),
userAgent: request.headers.get('user-agent') ?? 'unknown',
context,
})
@@ -115,7 +115,7 @@ export function verifyCronAuth(request: NextRequest, context?: string): NextResp
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
hasAuthorizationHeader: authHeader !== null,
- ip: getClientIp(request),
+ ip: resolveClientIp(request),
userAgent: request.headers.get('user-agent') ?? 'unknown',
context,
})
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index fd72ec23a89..51ee59920fd 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -525,7 +525,7 @@ export const env = createEnv({
REACT_SCAN_ENABLED: z.boolean().optional(), // Enable React Scan for performance debugging (dev only)
// Network / proxy trust
- AUTH_TRUSTED_PROXIES: z.string().optional(), // Comma-separated reverse-proxy IPs or CIDR ranges. When set, Better Auth walks the forwarded-IP chain right to left, skips these trusted hops, and uses the first untrusted address as the client IP. Leave unset to trust only single-value IP headers.
+ AUTH_TRUSTED_PROXIES: z.string().optional(), // Comma-separated reverse-proxy IPs or CIDR ranges. When set, the forwarded-IP chain is walked right to left, these trusted hops are skipped, and the first untrusted address becomes the client IP. Governs sessions, per-IP rate limits, audit log IPs, and webhook IP allowlists. Leave unset to trust only single-value IP headers.
// SSO Configuration (for script-based registration)
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
diff --git a/apps/sim/lib/core/rate-limiter/route-helpers.test.ts b/apps/sim/lib/core/rate-limiter/route-helpers.test.ts
index 0f895e81e1a..ca10680357d 100644
--- a/apps/sim/lib/core/rate-limiter/route-helpers.test.ts
+++ b/apps/sim/lib/core/rate-limiter/route-helpers.test.ts
@@ -1,7 +1,7 @@
/**
* @vitest-environment node
*/
-import { createMockRequest, requestUtilsMockFns } from '@sim/testing'
+import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
const { mockAdapter } = vi.hoisted(() => ({
@@ -22,15 +22,6 @@ vi.mock('@/lib/core/rate-limiter/storage', async () => {
}
})
-function passThroughClientIp() {
- requestUtilsMockFns.mockGetClientIp.mockImplementation(
- (req: { headers: { get(name: string): string | null } }) =>
- req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
- req.headers.get('x-real-ip')?.trim() ||
- 'unknown'
- )
-}
-
import { enforceIpRateLimit, enforceUserOrIpRateLimit, enforceUserRateLimit } from './route-helpers'
const consume = mockAdapter.consumeTokens as Mock
@@ -102,18 +93,14 @@ describe('route-helpers rate limiting', () => {
})
describe('enforceIpRateLimit', () => {
- beforeEach(() => {
- passThroughClientIp()
- })
-
- it('uses the X-Forwarded-For client IP in the bucket key', async () => {
+ it('uses a trustworthy client IP in the bucket key', async () => {
consume.mockResolvedValueOnce({
allowed: true,
tokensRemaining: 9,
resetAt: new Date(),
})
const request = createMockRequest('POST', undefined, {
- 'x-forwarded-for': '203.0.113.7, 10.0.0.1',
+ 'x-forwarded-for': '203.0.113.7',
})
await enforceIpRateLimit('public-bucket', request)
@@ -125,7 +112,30 @@ describe('route-helpers rate limiting', () => {
)
})
- it('folds spoofed `X-Forwarded-For: unknown` into a single shared bucket', async () => {
+ it('does not let a prepended forwarded hop mint a per-request bucket', async () => {
+ // No trusted proxies are configured, so the chain is unverifiable and
+ // every such request shares one bucket. Reading the leftmost token
+ // instead would hand an attacker a fresh full allowance per request.
+ consume.mockResolvedValue({
+ allowed: true,
+ tokensRemaining: 9,
+ resetAt: new Date(),
+ })
+
+ const reqA = createMockRequest('POST', undefined, {
+ 'x-forwarded-for': '203.0.113.7, 10.0.0.1',
+ })
+ const reqB = createMockRequest('POST', undefined, {
+ 'x-forwarded-for': '198.51.100.4, 10.0.0.1',
+ })
+ await enforceIpRateLimit('otp', reqA)
+ await enforceIpRateLimit('otp', reqB)
+
+ const keys = consume.mock.calls.map((call) => call[0])
+ expect(keys).toEqual(['route:otp:ip:unresolved', 'route:otp:ip:unresolved'])
+ })
+
+ it('folds an unparseable forwarded header into the same shared bucket', async () => {
consume.mockResolvedValue({
allowed: true,
tokensRemaining: 9,
@@ -138,7 +148,7 @@ describe('route-helpers rate limiting', () => {
await enforceIpRateLimit('otp', reqB)
const keys = consume.mock.calls.map((call) => call[0])
- expect(keys).toEqual(['route:otp:ip:unknown', 'route:otp:ip:unknown'])
+ expect(keys).toEqual(['route:otp:ip:unresolved', 'route:otp:ip:unresolved'])
})
it('returns a 429 with Retry-After on rate limit', async () => {
@@ -159,9 +169,7 @@ describe('route-helpers rate limiting', () => {
})
describe('enforceUserOrIpRateLimit', () => {
- beforeEach(() => {
- passThroughClientIp()
- })
+ beforeEach(() => {})
it('keys per-user when userId is present', async () => {
consume.mockResolvedValueOnce({
diff --git a/apps/sim/lib/core/rate-limiter/route-helpers.ts b/apps/sim/lib/core/rate-limiter/route-helpers.ts
index f71115bf532..8472dac6823 100644
--- a/apps/sim/lib/core/rate-limiter/route-helpers.ts
+++ b/apps/sim/lib/core/rate-limiter/route-helpers.ts
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter/storage'
-import { getClientIp } from '@/lib/core/utils/request'
+import { getRateLimitIpKey } from '@/lib/core/utils/request'
const logger = createLogger('RouteRateLimit')
const rateLimiter = new RateLimiter()
@@ -55,16 +55,15 @@ export async function enforceUserRateLimit(
}
/**
- * Apply a per-IP token bucket to an unauthenticated route. The `unknown` IP
- * fallback shares one global bucket per route so it cannot be amplified by
- * `X-Forwarded-For: unknown` spoofing.
+ * Apply a per-IP token bucket to an unauthenticated route. Requests with no
+ * trustworthy IP share one bucket per route — see {@link getRateLimitIpKey}.
*/
export async function enforceIpRateLimit(
bucketName: string,
request: NextRequest,
config: TokenBucketConfig = DEFAULT_PUBLIC_IP_ROUTE_LIMIT
): Promise {
- const ip = getClientIp(request)
+ const ip = getRateLimitIpKey(request)
const key = `route:${bucketName}:ip:${ip}`
const { allowed, resetAt } = await rateLimiter.checkRateLimitDirect(key, config)
if (allowed) return null
diff --git a/apps/sim/lib/core/security/deployment-auth.ts b/apps/sim/lib/core/security/deployment-auth.ts
index 69c842def61..f830b29178e 100644
--- a/apps/sim/lib/core/security/deployment-auth.ts
+++ b/apps/sim/lib/core/security/deployment-auth.ts
@@ -1,8 +1,7 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import type { NextRequest } from 'next/server'
-import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
-import { RateLimiter } from '@/lib/core/rate-limiter'
+import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter'
import {
type DeploymentAuthKind,
deploymentAuthCookieName,
@@ -10,7 +9,7 @@ import {
validateAuthToken,
} from '@/lib/core/security/deployment'
import { decryptSecret } from '@/lib/core/security/encryption'
-import { getClientIp } from '@/lib/core/utils/request'
+import { getRateLimitIpKey } from '@/lib/core/utils/request'
const logger = createLogger('DeploymentAuth')
@@ -105,7 +104,7 @@ export async function validateDeploymentAuth(
return { authorized: false, error: 'Authentication configuration error' }
}
- const ip = getClientIp(request)
+ const ip = getRateLimitIpKey(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`${cookiePrefix}-password:ip:${resource.id}:${ip}`,
PASSWORD_IP_RATE_LIMIT
diff --git a/apps/sim/lib/core/utils/request.ts b/apps/sim/lib/core/utils/request.ts
index 84150f4a38c..568fb9138ab 100644
--- a/apps/sim/lib/core/utils/request.ts
+++ b/apps/sim/lib/core/utils/request.ts
@@ -1,5 +1,13 @@
import { getRequestContext } from '@sim/logger'
+import {
+ type ClientIpHeaders,
+ parseTrustedProxies,
+ resolveClientIp as resolveClientIpWith,
+ UNRESOLVED_CLIENT_IP_BUCKET,
+} from '@sim/security/client-ip'
import { generateId } from '@sim/utils/id'
+import { env } from '@/lib/core/config/env'
+
/**
* Generate a short request ID for correlation. If called inside a request
* context (see `withRouteHandler` and `runWithRequestContext`), returns the
@@ -10,13 +18,27 @@ export function generateRequestId(): string {
return getRequestContext()?.requestId ?? generateId().slice(0, 8)
}
+/** Shared with Better Auth's `advanced.ipAddress.trustedProxies` — see auth.ts. */
+const trustedProxies = parseTrustedProxies(env.AUTH_TRUSTED_PROXIES)
+
+/**
+ * The request's client IP, or `null` when none can be trusted. `null` is a real
+ * outcome, not an error: behind an unconfigured proxy the forwarded chain is
+ * unverifiable, and resolving it anyway would return a client-controlled value.
+ * Never treat it as a match.
+ */
+export function resolveClientIp(request: { headers: ClientIpHeaders }): string | null {
+ return resolveClientIpWith(request, { trustedProxies })
+}
+
/**
- * Extract the client IP from a request, checking `x-forwarded-for` then `x-real-ip`.
+ * The client IP to key a rate limit on, falling back to one shared bucket when
+ * no trustworthy address exists — the opposite of what an IP-gated check does
+ * with the same `null`. Callers behind an unverifiable chain are
+ * indistinguishable, so they must not each get a full allowance; that is the
+ * amplification a spoofable IP hands an attacker. Use this for every IP-keyed
+ * limit rather than resolving and defaulting at the call site.
*/
-export function getClientIp(request: { headers: { get(name: string): string | null } }): string {
- return (
- request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
- request.headers.get('x-real-ip')?.trim() ||
- 'unknown'
- )
+export function getRateLimitIpKey(request: { headers: ClientIpHeaders }): string {
+ return resolveClientIp(request) ?? UNRESOLVED_CLIENT_IP_BUCKET
}
diff --git a/apps/sim/lib/public-shares/rate-limit.ts b/apps/sim/lib/public-shares/rate-limit.ts
index 60f7223a60d..ce5657ed162 100644
--- a/apps/sim/lib/public-shares/rate-limit.ts
+++ b/apps/sim/lib/public-shares/rate-limit.ts
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'
import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter'
-import { getClientIp } from '@/lib/core/utils/request'
+import { getRateLimitIpKey } from '@/lib/core/utils/request'
const rateLimiter = new RateLimiter()
@@ -29,7 +29,7 @@ export async function enforcePublicFileRateLimit(
request: { headers: { get(name: string): string | null } },
scope: 'metadata' | 'content'
): Promise {
- const ip = getClientIp(request)
+ const ip = getRateLimitIpKey(request)
const config = scope === 'content' ? CONTENT_RATE_LIMIT : METADATA_RATE_LIMIT
const result = await rateLimiter.checkRateLimitDirect(`public-file:${scope}:${ip}`, config)
if (result.allowed) return null
diff --git a/apps/sim/lib/webhooks/providers/generic.ts b/apps/sim/lib/webhooks/providers/generic.ts
index 71372bebad6..5f3f174dd79 100644
--- a/apps/sim/lib/webhooks/providers/generic.ts
+++ b/apps/sim/lib/webhooks/providers/generic.ts
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
-import { getClientIp } from '@/lib/core/utils/request'
+import { resolveClientIp } from '@/lib/core/utils/request'
import type {
AuthContext,
EventFilterContext,
@@ -31,9 +31,11 @@ export const genericHandler: WebhookProviderHandler = {
const allowedIps = providerConfig.allowedIps
if (allowedIps && Array.isArray(allowedIps) && allowedIps.length > 0) {
- const clientIp = getClientIp(request)
+ // An unresolvable IP denies — this allowlist is the only thing between
+ // the webhook and the public internet.
+ const clientIp = resolveClientIp(request)
- if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) {
+ if (clientIp === null || !allowedIps.includes(clientIp)) {
logger.warn(`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`)
return new NextResponse('Forbidden - IP not allowed', {
status: 403,
diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts
index 64adc724673..46c038ee16d 100644
--- a/apps/sim/proxy.ts
+++ b/apps/sim/proxy.ts
@@ -5,7 +5,7 @@ import { sendToProfound } from './lib/analytics/profound'
import { getEnv } from './lib/core/config/env'
import { isAuthDisabled, isDev, isHosted } from './lib/core/config/env-flags'
import { generateRuntimeCSP } from './lib/core/security/csp'
-import { getClientIp } from './lib/core/utils/request'
+import { resolveClientIp } from './lib/core/utils/request'
import { isNonCanonicalSimHost } from './lib/core/utils/urls'
const logger = createLogger('Proxy')
@@ -212,7 +212,7 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isMcpOauthDiscoveryEndpoint) {
logger.warn('Blocked suspicious request', {
userAgent,
- ip: getClientIp(request),
+ ip: resolveClientIp(request),
url: request.url,
method: request.method,
pattern: SUSPICIOUS_UA_PATTERNS.find((pattern) => pattern.test(userAgent))?.toString(),
diff --git a/bun.lock b/bun.lock
index 80d2428e5b0..e8bbc8df236 100644
--- a/bun.lock
+++ b/bun.lock
@@ -380,6 +380,7 @@
"dependencies": {
"@sim/db": "workspace:*",
"@sim/logger": "workspace:*",
+ "@sim/security": "workspace:*",
"@sim/utils": "workspace:*",
"drizzle-orm": "^0.45.2",
},
@@ -576,6 +577,7 @@
"name": "@sim/security",
"version": "0.1.0",
"dependencies": {
+ "@better-auth/core": "1.6.23",
"ipaddr.js": "2.3.0",
},
"devDependencies": {
@@ -601,6 +603,7 @@
"@sim/utils": "workspace:*",
},
"devDependencies": {
+ "@sim/security": "workspace:*",
"@sim/tsconfig": "workspace:*",
"typescript": "^7.0.2",
"vitest": "^4.1.0",
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 7c733774c9e..c834183037c 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -23,10 +23,13 @@ services:
# (apex + www, alias hostnames, reverse-proxy IPs). Empty by default.
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-}
# AUTH_TRUSTED_PROXIES: comma-separated reverse-proxy IPs or CIDR ranges in
- # front of the app (ingress, load balancer). Better Auth walks
- # x-forwarded-for right to left, skips these hops, and uses the first
- # untrusted address as the client IP. Required for correct session IPs and
- # rate-limit keying behind a multi-hop proxy chain. Empty by default.
+ # front of the app (ingress, load balancer). Sim walks x-forwarded-for
+ # right to left, skips these hops, and uses the first untrusted address as
+ # the client IP. Governs session IPs, per-IP rate-limit keying, audit log
+ # IPs, and webhook IP allowlists. Behind a proxy, leaving this empty means
+ # no client IP resolves at all — the leftmost x-forwarded-for value is
+ # never used as a fallback, since clients can set it themselves. Do not
+ # use 0.0.0.0/0: it matches every hop and resolves nothing.
- AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-}
# Required. Compose aborts with this message rather than starting the app
# with an empty secret, which would silently corrupt stored credentials.
diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml
index 43686a3715f..d708f3cc081 100644
--- a/helm/sim/values.yaml
+++ b/helm/sim/values.yaml
@@ -80,9 +80,17 @@ app:
# Merged into Better Auth `trustedOrigins` alongside NEXT_PUBLIC_APP_URL. Leave empty when serving from a single origin.
TRUSTED_ORIGINS: ""
# AUTH_TRUSTED_PROXIES: comma-separated reverse-proxy IPs or CIDR ranges in front of the app
- # (ingress controller, load balancer). Better Auth walks x-forwarded-for right to left, skips
- # these hops, and uses the first untrusted address as the client IP. Required for correct
- # session IPs and rate-limit keying behind a multi-hop proxy chain (e.g. "10.0.0.0/16").
+ # (ingress controller, load balancer). Sim walks x-forwarded-for right to left, skips these
+ # hops, and uses the first untrusted address as the client IP; x-real-ip is the fallback when
+ # x-forwarded-for yields nothing. Governs session IPs, per-IP rate-limit keying, the audit
+ # log ipAddress column, and per-webhook IP allowlists.
+ #
+ # Set this to your ingress controller's pod/service CIDR (e.g. "10.0.0.0/16"), NOT to
+ # 0.0.0.0/0 — that matches every hop, so the walk finds no client and nothing resolves an IP.
+ # Left empty behind a proxy, no client IP resolves: rate limits fall back to one shared
+ # bucket per endpoint, audit rows record a blank IP, and webhook IP allowlists reject all
+ # deliveries. The leftmost x-forwarded-for value is never used as a fallback — clients can
+ # set that header themselves.
AUTH_TRUSTED_PROXIES: ""
# SOCKET_SERVER_URL: Auto-detected when realtime.enabled=true (uses internal service)
# NEXT_PUBLIC_SOCKET_URL: public WebSocket URL for browsers. Leave empty to default to the
diff --git a/packages/audit/package.json b/packages/audit/package.json
index caaf323d8d5..dec8f4ae398 100644
--- a/packages/audit/package.json
+++ b/packages/audit/package.json
@@ -27,6 +27,7 @@
"dependencies": {
"@sim/db": "workspace:*",
"@sim/logger": "workspace:*",
+ "@sim/security": "workspace:*",
"@sim/utils": "workspace:*",
"drizzle-orm": "^0.45.2"
},
diff --git a/packages/audit/src/log.test.ts b/packages/audit/src/log.test.ts
index 98a71773e65..ecfaed64fa8 100644
--- a/packages/audit/src/log.test.ts
+++ b/packages/audit/src/log.test.ts
@@ -1,13 +1,7 @@
/**
* @vitest-environment node
*/
-import {
- auditMock,
- dbChainMock,
- dbChainMockFns,
- requestUtilsMockFns,
- resetDbChainMock,
-} from '@sim/testing'
+import { auditMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({
@@ -75,12 +69,6 @@ describe('recordAudit', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
- requestUtilsMockFns.mockGetClientIp.mockImplementation(
- (request: { headers: { get(name: string): string | null } }) =>
- request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
- request.headers.get('x-real-ip')?.trim() ||
- 'unknown'
- )
})
afterEach(() => {
@@ -139,10 +127,10 @@ describe('recordAudit', () => {
)
})
- it('extracts IP address from x-forwarded-for header', async () => {
+ it('extracts IP address from a single-value x-forwarded-for header', async () => {
const request = new Request('https://example.com', {
headers: {
- 'x-forwarded-for': '1.2.3.4, 5.6.7.8',
+ 'x-forwarded-for': '1.2.3.4',
'user-agent': 'TestAgent/1.0',
},
})
@@ -167,6 +155,37 @@ describe('recordAudit', () => {
)
})
+ it('records no IP for an unverifiable forwarded chain', async () => {
+ // With no trusted proxies configured the leftmost token is client-supplied.
+ // A blank `ipAddress` is honest; recording `1.2.3.4` would put an
+ // attacker-chosen string into the audit trail as though it were evidence.
+ const request = new Request('https://example.com', {
+ headers: {
+ 'x-forwarded-for': '1.2.3.4, 5.6.7.8',
+ 'user-agent': 'TestAgent/1.0',
+ },
+ })
+
+ recordAudit({
+ workspaceId: 'ws-1',
+ actorId: 'user-1',
+ actorName: 'Test',
+ actorEmail: 'test@test.com',
+ action: AuditAction.MEMBER_INVITED,
+ resourceType: AuditResourceType.WORKSPACE,
+ request,
+ })
+
+ await flush()
+
+ expect(dbChainMockFns.values).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ipAddress: undefined,
+ userAgent: 'TestAgent/1.0',
+ })
+ )
+ })
+
it('falls back to x-real-ip when x-forwarded-for is absent', async () => {
const request = new Request('https://example.com', {
headers: { 'x-real-ip': '10.0.0.1' },
diff --git a/packages/audit/src/log.ts b/packages/audit/src/log.ts
index 93381ae43b7..57491939e3b 100644
--- a/packages/audit/src/log.ts
+++ b/packages/audit/src/log.ts
@@ -1,5 +1,6 @@
import { auditLog, db, user } from '@sim/db'
import { createLogger } from '@sim/logger'
+import { type ClientIpHeaders, parseTrustedProxies, resolveClientIp } from '@sim/security/client-ip'
import { generateShortId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import type { AuditActionType, AuditResourceTypeValue } from './types'
@@ -26,12 +27,18 @@ interface AuditLogParams {
request?: { headers: { get(name: string): string | null } }
}
-function getClientIp(request: { headers: { get(name: string): string | null } }): string {
- return (
- request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
- request.headers.get('x-real-ip')?.trim() ||
- 'unknown'
- )
+/** Same variable the app and Better Auth read, so all three agree on the IP. */
+const trustedProxies = parseTrustedProxies(
+ typeof process !== 'undefined' ? process.env.AUTH_TRUSTED_PROXIES : undefined
+)
+
+/**
+ * The actor's client IP, or `undefined` when none can be trusted. A blank
+ * `ipAddress` is honest; a client-supplied one is worse than blank, because in
+ * an audit trail it reads as evidence.
+ */
+function resolveAuditIpAddress(request: { headers: ClientIpHeaders }): string | undefined {
+ return resolveClientIp(request, { trustedProxies }) ?? undefined
}
/**
@@ -89,7 +96,7 @@ function buildAuditRow(
resourceName: params.resourceName,
description: params.description,
metadata: params.metadata ?? {},
- ipAddress: params.request ? getClientIp(params.request) : undefined,
+ ipAddress: params.request ? resolveAuditIpAddress(params.request) : undefined,
userAgent: params.request?.headers.get('user-agent') ?? undefined,
}
}
diff --git a/packages/security/package.json b/packages/security/package.json
index 68b9e74dfb6..9cc6f80766e 100644
--- a/packages/security/package.json
+++ b/packages/security/package.json
@@ -10,6 +10,10 @@
"node": ">=20.0.0"
},
"exports": {
+ "./client-ip": {
+ "types": "./src/client-ip.ts",
+ "default": "./src/client-ip.ts"
+ },
"./compare": {
"types": "./src/compare.ts",
"default": "./src/compare.ts"
@@ -53,6 +57,7 @@
"test:watch": "vitest"
},
"dependencies": {
+ "@better-auth/core": "1.6.23",
"ipaddr.js": "2.3.0"
},
"devDependencies": {
diff --git a/packages/security/src/client-ip.test.ts b/packages/security/src/client-ip.test.ts
new file mode 100644
index 00000000000..e5229749f32
--- /dev/null
+++ b/packages/security/src/client-ip.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it } from 'vitest'
+import {
+ findMalformedTrustedProxies,
+ isAllTrustingProxyEntry,
+ parseTrustedProxies,
+ resolveClientIp,
+} from './client-ip'
+
+/** Builds a request stub carrying only the given headers. */
+function requestWith(headers: Record) {
+ const normalized = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
+ return { headers: { get: (name: string) => normalized.get(name.toLowerCase()) ?? null } }
+}
+
+/** A CIDR covering the load balancer subnet in the multi-hop cases below. */
+const LB_SUBNET = ['10.0.0.0/8']
+
+describe('resolveClientIp', () => {
+ describe('without trusted proxies', () => {
+ it('trusts a single-value forwarded header', () => {
+ expect(resolveClientIp(requestWith({ 'x-forwarded-for': '203.0.113.7' }))).toBe('203.0.113.7')
+ })
+
+ it('refuses to resolve a multi-hop chain', () => {
+ // The chain cannot be verified without knowing which hops are ours, and
+ // the leftmost token is client-supplied. Returning either end would be a
+ // guess; only `null` is honest.
+ expect(
+ resolveClientIp(requestWith({ 'x-forwarded-for': '203.0.113.7, 198.51.100.1' }))
+ ).toBeNull()
+ })
+
+ it('returns null when no forwarded header is present', () => {
+ expect(resolveClientIp(requestWith({}))).toBeNull()
+ })
+
+ it('never returns the literal "unknown"', () => {
+ expect(resolveClientIp(requestWith({ 'x-forwarded-for': 'unknown' }))).toBeNull()
+ })
+ })
+
+ describe('with trusted proxies', () => {
+ it('returns the first untrusted hop, walking right to left', () => {
+ expect(
+ resolveClientIp(requestWith({ 'x-forwarded-for': '203.0.113.7, 10.0.0.5' }), {
+ trustedProxies: LB_SUBNET,
+ })
+ ).toBe('203.0.113.7')
+ })
+
+ it('ignores a client-prepended address ahead of the real one', () => {
+ // The spoof case: an attacker sends `X-Forwarded-For: 203.0.113.9`, the
+ // load balancer appends the true peer, and the header arrives as the
+ // chain below. The attacker's value must not win.
+ const resolved = resolveClientIp(
+ requestWith({ 'x-forwarded-for': '203.0.113.9, 198.51.100.1, 10.0.0.5' }),
+ { trustedProxies: LB_SUBNET }
+ )
+ expect(resolved).toBe('198.51.100.1')
+ expect(resolved).not.toBe('203.0.113.9')
+ })
+
+ it('fails closed on a malformed hop rather than skipping it', () => {
+ expect(
+ resolveClientIp(requestWith({ 'x-forwarded-for': '203.0.113.7, not-an-ip, 10.0.0.5' }), {
+ trustedProxies: LB_SUBNET,
+ })
+ ).toBeNull()
+ })
+
+ it('returns null when every hop is trusted', () => {
+ expect(
+ resolveClientIp(requestWith({ 'x-forwarded-for': '10.0.0.4, 10.0.0.5' }), {
+ trustedProxies: LB_SUBNET,
+ })
+ ).toBeNull()
+ })
+
+ it('resolves nothing when the trusted set covers every address', () => {
+ // `0.0.0.0/0` is valid CIDR, so it survives parsing and then matches
+ // every hop — the walk runs off the end and yields null rather than
+ // falling back to the client-supplied leftmost token.
+ expect(
+ resolveClientIp(requestWith({ 'x-forwarded-for': '203.0.113.9, 10.0.0.5' }), {
+ trustedProxies: ['0.0.0.0/0'],
+ })
+ ).toBeNull()
+ expect(
+ resolveClientIp(requestWith({ 'x-forwarded-for': '203.0.113.9' }), {
+ trustedProxies: ['0.0.0.0/0'],
+ })
+ ).toBeNull()
+ })
+
+ it('ignores malformed trusted-proxy entries instead of trusting the chain', () => {
+ // A typo must not silently disable the chain walk and hand back a real
+ // proxy hop as though it were the client.
+ expect(
+ resolveClientIp(requestWith({ 'x-forwarded-for': '203.0.113.7, 10.0.0.5' }), {
+ trustedProxies: ['not-a-cidr'],
+ })
+ ).toBeNull()
+ })
+ })
+
+ describe('header precedence', () => {
+ it('prefers x-forwarded-for over x-real-ip', () => {
+ expect(
+ resolveClientIp(
+ requestWith({ 'x-forwarded-for': '203.0.113.7', 'x-real-ip': '198.51.100.1' })
+ )
+ ).toBe('203.0.113.7')
+ })
+
+ it('falls back to x-real-ip when x-forwarded-for yields nothing', () => {
+ expect(
+ resolveClientIp(requestWith({ 'x-forwarded-for': 'garbage', 'x-real-ip': '198.51.100.1' }))
+ ).toBe('198.51.100.1')
+ })
+
+ it('honors an explicit header list', () => {
+ expect(
+ resolveClientIp(requestWith({ 'x-real-ip': '198.51.100.1' }), {
+ headers: ['x-forwarded-for'],
+ })
+ ).toBeNull()
+ })
+ })
+
+ describe('IPv6', () => {
+ it('unwraps an IPv4-mapped address to its IPv4 form', () => {
+ expect(resolveClientIp(requestWith({ 'x-forwarded-for': '::ffff:203.0.113.7' }))).toBe(
+ '203.0.113.7'
+ )
+ })
+
+ it('preserves the full address rather than masking it to a /64', () => {
+ // Better Auth masks IPv6 to a /64 by default, which would collapse every
+ // address in a subnet to one value. Identification needs the whole thing,
+ // returned in the fully-expanded canonical form.
+ expect(resolveClientIp(requestWith({ 'x-forwarded-for': '2001:db8::dead:beef' }))).toBe(
+ '2001:0db8:0000:0000:0000:0000:dead:beef'
+ )
+ })
+
+ it('canonicalizes two spellings of one address to the same value', () => {
+ // The property downstream equality checks depend on: a caller must never
+ // see the same client as two different addresses because the proxy
+ // compressed the zero groups differently.
+ const compressed = resolveClientIp(requestWith({ 'x-forwarded-for': '2001:db8::1' }))
+ const expanded = resolveClientIp(
+ requestWith({ 'x-forwarded-for': '2001:0db8:0000:0000:0000:0000:0000:0001' })
+ )
+ expect(compressed).toBe(expanded)
+ })
+ })
+})
+
+describe('parseTrustedProxies', () => {
+ it('splits, trims, and drops empty entries', () => {
+ expect(parseTrustedProxies(' 10.0.0.0/8 , ,192.168.0.0/16 ')).toEqual([
+ '10.0.0.0/8',
+ '192.168.0.0/16',
+ ])
+ })
+
+ it('returns an empty list for an absent value', () => {
+ expect(parseTrustedProxies(undefined)).toEqual([])
+ expect(parseTrustedProxies('')).toEqual([])
+ })
+})
+
+describe('findMalformedTrustedProxies', () => {
+ it('reports only the entries that are not an IP or CIDR', () => {
+ expect(
+ findMalformedTrustedProxies(['10.0.0.0/8', 'nope', '203.0.113.7', '10.0.0.0/8x'])
+ ).toEqual(['nope', '10.0.0.0/8x'])
+ })
+})
+
+describe('isAllTrustingProxyEntry', () => {
+ it('flags the ranges that trust every hop', () => {
+ expect(isAllTrustingProxyEntry('0.0.0.0/0')).toBe(true)
+ expect(isAllTrustingProxyEntry(' ::/0 ')).toBe(true)
+ })
+
+ it('leaves a bounded range alone', () => {
+ expect(isAllTrustingProxyEntry('10.0.0.0/8')).toBe(false)
+ })
+})
diff --git a/packages/security/src/client-ip.ts b/packages/security/src/client-ip.ts
new file mode 100644
index 00000000000..52dcfbed035
--- /dev/null
+++ b/packages/security/src/client-ip.ts
@@ -0,0 +1,102 @@
+import { findInvalidTrustedProxies, getIPFromHeader } from '@better-auth/core/utils/ip'
+
+/**
+ * Headers consulted for the client IP, in precedence order. `x-forwarded-for`
+ * carries the proxy chain and is the only one verifiable against a trusted-proxy
+ * set; `x-real-ip` is a single-value fallback for ingresses that set it instead.
+ *
+ * Also passed to Better Auth as `advanced.ipAddress.ipAddressHeaders`, whose own
+ * default is `x-forwarded-for` alone — without it an `x-real-ip`-only deployment
+ * would resolve an IP here and record none on the session.
+ */
+export const CLIENT_IP_HEADERS = ['x-forwarded-for', 'x-real-ip'] as const
+
+/**
+ * Better Auth masks IPv6 to a /64 by default, which groups a subnet into one
+ * rate-limit bucket but cannot identify a caller. Everything here identifies, so
+ * it stays exact — at the cost that results come back fully expanded
+ * (`2001:0db8:0000:...`), so anything comparing against operator-entered text
+ * must canonicalize that side too.
+ */
+const EXACT_IPV6_SUBNET = 128
+
+/** Anything exposing header lookup by name — `Request.headers`, `Headers`, or a stub. */
+export interface ClientIpHeaders {
+ get(name: string): string | null
+}
+
+export interface ResolveClientIpOptions {
+ /**
+ * Reverse-proxy hops trusted to have appended the forwarded chain, as IPs or
+ * CIDR ranges. Empty declares no proxy, so only a single-value header is
+ * trusted and any multi-hop chain resolves to `null`.
+ */
+ trustedProxies?: string[]
+ /** Overrides {@link CLIENT_IP_HEADERS}. */
+ headers?: readonly string[]
+}
+
+/**
+ * Resolves the client IP of a request, or `null` when no trustworthy address
+ * can be established.
+ *
+ * The leftmost `x-forwarded-for` token is written by the client, so it is never
+ * read directly. With `trustedProxies` set the chain is walked right to left,
+ * known hops are skipped, and the first untrusted address is the client; a
+ * malformed hop fails closed.
+ *
+ * Delegates to Better Auth so this agrees by construction with the `ipAddress`
+ * on every session row — a second parser would be a second definition of "the
+ * client IP", and the two would drift.
+ *
+ * Callers must treat `null` as unknown, never as a match: a shared rate-limit
+ * bucket, or a denial for anything IP-gated.
+ */
+export function resolveClientIp(
+ request: { headers: ClientIpHeaders },
+ options: ResolveClientIpOptions = {}
+): string | null {
+ const { trustedProxies = [], headers = CLIENT_IP_HEADERS } = options
+
+ for (const header of headers) {
+ const value = request.headers.get(header)
+ if (!value) continue
+ const ip = getIPFromHeader(value, { trustedProxies, ipv6Subnet: EXACT_IPV6_SUBNET })
+ if (ip) return ip
+ }
+
+ return null
+}
+
+/**
+ * Bucket key segment standing in for a client IP that could not be trusted.
+ * Never a valid IP, so no client can steer itself into or out of it.
+ */
+export const UNRESOLVED_CLIENT_IP_BUCKET = 'unresolved'
+
+/** Splits an `AUTH_TRUSTED_PROXIES` value into entries. */
+export function parseTrustedProxies(value: string | null | undefined): string[] {
+ return (value ?? '')
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+}
+
+/**
+ * Trusted-proxy entries that are not a valid IP or CIDR range. Malformed entries
+ * are dropped during resolution rather than throwing, so reporting them is the
+ * only way an operator learns a typo left the chain unverified.
+ */
+export function findMalformedTrustedProxies(entries: string[]): string[] {
+ return findInvalidTrustedProxies(entries)
+}
+
+/**
+ * Whether an entry trusts every possible hop, which makes the walk skip every
+ * address and run off the end so no request resolves an IP. It fails closed, but
+ * silently — the symptom is indistinguishable from having no clients.
+ */
+export function isAllTrustingProxyEntry(entry: string): boolean {
+ const normalized = entry.trim()
+ return normalized === '0.0.0.0/0' || normalized === '::/0'
+}
diff --git a/packages/testing/package.json b/packages/testing/package.json
index 826d9fb6920..43e3619ad54 100644
--- a/packages/testing/package.json
+++ b/packages/testing/package.json
@@ -57,6 +57,7 @@
"vitest": "^3.0.0 || >=4.1.0 <5.0.0"
},
"devDependencies": {
+ "@sim/security": "workspace:*",
"@sim/tsconfig": "workspace:*",
"typescript": "^7.0.2",
"vitest": "^4.1.0"
diff --git a/packages/testing/src/mocks/request.mock.ts b/packages/testing/src/mocks/request.mock.ts
index 614366ad938..6f1a8e8dfe2 100644
--- a/packages/testing/src/mocks/request.mock.ts
+++ b/packages/testing/src/mocks/request.mock.ts
@@ -1,6 +1,11 @@
/**
* Mock request utilities for API testing
*/
+import {
+ type ClientIpHeaders,
+ resolveClientIp,
+ UNRESOLVED_CLIENT_IP_BUCKET,
+} from '@sim/security/client-ip'
import { NextRequest } from 'next/server'
import { vi } from 'vitest'
@@ -69,17 +74,27 @@ export function createMockFormDataRequest(
/**
* Controllable mock functions for `@/lib/core/utils/request`.
*
+ * `generateRequestId` is stubbed for determinism. The IP helpers deliberately
+ * run the REAL resolver (with no trusted proxies, as a test environment
+ * declares none) so route tests see production's IP semantics — a stubbed
+ * passthrough here would be a second definition of "the client IP". Override
+ * per test when a specific address is needed.
+ *
* @example
* ```ts
* import { requestUtilsMockFns } from '@sim/testing'
*
* requestUtilsMockFns.mockGenerateRequestId.mockReturnValueOnce('test-req-42')
- * requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('10.0.0.5')
+ * requestUtilsMockFns.mockResolveClientIp.mockReturnValueOnce('10.0.0.5')
* ```
*/
export const requestUtilsMockFns = {
mockGenerateRequestId: vi.fn(() => 'mock-request-id'),
- mockGetClientIp: vi.fn(() => '127.0.0.1'),
+ mockResolveClientIp: vi.fn((request: { headers: ClientIpHeaders }) => resolveClientIp(request)),
+ mockGetRateLimitIpKey: vi.fn(
+ (request: { headers: ClientIpHeaders }) =>
+ resolveClientIp(request) ?? UNRESOLVED_CLIENT_IP_BUCKET
+ ),
}
/**
@@ -92,6 +107,7 @@ export const requestUtilsMockFns = {
*/
export const requestUtilsMock = {
generateRequestId: requestUtilsMockFns.mockGenerateRequestId,
- getClientIp: requestUtilsMockFns.mockGetClientIp,
+ resolveClientIp: requestUtilsMockFns.mockResolveClientIp,
+ getRateLimitIpKey: requestUtilsMockFns.mockGetRateLimitIpKey,
noop: () => {},
}