fix(ratelimit): key rate limiters on real client IP behind trusted proxy (bookshelf-t582g.9) #1247
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-t582g.9"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
r.RemoteAddr, which is the reverse proxy's IP whenTrustProxyHeaders=true. This collapsed every real user into one bucket — one attacker could exhaust the burst and lock out all users.audit.ExtractIPWithPolicy(already had the correct XFF/X-Real-IP extraction logic) and wired it intoclientIP(r, trustProxyHeaders)in the users package.TrustProxyHeaders booltousers.Deps(mirroringOIDCDepsandDeviceDeps) and threaded it through all four call sites.TrustProxyHeaders=false(default), behavior is identical to before — onlyRemoteAddris used, preventing header-forgery bypasses.Test plan
clientIPspecs:trustProxy=falseignores XFF/X-Real-IP (preserves existing behavior)clientIPspecs:trustProxy=trueuses first XFF hop, prefers X-Real-IP over XFF, falls back to RemoteAddr when no proxy headers presentaudit.ExtractIPWithPolicycovered by two new specs in the audit packagecheck-coverage: OK — zero uncovered statement blocksmake testgreen;make lintclean on affected packagesCloses bead bookshelf-t582g.9 on merge.
Security Review — PR #1247 (rate limiters key on trusted-proxy client IP)
Adversarial focus: trust model of the rate-limit key. Reviewed
git diff origin/main...origin/bd-bookshelf-t582g.9plusextractIPWithPolicyininternal/audit/recorder.goand the config/wiring flow.Trust-model verdict per the four concerns:
TrustProxyHeaders=false) — SAFE.clientIP→audit.ExtractIPWithPolicyskips the whole header block and keys onr.RemoteAddronly. ForgedX-Forwarded-For/X-Real-IPare ignored (covered by new tests). Flag defaults false (config.go:548).audit.ExtractIPWithPolicy, driven by the same singlecfg.TrustProxyHeadersflag that feeds the auditMiddleware(app.go:938) and the usersDeps(app.go:398). One extraction function, one config source. This is exactly the consistency the PR aimed for.[MAJOR] internal/audit/recorder.go:148 — trusted mode keys on the leftmost (client-supplied) X-Forwarded-For hop
In trusted mode, when no
X-Real-IPis present, extraction takesxff[:idx]— the leftmost XFF value. A reverse proxy that appends the real client IP (RFC-style:X-Forwarded-For: <client-sent>, <real-ip>) leaves the leftmost value fully attacker-controlled. An attacker can then (a) send a fresh forged leftmost value per request to mint unlimited rate-limit buckets and bypass throttling on/login,/auth/refresh, device-grant, and OIDC backchannel; or (b) pin a victim's IP as the leftmost value to burn that victim's bucket and DoS-lock them out. The safe hop when a proxy appends is the rightmost entry (or a proxy-hop count), not the leftmost. This is safe only if the operator configures the proxy to setX-Real-IPor to replace XFF with the true client IP — a requirement that is neither enforced nor documented.Suggested fix: for XFF, take the rightmost hop (or add a trusted-proxy-hop-count config and index from the right); and/or document in the
trust-proxy-headersflag help + ops docs that the proxy MUST setX-Real-IPor replace XFF (proxy_set_header X-Forwarded-For $remote_addr). Note this leftmost behavior is pre-existing in the audit middleware (bookshelf-kxyu) and the PR intentionally reuses it for consistency; the fix should land inextractIPWithPolicyso both audit and rate-limiting are corrected together. A follow-up bead on the shared helper is acceptable if the proxy-config requirement is documented in this PR.[MINOR] internal/users/ratelimiter.go:171 — stale doc comment above clientIP still says headers are never trusted
The block comment at 171-178 is the new accurate one, but line 173's parenthetical ("never trust forwarded headers from untrusted clients") reads as a contradiction now that trusted mode consults them. Tighten to "never trust forwarded headers when TrustProxyHeaders is false" to match the actual policy.
REVIEW VERDICT: 0 blocker, 1 major, 1 minor
db7fd27b320c0de796a7Security re-review — PR #1247 (
bd-bookshelf-t582g.9)Re-review of the XFF-hop MAJOR fix (leftmost -> rightmost X-Forwarded-For hop). The MAJOR is CLOSED. Verification against the four criteria:
(1) Trusted mode now keys on the rightmost (proxy-appended) hop — CONFIRMED.
internal/audit/recorder.go:167switchedstrings.Index(xff, ",")(leftmost) tostrings.LastIndex(xff, ",")returningxff[idx+1:](rightmost). X-Real-IP is still preferred when present. A client-supplied leftmost value no longer influences the key, and a spoof-resistance test proves it:internal/users/ratelimiter_test.go— "a spoofed leftmost XFF value does not change the rate-limit key" asserts"attacker-spoof, 10.0.0.2"and"different-spoof, 10.0.0.2"produce the same key, plus "gives different real client IPs separate rate-limit buckets" proves distinct rightmost hops still separate. Audit-side mirror test updated to10.0.0.1(rightmost) inrecorder_test.go.(2) Untrusted mode still ignores XFF entirely — CONFIRMED.
extractIPWithPolicyonly consults headers insideif trustProxyHeaders; otherwise it falls straight tonet.SplitHostPort(r.RemoteAddr). Covered by "ignores X-Forwarded-For when trust is disabled" and "ignores X-Real-IP when trust is disabled" (ratelimiter_test.go).(3) Shared-helper change did not break audit trust semantics — CONFIRMED.
The audit
Middlewarecall site (recorder.go:96) is unchanged and still passes its owntrustProxyHeadersparam, sourced from the samecfg.TrustProxyHeaders(app.go:938) that now feeds the rate limiter (wire.go:99/158/249). Audit and rate-limit trust are unified. Audit tests updated consistently (Describe renamed to "rightmost", assertion flipped to the appended hop). No leftover single-argclientIP(r)/ExportClientIP(r)callers remain.(4) Single-trusted-hop assumption documented + multi-proxy limitation noted — CONFIRMED.
extractIPWithPolicydoc block (recorder.go:147-160) states the single-trusted-proxy rationale and calls out the multi-hop (CDN + reverse proxy) limitation as separately tracked. Config flag help (config.go:548) scopes it to "ONLY behind a trusted reverse proxy". Follow-up-acceptable per the task.IPv6 note:
LastIndex(",")is safe — XFF entries are comma-separated addresses with no intra-address comma, so the rightmost hop is extracted correctly.Findings
[MINOR] internal/audit/recorder.go:64 — stale doc comment on
MiddlewareThe
Middlewaredoc's "IP extraction policy" still reads "trustProxyHeaders=true: X-Real-IP preferred, then first X-Forwarded-For hop." The behavior is now the rightmost (proxy-appended) hop. Update "first" -> "rightmost (proxy-appended)" to match the implementation and theextractIPWithPolicydoc. Doc-only, no correctness impact.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
zombor referenced this pull request2026-07-27 02:19:35 +00:00
0c0de796a7544ee2d523