|
| 1 | +# Ticket #51 — client-IP extraction helper for HTTP requests |
| 2 | + |
| 3 | +Pure helper that produces the source IP for a request, in a form suitable for keying a rate-limit bucket. Standalone primitive: ships the function and its test table only, no callers wired. Feeds the per-IP rate-limiter (#50) once the wiring ticket lands; the split exists so the trust-model nuance (when to read `X-Forwarded-For`, when to ignore it) gets its own review pass before any handler depends on it. |
| 4 | + |
| 5 | +## Implementation |
| 6 | + |
| 7 | +- **`internal/relay/client_ip.go`** — exported `ClientIP(r *http.Request, trustForwardedFor bool) string`. Pure function, no I/O, no state. |
| 8 | + - `trustForwardedFor=false`: returns `net.SplitHostPort(r.RemoteAddr)` host portion. Empty string on parse error. |
| 9 | + - `trustForwardedFor=true`: takes the left-most `X-Forwarded-For` entry (`strings.IndexByte(',')` + `strings.TrimSpace`), falls back to `RemoteAddr` if the header is absent, empty, or yields no non-empty first entry. |
| 10 | + - Empty-string return is the only "no usable source" signal — callers MUST treat as deny. No `(string, error)` pair; every caller would handle the error identically. |
| 11 | + - No canonicalisation: the returned string is the raw host portion as it appears on the wire (no IPv6 lower-casing, no zone-id stripping). Two different zones (`fe80::1%eth0` vs `fe80::1%eth1`) legitimately denote different interfaces; collapsing them is the wrong default. Docstring names the consequence — log emitters must `strconv.Quote` to avoid log injection from embedded control bytes. |
| 12 | +- **`internal/relay/client_ip_test.go`** — 13-row table covering `RemoteAddr` (IPv4 with port, IPv6 bracketed, IPv6 loopback, malformed no-port, empty), XFF-disabled-header-ignored, XFF-enabled (single, multi-entry, leading whitespace), and the four fallback rows (absent header, empty header, whitespace-only first entry, both sources malformed → `""`). Constructed via `httptest.NewRequest` with `r.RemoteAddr` and `r.Header` set directly — no real listener. |
| 13 | +- **No changes to `cmd/pyrycode-relay/main.go`** — per the AC, this is the primitive only. The unexported `remoteHost` helper in `server_endpoint.go:129-136` is **unchanged**: it remains the logging helper (falls back to `r.RemoteAddr` verbatim on parse error). The two helpers diverge on contract (logging vs rate-limit key), so the new helper is a new export rather than a mutation of `remoteHost`. Whether to migrate logging is the wiring ticket's call. |
| 14 | + |
| 15 | +## Trust model |
| 16 | + |
| 17 | +The bool parameter is the entire policy surface. The helper itself performs no trust decision; the caller passes the bit it has determined from operator configuration. Two named threats: |
| 18 | + |
| 19 | +- **Direct-facing deployment, `trustForwardedFor=true` by mistake.** Adversarial phone forges `X-Forwarded-For: 127.0.0.1`; every request reports `127.0.0.1`; rate-limit collapses to one bucket. Self-DoS via misconfiguration, not privilege escalation. The wiring ticket is responsible for defaulting the bit to `false`. |
| 20 | +- **Trusted-proxy deployment, proxy misconfigured.** Phone sets `X-Forwarded-For: victim-ip` *before* the proxy; the proxy must overwrite or append. The relay-side helper cannot defend against an upstream that doesn't sanitise — that's the proxy's responsibility. Documented in the docstring. |
| 21 | + |
| 22 | +## Out of scope |
| 23 | + |
| 24 | +- Wiring into any handler / `cmd/pyrycode-relay/main.go` — the rate-limit wiring ticket's job. |
| 25 | +- Trusted-proxy CIDR allowlist (right-most-trusted hop chain). Today's bool is flat all-or-nothing. The hop-aware variant is a future ticket once a real proxy is chosen. |
| 26 | +- `X-Real-IP` support. Separate (proxy-specific) convention; deferred to the wiring ticket. |
| 27 | +- Multi-header `X-Forwarded-For` (two separate `X-Forwarded-For:` headers on one request). `r.Header.Get` returns only the first; if a real proxy is observed emitting two-header XFF, the fix is `r.Header.Values` + `strings.Join` — two lines, one new test row. Deferred until observed (per *Evidence-Based Fix Selection*). |
| 28 | +- Canonicalisation, zone-id stripping, control-byte sanitisation — deliberate non-policy on a security primitive. |
| 29 | + |
| 30 | +## Cross-links |
| 31 | + |
| 32 | +- [Threat model § DoS resistance](../../threat-model.md) — the "Future hardening: per-IP rate limit" line this helper enables. |
| 33 | +- [Codebase: #50 per-IP rate-limiter](50.md) — the sibling primitive this helper produces keys for. |
| 34 | +- [Lesson: `r.Header.Get` returns the first header only](../../lessons.md) — the multi-header XFF gotcha deferred above. |
0 commit comments