clientIP() (internal/api/ratelimit.go) keyed rate-limit buckets on the RIGHTMOST X-Forwarded-For entry, assuming traefik appends the real client IP. The deployed ingress does not rewrite XFF, so a client rotating its own XFF header got a fresh bucket per request — pentest evidence: 8 creates with rotating XFF -> 6x201, limits never tripped.
Fix
Bucket keys now use the actual peer address (RemoteAddr) by default. All client-supplied IP headers (X-Forwarded-For, X-Real-Ip) are ignored — no header a client controls can influence keying.
Per-client limits behind a trusted ingress are restored via an explicit opt-in: PALETTE_TRUSTED_IP_HEADER (e.g. CF-Connecting-IP when Cloudflare fronts traefik, as on prod p.archfox.org — Cloudflare strips any client-supplied value of that header, so it is attacker-proof when set). The header is honored ONLY when explicitly configured at startup; rightmost-XFF-style derivations are gone entirely.
Deployment note (prod)
p.archfox.org sits behind Cloudflare -> traefik. To keep per-client rate limits there, set PALETTE_TRUSTED_IP_HEADER=CF-Connecting-IP on the prod deployment (one env var in the k8s spec — deploy/QA's call, not done here). Unset, the behavior stays safe: all clients share one bucket per endpoint, so the limit is enforceable but coarse.
Tests
TestRotatingXFFDoesNotResetBucket: the pentest repro (8 creates, rotating XFF) now yields exactly burst-allowed + rest-429.
TestClientIPTrustedHeaderOnlyWhenConfigured: trusted header honored only when explicitly configured.
go build ./... and go test ./... pass. README env-var table documents PALETTE_TRUSTED_IP_HEADER.
Ref #280
## Problem
`clientIP()` (internal/api/ratelimit.go) keyed rate-limit buckets on the RIGHTMOST X-Forwarded-For entry, assuming traefik appends the real client IP. The deployed ingress does not rewrite XFF, so a client rotating its own XFF header got a fresh bucket per request — pentest evidence: 8 creates with rotating XFF -> 6x201, limits never tripped.
## Fix
- Bucket keys now use the actual peer address (`RemoteAddr`) by default. All client-supplied IP headers (X-Forwarded-For, X-Real-Ip) are ignored — no header a client controls can influence keying.
- Per-client limits behind a trusted ingress are restored via an explicit opt-in: `PALETTE_TRUSTED_IP_HEADER` (e.g. `CF-Connecting-IP` when Cloudflare fronts traefik, as on prod p.archfox.org — Cloudflare strips any client-supplied value of that header, so it is attacker-proof when set). The header is honored ONLY when explicitly configured at startup; rightmost-XFF-style derivations are gone entirely.
## Deployment note (prod)
p.archfox.org sits behind Cloudflare -> traefik. To keep per-client rate limits there, set `PALETTE_TRUSTED_IP_HEADER=CF-Connecting-IP` on the prod deployment (one env var in the k8s spec — deploy/QA's call, not done here). Unset, the behavior stays safe: all clients share one bucket per endpoint, so the limit is enforceable but coarse.
## Tests
- `TestRotatingXFFDoesNotResetBucket`: the pentest repro (8 creates, rotating XFF) now yields exactly burst-allowed + rest-429.
- `TestClientIPUsesRemoteAddrNotXFF`: XFF/X-Real-Ip ignored, peer address used.
- `TestClientIPTrustedHeaderOnlyWhenConfigured`: trusted header honored only when explicitly configured.
`go build ./...` and `go test ./...` pass. README env-var table documents `PALETTE_TRUSTED_IP_HEADER`.
clientIP() keyed rate-limit buckets on the rightmost X-Forwarded-For
entry, assuming traefik appends the real client IP. The deployed ingress
does not rewrite XFF, so rotating the header gave a fresh bucket per
request (pentest H1: 8 creates with rotating XFF -> 6x201).
Now the bucket keys on the actual peer address (RemoteAddr) by default;
every client-supplied IP header is ignored. Deployments whose ingress
overwrites a client-IP header can opt in via PALETTE_TRUSTED_IP_HEADER
(e.g. CF-Connecting-IP behind Cloudflare) to restore per-client limits.
Adds tests: rotating XFF no longer resets the bucket; the trusted header
is honored only when explicitly configured.
fen
merged commit eca8533d70 into dev2026-09-18 01:33:04 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Ref #280
Problem
clientIP()(internal/api/ratelimit.go) keyed rate-limit buckets on the RIGHTMOST X-Forwarded-For entry, assuming traefik appends the real client IP. The deployed ingress does not rewrite XFF, so a client rotating its own XFF header got a fresh bucket per request — pentest evidence: 8 creates with rotating XFF -> 6x201, limits never tripped.Fix
RemoteAddr) by default. All client-supplied IP headers (X-Forwarded-For, X-Real-Ip) are ignored — no header a client controls can influence keying.PALETTE_TRUSTED_IP_HEADER(e.g.CF-Connecting-IPwhen Cloudflare fronts traefik, as on prod p.archfox.org — Cloudflare strips any client-supplied value of that header, so it is attacker-proof when set). The header is honored ONLY when explicitly configured at startup; rightmost-XFF-style derivations are gone entirely.Deployment note (prod)
p.archfox.org sits behind Cloudflare -> traefik. To keep per-client rate limits there, set
PALETTE_TRUSTED_IP_HEADER=CF-Connecting-IPon the prod deployment (one env var in the k8s spec — deploy/QA's call, not done here). Unset, the behavior stays safe: all clients share one bucket per endpoint, so the limit is enforceable but coarse.Tests
TestRotatingXFFDoesNotResetBucket: the pentest repro (8 creates, rotating XFF) now yields exactly burst-allowed + rest-429.TestClientIPUsesRemoteAddrNotXFF: XFF/X-Real-Ip ignored, peer address used.TestClientIPTrustedHeaderOnlyWhenConfigured: trusted header honored only when explicitly configured.go build ./...andgo test ./...pass. README env-var table documentsPALETTE_TRUSTED_IP_HEADER.