security(parser): X-Forwarded-For resolves to the rightmost untrusted hop (#612) - #628
Conversation
…rightmost untrusted hop Refs #612 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KCu1p3UrUGSzWVwhNuJ47k
… hop (#612) Proxies and CDNs APPEND the address they accepted the connection from, so a client-supplied header arrives as "<forged>, <real client>". The three XFF-aware parsers (nginx JSON/custom, Caddy, Traefik) took the LEFTMOST non-trusted hop, letting a client behind a trusted proxy attribute its traffic, strikes and ban to any address it named. One shared helper now walks the header from the right past trusted proxies. Closes #612 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KCu1p3UrUGSzWVwhNuJ47k
|
Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here. So far, Strix has reviewed 49 pull requests, surfaced 8 security issues (1 critical/high) and blocked 3 risky merges across this workspace. |
There was a problem hiding this comment.
This PR successfully closes a critical security vulnerability in X-Forwarded-For header processing. The implementation correctly switches from leftmost to rightmost untrusted hop resolution, preventing attackers from framing arbitrary IP addresses while staying undetected.
The changes are well-structured with proper separation of concerns through the new clientFromXFF helper function, comprehensive test coverage including edge cases (forged headers, multi-hop chains, all-trusted suffixes), and clear documentation of the security rationale. The implementation maintains backward compatibility for scenarios without trusted proxies configured and properly handles parsing errors by skipping unparseable tokens.
All three parsers (nginx, Caddy, Traefik) are updated consistently, and the PR includes appropriate documentation updates. No defects identified that would block merge.
You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.
… parse ports, brackets and mapped forms; join repeated Caddy headers (#612 review) Adversarial review of #628: proxies that append "unknown" (Squid forwarded_for off), an obfuscated identifier (RFC 7239) or ip:port (IIS ARR, Azure Application Gateway) made the resolver skip the proxy's token and walk into client-controlled text — the framing vector again. The walk now parses ip:port / [v6]:port, unmaps IPv4-mapped forms before the trusted check and the result, and stops with "no client" (fall back to remote_addr) at the first token it cannot read. Caddy logs a repeated header as a JSON array; the values are one list in order, joined before resolving. Fuzz targets construct the parsers with trusted proxies and seed the resolver path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KCu1p3UrUGSzWVwhNuJ47k
What & why
Closes #612 (Phase 2 of #605, invariant C4).
When
remote_addris a trusted proxy, the nginx JSON/custom, Caddy and Traefik parsers took the leftmost non-trusted hop ofX-Forwarded-For. Proxies and CDNs append the connecting address to whatever the client sent, soX-Forwarded-For: 198.51.100.7from an attacker arrives as"198.51.100.7, <attacker>"and the attacker's hits, strikes and ban went to198.51.100.7— framing any address, including the operator's, while staying clean. The client is the rightmost hop that is not a trusted proxy: the proxies' appended suffix is the only part of the header the client did not write.Acceptance criteria restated:
remote_addr=<trusted>,xff="203.0.113.9, 198.51.100.7"→198.51.100.7in all three parsers (TestXFF_RightmostUntrustedHop, table of 5 shapes × 3 parsers; fails ondevfor the two forged shapes, commit 1).Found while fixing: production constructs the three parsers with an empty trusted-proxy list — there is no config key — so header resolution is unreachable today. Filed as #626 (with the CDN-edge attribution consequence, INVARIANTS E4-2).
Changes
internal/parser/xff.go(new):clientFromXFF(xff, trusted)— rightmost untrusted hop. Tokens may carry a port (ip:port,[v6]:port, as IIS ARR / Azure Application Gateway write); IPv4-mapped forms are unmapped before the trusted check and the result. The walk stops at the first token it cannot read (unknownfrom Squid, an RFC 7239 obfuscated identifier, garbage) and falls back toremote_addr— walking further left would land in client-controlled text, the framing vector again (adversarial review finding; the earlier "skip unparseable" rule was wrong).internal/parser/caddy.go: a repeatedX-Forwarded-Forheader (HAProxy's default adds its own line) is logged by Caddy as a JSON array; the values are joined in order before resolving, so the proxy-added value stays the rightmost suffix.internal/parser/nginx.go,caddy.go,traefik.go: use it; comments corrected.internal/parser/xff_rightmost_test.go(new): 13 header shapes × 3 parsers (forged/real, ports, brackets, mapped client, mapped trusted proxy,unknown,_hidden, garbage, only-trusted, empty) plus the Caddy repeated-header case; two existing subtests renamed.FuzzNginxParser/FuzzCaddyParser/FuzzTraefikParsernow construct the parsers with trusted proxies and seed lines that reach the resolver — before, the resolver was never executed under fuzz (adversarial review finding).docs/content/en/guides/docker-nginx-wordpress.md,docs/internal/INVARIANTS.md(C4).Tests
make lint testgreen locally (-race):go test -race ./...ok;gofmt -l .empty;go vet ./...ok;golangci-lint run ./...(v2 image) 0 issues; ip-hygiene and docs-placeholder gates clean.Blast radius (per docs/internal/INVARIANTS.md)
SourceIPdownstream (aggregator, counters, decision, allowlist) are unchanged; only which header hop becomesSourceIPwhen a trusted proxy is configured.remote_addr not trusted → header ignoredandno trusted proxies → header ignoredbranches unchanged (existing tests); combined/vhost nginx formats never consult the header (unchanged,nginx_test); Nextcloud/Keycloak/Vaultwarden parsers use their own remote-address fields (unchanged).internal/parser.TestXFF_RightmostUntrustedHop(parser level; an end-to-end scenario needs feat(config): trusted_proxies has no config surface — X-Forwarded-For resolution is unreachable in production, attacks behind a CDN are attributed to the edge #626's config surface).Security review (per docs/internal/SECURITY-REVIEW.md)
ParseAddrPortthenparseIP, both bounded; the 4096-byte line cap applies first); the walk stops at the first unreadable token instead of skipping it, so hostile content can only push the result towardsremote_addr, never towards a client-chosen address. Mapped spellings are unmapped before the trusted check, so::ffff:<trusted>cannot be returned as the client.redactForLog). §10 self-review walked (no dead branches;ok=falsefallback toremote_addrpreserved).Self-assessment: closes an evasion + framing vector; no new lock-out, injection, privilege or secret exposure.
Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01KCu1p3UrUGSzWVwhNuJ47k