Skip to content

fix(audit): resolve the client IP from the trusted proxy chain, not the leftmost header - #6466

Open
waleedlatif1 wants to merge 1 commit into
stagingfrom
fix/trusted-client-ip-resolution
Open

fix(audit): resolve the client IP from the trusted proxy chain, not the leftmost header#6466
waleedlatif1 wants to merge 1 commit into
stagingfrom
fix/trusted-client-ip-resolution

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • getClientIp read the leftmost x-forwarded-for token, which is written by the client. Every IP-derived decision was therefore steerable by sending your own header: per-IP rate limits could be reset on every request, audit rows recorded an attacker-chosen address, and per-webhook IP allowlists could be satisfied outright.
  • Added @sim/security/client-ip, which walks the chain right to left against AUTH_TRUSTED_PROXIES and returns null when no address can be trusted. It delegates to Better Auth's own resolver (@better-auth/core/utils/ip) rather than reimplementing the parse, so it agrees by construction with the ipAddress recorded on every session row.
  • Deleted both copies of the old helper (apps/sim/lib/core/utils/request.ts, packages/audit/src/log.ts) and migrated all 14 call sites. Renamed to resolveClientIp so every site had to be revisited — the return type went stringstring | null, and a template-literal rate-limit key would have silently stringified null.
  • Callers split on that null deliberately: rate limits share one strict bucket via getRateLimitIpKey, the webhook allowlist denies, audit records blank.
  • Passed ipAddressHeaders to Better Auth from the same shared constant. Its default is x-forwarded-for alone, so an x-real-ip-only ingress previously resolved an IP for our code but recorded none on the session.
  • Log an error at startup when AUTH_TRUSTED_PROXIES contains a malformed entry or an all-trusting range (0.0.0.0/0) — both silently leave every request resolving no IP.
  • Docs, Helm values, compose, and .env.example updated: the variable now governs sessions, per-IP rate limits, audit log IPs, and webhook IP allowlists.

Behavior change worth reviewing

Behind a proxy with AUTH_TRUSTED_PROXIES unset, no client IP resolves. Nothing breaks outright — rate limits fall back to one shared bucket per endpoint (stricter, not weaker), audit rows blank the IP, and any webhook with an IP allowlist rejects deliveries. Sim will not fall back to the client-supplied value. Prod sets the variable; this mainly affects self-hosters, and the docs now call it out.

No new dependencies (@better-auth/core is already transitive via better-auth), no migration, no feature flag.

Type of Change

  • Bug fix

Testing

  • 21 unit tests on the resolver covering the spoof case (prepended hop loses to the real one), fail-closed on a malformed hop, unverifiable multi-hop chains, all-trusting and malformed proxy sets, header precedence, and IPv6 canonicalization.
  • Verified the tests can fail: reverting the resolver to the old leftmost-XFF logic turns 11 of them red, including the spoof case.
  • Full apps/sim suite green (21,046 passed), plus @sim/security, @sim/audit, @sim/realtime. All 23 workspaces type-check.
  • check:api-validation:strict, check:boundaries, check:realtime-prune, check:import-specifiers, check:native-typecheck all pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…t 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.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner August 9, 2026 01:12
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 9, 2026 1:16am

Request Review

@cursor

cursor Bot commented Aug 9, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes authentication-adjacent client IP resolution and every IP-gated control (rate limits, audit, webhooks, sessions); misconfigured AUTH_TRUSTED_PROXIES can blank IPs or break webhook allowlists behind proxies.

Overview
Fixes a security bug where client IP was taken from the leftmost X-Forwarded-For token, which attackers can set. That steered per-IP rate limits, audit ipAddress, session IPs, and webhook IP allowlists.

New @sim/security/client-ip walks X-Forwarded-For right-to-left against AUTH_TRUSTED_PROXIES (via Better Auth’s getIPFromHeader) and returns null when the chain can’t be trusted. resolveClientIp is used for sessions, audit, webhooks, and logging; getRateLimitIpKey maps null to a shared unresolved bucket so limits stay strict instead of amplifiable.

Call sites across public APIs, rate-limit helpers, deployment auth, generic webhooks, audit logging, and proxy.ts migrate off deleted getClientIp. Better Auth now shares CLIENT_IP_HEADERS (x-forwarded-for, x-real-ip) and the same parsed proxy list; startup errors log for malformed or all-trusting (0.0.0.0/0) AUTH_TRUSTED_PROXIES.

Self-hosting docs, Helm, Compose, and .env.example describe unified behavior: unset proxies behind a load balancer means no resolved IP (shared rate buckets, blank audit IPs, webhook allowlists deny).

Reviewed by Cursor Bugbot for commit d6d4180. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR centralizes client-IP resolution around Better Auth’s trusted-proxy-chain logic and migrates rate limiting, audit logging, session recording, analytics, and webhook allowlists to the shared result.

  • Adds a shared client-IP resolver and trusted-proxy configuration diagnostics.
  • Uses a common unresolved-IP bucket for rate limits and fail-closed behavior for webhook allowlists.
  • Aligns Better Auth headers and proxy settings with application-level resolution.
  • Updates deployment configuration, documentation, tests, and workspace dependencies.

Confidence Score: 4/5

The IPv6 allowlist comparison should be corrected before merging because valid compressed addresses are rejected after resolver canonicalization.

The shared resolver substantially improves proxy-chain handling, but the webhook authorization path compares its expanded IPv6 output against raw configured strings, causing legitimate allowlisted webhook requests to receive 403 responses.

Files Needing Attention: apps/sim/lib/webhooks/providers/generic.ts, packages/security/src/client-ip.test.ts

Important Files Changed

Filename Overview
packages/security/src/client-ip.ts Introduces shared trusted-proxy-chain resolution, header precedence, IPv6 canonicalization, and configuration helpers; canonical output requires downstream allowlist normalization.
apps/sim/lib/webhooks/providers/generic.ts Makes webhook IP checks fail closed, but directly compares canonicalized IPv6 output against unnormalized stored entries.
apps/sim/lib/core/utils/request.ts Wraps shared resolution with application proxy configuration and provides a stable unresolved rate-limit bucket.
apps/sim/lib/auth/auth.ts Aligns Better Auth’s IP headers and trusted proxies with the shared resolver and adds startup diagnostics.
packages/audit/src/log.ts Replaces the local leftmost-header parser with shared fail-closed resolution and records no IP when unresolved.
packages/security/src/client-ip.test.ts Adds broad resolver coverage but violates the repository’s absolute-import convention.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Request[Incoming request] --> Headers[X-Forwarded-For / X-Real-IP]
  Headers --> Resolver[Shared client-IP resolver]
  Proxies[AUTH_TRUSTED_PROXIES] --> Resolver
  Resolver -->|Resolved IP| Sessions[Session IP]
  Resolver -->|Resolved IP| Audit[Audit IP]
  Resolver -->|Resolved IP| Allowlist[Webhook allowlist]
  Resolver -->|Resolved IP| RateLimit[Per-IP rate-limit bucket]
  Resolver -->|null| SharedBucket[Shared unresolved bucket]
  Resolver -->|null| BlankAudit[Blank audit/session IP]
  Resolver -->|null| DenyWebhook[Deny allowlisted webhook]
Loading

Reviews (1): Last reviewed commit: "fix(security): resolve the client IP fro..." | Re-trigger Greptile

const clientIp = resolveClientIp(request)

if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) {
if (clientIp === null || !allowedIps.includes(clientIp)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Canonical IPv6 allowlists reject matches

When an allowlist contains a compressed IPv6 address such as 2001:db8::1, resolveClientIp returns its fully expanded form and this direct includes comparison treats the equivalent addresses as different, causing the legitimate webhook request to receive a 403.

Comment on lines +2 to +7
import {
findMalformedTrustedProxies,
isAllTrustingProxyEntry,
parseTrustedProxies,
resolveClientIp,
} from './client-ip'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Relative import violates repository convention

The new test imports ./client-ip despite the repository-wide requirement to use absolute imports, introducing an inconsistent pattern that makes future module relocation more error-prone.

Suggested change
import {
findMalformedTrustedProxies,
isAllTrustingProxyEntry,
parseTrustedProxies,
resolveClientIp,
} from './client-ip'
import {
findMalformedTrustedProxies,
isAllTrustingProxyEntry,
parseTrustedProxies,
resolveClientIp,
} from '@sim/security/client-ip'

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d6d4180. Configure here.


try {
const ip = getClientIp(req)
const ip = getRateLimitIpKey(req)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turnstile gets unresolved remote IP

Medium Severity

getRateLimitIpKey can return the sentinel unresolved, and that value is passed straight into Turnstile as remoteIp. The Turnstile helper only skips the old unknown sentinel, so Cloudflare may receive remoteip=unresolved and reject otherwise-valid captchas whenever no trustworthy client IP resolves.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d6d4180. Configure here.

const clientIp = resolveClientIp(request)

if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) {
if (clientIp === null || !allowedIps.includes(clientIp)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IPv6 allowlist match fails

Medium Severity

Webhook IP allowlisting still uses exact includes against the resolved address, but resolveClientIp now returns fully expanded IPv6. Operator-entered compressed forms no longer match, so legitimate IPv6 webhook sources can be rejected with 403.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d6d4180. Configure here.

Comment thread apps/sim/lib/auth/auth.ts
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],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session IPv6 diverges from resolver

Medium Severity

resolveClientIp forces ipv6Subnet: 128, but Better Auth’s advanced.ipAddress is configured without ipv6Subnet, so it keeps the library default of /64. Session-recorded IPv6 addresses therefore will not match the IP every other caller resolves, despite the shared-header/proxy setup.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d6d4180. Configure here.

mockGetRateLimitIpKey: vi.fn(
(request: { headers: ClientIpHeaders }) =>
resolveClientIp(request) ?? UNRESOLVED_CLIENT_IP_BUCKET
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate-limit mock wired incorrectly

Low Severity

mockGetRateLimitIpKey calls the real resolveClientIp instead of mockResolveClientIp, while migrated route tests still stub only mockResolveClientIp. Routes that now key limits with getRateLimitIpKey ignore those stubs, so IP-related assertions can pass for the wrong reason.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d6d4180. Configure here.

@waleedlatif1 waleedlatif1 changed the title fix(security): resolve the client IP from the trusted proxy chain, not the leftmost header fix(audit): resolve the client IP from the trusted proxy chain, not the leftmost header Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant