Area: api · observability — edge integration · split out of #332 / #281
Context
PR #332 dropped chi's deprecated middleware.RealIP (#281) because it rewrote r.RemoteAddr from spoofable forwarded headers (X-Forwarded-For/X-Real-IP/True-Client-IP) on every request, whether or not a trusted proxy set them, and nothing in WaveHouse read r.RemoteAddr (no per-IP logic — rate limiting is the reverse proxy's job). That removed the IP-spoofing vector (GHSA-3fxj-6jh8-hvhx / GHSA-rjr7-jggh-pgcp / GHSA-9g5q-2w5x-hmxf) and unblocked the go-deps dependabot bump (#209).
The trade-off: WaveHouse now has no real client IP in its own traces/logs — behind a proxy, r.RemoteAddr (and OTel's client.address) is the proxy's IP. We want the real client IP back, captured safely, plus to formalize trace continuation from the proxy.
Scope
- Trusted-proxy config. Declare trusted proxies — a CIDR list (e.g.
WH_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12) is the correct model. Default empty ⇒ trust nothing (use the immediate peer).
- Client-IP extraction (trusted-proxy-aware). When the immediate peer is a trusted proxy, walk
X-Forwarded-For right-to-left and take the first hop that is not itself a trusted proxy — the real client. ⚠️ Do not take the leftmost / True-Client-IP value blindly: that's client-controlled and would reintroduce the exact spoofing the RealIP drop just fixed.
- Surface it. Attach the resolved client IP as a span attribute (
client.address) and a structured-log field, alongside the existing trace_id/span_id (internal/observability/logger.go).
- Trace-context propagation (already works — formalize + gate). WaveHouse already sets the W3C TraceContext propagator (
internal/observability/provider.go:110) and uses otelhttp without WithPublicEndpoint, so an incoming traceparent from an nginx/Caddy OTel module is already adopted as the parent — the request joins that trace and logs already carry the continued trace_id. Two gaps: (a) it's undocumented and has no test for the incoming-traceparent case; (b) it trusts any client's traceparent (trace pollution / a direct client injecting itself into your traces). Gate continuation on the same trusted-proxy boundary (e.g. otelhttp.WithPublicEndpoint() or a custom check, so an untrusted peer's incoming trace is linked, not adopted).
- Docs. Add a "trusted proxy / client IP" section to the reverse-proxy page, and document that a proxy-supplied
traceparent is continued.
Security note
The XFF-parsing logic is a classic footgun — getting the trusted-hop walk wrong rebuilds a spoofable-IP feature. Warrants careful review + tests: spoof attempts, multiple chained proxies, IPv6, and missing/empty headers.
Related: #281 (dropped RealIP), #332 (the PR that dropped it), #241 (reverse-proxy docs), #209 (go-deps dependabot, unblocked by the drop).
Area: api · observability — edge integration · split out of #332 / #281
Context
PR #332 dropped chi's deprecated
middleware.RealIP(#281) because it rewroter.RemoteAddrfrom spoofable forwarded headers (X-Forwarded-For/X-Real-IP/True-Client-IP) on every request, whether or not a trusted proxy set them, and nothing in WaveHouse readr.RemoteAddr(no per-IP logic — rate limiting is the reverse proxy's job). That removed the IP-spoofing vector (GHSA-3fxj-6jh8-hvhx / GHSA-rjr7-jggh-pgcp / GHSA-9g5q-2w5x-hmxf) and unblocked the go-deps dependabot bump (#209).The trade-off: WaveHouse now has no real client IP in its own traces/logs — behind a proxy,
r.RemoteAddr(and OTel'sclient.address) is the proxy's IP. We want the real client IP back, captured safely, plus to formalize trace continuation from the proxy.Scope
WH_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12) is the correct model. Default empty ⇒ trust nothing (use the immediate peer).X-Forwarded-Forright-to-left and take the first hop that is not itself a trusted proxy — the real client.True-Client-IPvalue blindly: that's client-controlled and would reintroduce the exact spoofing the RealIP drop just fixed.client.address) and a structured-log field, alongside the existingtrace_id/span_id(internal/observability/logger.go).internal/observability/provider.go:110) and usesotelhttpwithoutWithPublicEndpoint, so an incomingtraceparentfrom an nginx/Caddy OTel module is already adopted as the parent — the request joins that trace and logs already carry the continuedtrace_id. Two gaps: (a) it's undocumented and has no test for the incoming-traceparentcase; (b) it trusts any client'straceparent(trace pollution / a direct client injecting itself into your traces). Gate continuation on the same trusted-proxy boundary (e.g.otelhttp.WithPublicEndpoint()or a custom check, so an untrusted peer's incoming trace is linked, not adopted).traceparentis continued.Security note
The XFF-parsing logic is a classic footgun — getting the trusted-hop walk wrong rebuilds a spoofable-IP feature. Warrants careful review + tests: spoof attempts, multiple chained proxies, IPv6, and missing/empty headers.
Related: #281 (dropped RealIP), #332 (the PR that dropped it), #241 (reverse-proxy docs), #209 (go-deps dependabot, unblocked by the drop).