Skip to content

fix(security): stop trusting forged client IPs from the whole private range - #127

Open
fjaeckel wants to merge 1 commit into
mainfrom
security/trusted-proxy-spoofing
Open

fix(security): stop trusting forged client IPs from the whole private range#127
fjaeckel wants to merge 1 commit into
mainfrom
security/trusted-proxy-spoofing

Conversation

@fjaeckel

Copy link
Copy Markdown
Owner

Summary

Fixes a Medium severity rate-limit bypass and access-log forgery.

c.ClientIP() is the key for every IP-based rate limit and the client_ip written to the access log. When the connecting peer is a trusted proxy, Gin takes that value from a client-supplied X-Real-IP / X-Forwarded-For header.

The trusted set was all of RFC-1918 plus loopback:

SetTrustedProxies([]string{"127.0.0.1","::1","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"})
RemoteIPHeaders = []string{"X-Real-IP","X-Forwarded-For"}

…and docker-compose.yml published the API port. So traffic reaching the API directly arrived from the Docker bridge — inside 172.16.0.0/12 — and the header was honoured verbatim.

Verified against a running instance

With rate limiting enabled:

Test Result
Baseline 20× POST /auth/login, no header 10×401 then 10×429 (limit works)
40× login with rotating X-Real-IP, from the already-throttled source 0×429
Same via X-Forwarded-For 0×429
30× POST /auth/register with rotating IPs 30×201 — unlimited account creation
Request with X-Real-IP: 203.0.113.77 logged as "client_ip":"203.0.113.77"log forgery

Scope, honestly

The per-account lockout still held — 5 wrong logins lock an account even across rotating spoofed IPs, so single-account password guessing was never helped by this. What the IP limits alone guarded was bypassable: password spraying, password-reset/verification email flooding, registration flooding, and mass account-lockout DoS against arbitrary victims.

Behind an nginx-only ingress the app was already safe, since nginx's proxy_set_header X-Real-IP $remote_addr overwrites any client value.

Changes

  • Trusted proxies are configurable via TRUSTED_PROXIES, defaulting to loopback only. A startup warning names the setting when unset.
  • docker-compose binds the API port to 127.0.0.1 (override with API_BIND), so the API is reached through the nginx ingress rather than directly. This also stops /metrics being reachable off-host.
  • TRUSTED_PROXIES documented in docker-compose.yml and .env.example; compose defaults it to 172.16.0.0/12 so the bundled nginx keeps working out of the box.

⚠️ Deployment note

Deployments behind a reverse proxy must set TRUSTED_PROXIES to the ingress address. If it's unset, forwarded client IPs are ignored and all proxied traffic shares a single rate-limit bucket (fails closed, but coarsely). Narrow it to the proxy's actual address where you can pin it.

Tests

splitAndTrim parsing, and an assertion that the default trusted set contains no broad private range — so the insecure default can't come back silently.

Full suite: 24 packages passing, gofmt clean.

🤖 Generated with Claude Code


Generated by Claude Code

… range

c.ClientIP() keys every IP-based rate limit and the client_ip recorded in the
access log. When the connecting peer is a trusted proxy, Gin takes that value
from a client-supplied X-Real-IP / X-Forwarded-For header. The trusted set was
all of RFC-1918 plus loopback, and the compose file published the API port --
so traffic reaching the API directly arrived from the Docker bridge, inside
172.16.0.0/12, and the header was honoured verbatim.

Verified against a running instance with rate limiting enabled: after the real
source was throttled (10 x 429), 40 logins with a rotating X-Real-IP returned
0 x 429, and 30 registrations with rotating IPs all returned 201. A request
carrying 'X-Real-IP: 203.0.113.77' was logged with that value as client_ip, so
an attacker can also write arbitrary source addresses into the access log.

The per-account lockout still capped single-account password guessing and is
unaffected; what the IP limits alone guarded -- password spraying, reset and
verification email flooding, registration flooding, mass account-lockout DoS --
was bypassable.

Changes:
  - Trusted proxies are configurable via TRUSTED_PROXIES and now default to
    loopback only. A startup warning names the setting when it is unset.
  - docker-compose binds the API port to 127.0.0.1 (override with API_BIND),
    so the API is reached through the nginx ingress rather than directly. This
    also stops /metrics being reachable off-host.
  - TRUSTED_PROXIES documented in docker-compose.yml and .env.example.

Deployments behind a reverse proxy MUST set TRUSTED_PROXIES to the ingress
address, otherwise forwarded client IPs are ignored and all proxied traffic
shares one rate-limit bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukWfyJMY28qv2CJjxFvKF
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.

2 participants