Skip to content

relay: client-IP extraction helper for HTTP requests (RemoteAddr + XFF) #49

@ilmoniemi

Description

@ilmoniemi

User Story

As a relay operator, I want a reusable client-IP extraction helper so that the rate-limit middleware (and any future per-IP policy) can derive a consistent source identifier from an inbound HTTP request — with explicit, configurable trust in proxy-supplied headers.

Context

docs/threat-model.md § DoS resistance and § "Spoofable headers" together set the constraint: behind a trusted reverse proxy the X-Forwarded-For header is authoritative for the original client IP; otherwise the only trustworthy source is the TCP peer (r.RemoteAddr), and trusting XFF without a proxy in front allows clients to spoof their bucket identity. The helper must make that trust decision a parameter, not a default.

This ticket introduces the extraction helper only — a pure function with full test coverage. The token-bucket primitive that consumes it lives in a sibling ticket; wiring both into the upgrade handlers lives in a follow-up. (Split from #46, which was split from #34.)

Acceptance Criteria

  • An exported helper lives in internal/relay/ taking an *http.Request and a trustForwardedFor bool and returning the client IP as a string:
    • When trustForwardedFor is false, returns the host portion of r.RemoteAddr (port stripped).
    • When trustForwardedFor is true and X-Forwarded-For is present and non-empty, returns the left-most entry (the original client) with surrounding whitespace stripped; falls back to r.RemoteAddr (host portion) if the header is absent or empty.
    • Returns the empty string only when no usable source is available; callers treat that as "deny" (the wiring ticket enforces this).
  • Pure function with no goroutines, no shared state, no allocations beyond the returned string — safe for concurrent use from request handlers.
  • Tests cover at minimum: RemoteAddr extraction with host:port, with bare host (no port), and with IPv6-bracketed [::1]:port; XFF extraction with a single entry, with multiple comma-separated entries (verifies left-most selected), with leading/trailing whitespace around entries; XFF disabled (header is ignored even when present); empty XFF header falls back to RemoteAddr; both sources empty returns "".
  • No changes to cmd/pyrycode-relay/main.go — this ticket ships the helper only.

Technical Notes

  • Tests in this codebase live in package relay (not relay_test) so they can reach unexported helpers; use httptest.NewRequest or hand-built *http.Request literals as needed.
  • net.SplitHostPort handles the IPv6 bracket case correctly; if the address has no port, it returns an error and the raw value is the host. Use that as the branching cue rather than substring math.
  • Header lookup must be case-insensitive — r.Header.Get("X-Forwarded-For") is the right shape (Go canonicalises).

Size Estimate

XS — one exported function (~20-30 lines) + tests. Trivial change with no goroutines or mutable state.


Split from #46 (which was split from #34). The companion token-bucket primitive is a sibling ticket; both block the wiring ticket #47.

Metadata

Metadata

Assignees

No one assigned

    Labels

    security-sensitiveTouches auth, crypto, or internet-exposed input pathssize:xsTiny ticket: <30 lines production code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions