relay: client-IP extraction helper for HTTP requests (#51)#53
Merged
Conversation
Adds ClientIP, a pure helper that returns the client source IP from an *http.Request. With trustForwardedFor=false it returns the host portion of r.RemoteAddr; with trustForwardedFor=true it takes the left-most X-Forwarded-For entry and falls back to RemoteAddr when the header is absent, empty, or yields no non-empty first entry. Returns "" only when no usable source is available — callers treat that as deny. Table-driven tests cover the RemoteAddr (IPv4/IPv6/malformed/empty) and XFF (single, multi-entry, whitespace, disabled, fallback, double- malformed) matrix from the spec. No callers wired in this ticket — wiring is the rate-limit ticket's job. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ilmoniemi
commented
May 11, 2026
Contributor
Author
ilmoniemi
left a comment
There was a problem hiding this comment.
Code Review: #51
Decision: PASS
Findings
None.
Summary
Faithful implementation of the architect's spec — the production code is essentially the docstring-and-body block from the spec verbatim, and the test table maps 1:1 to the spec's required-cases table (all 13 rows present, names match).
Verified locally:
go test -race ./internal/relay/ -run TestClientIP -v— all 13 subtests PASSgo vet ./...— clean- No callers of
ClientIPexist (per AC; wiring belongs to the rate-limit ticket) - Existing unexported
remoteHosthelper inserver_endpoint.go:131-136left untouched, as the spec mandates
Security-sensitive checks (label-gated):
- Architect security-review section present in
docs/specs/architecture/51-client-ip-helper.mdwith PASS verdict and a thorough adversarial walk (8 scenarios) - Helper handles no tokens/secrets; no file I/O; no subprocess; no crypto; no bare
ListenAndServe - No
// #nosecannotations - Security review's SHOULD-FIX-deferred finding (log-quoting, item 5) is honored in
client_ip.go:30-31via the docstring's log-injection note — the belt-and-suspenders move the spec suggested - Empty-string deny semantics matched:
net.SplitHostPortfailure →"", XFF-only-whitespace → fallback → possibly"". Both paths covered by tests (RemoteAddr_MalformedNoPort,RemoteAddr_Empty,XFF_Enabled_RemoteAddrAlsoMalformed_ReturnsEmpty)
Spec-implementation parity spot-checks:
client_ip.go:34—r.Header.Get(notHeader.Values), as specified for single-header-only XFFclient_ip.go:35—strings.IndexByte(notSplitN), avoids slice allocation per specclient_ip.go:38—TrimSpaceon the comma-stripped value, enabling the whitespace-only-first-entry fallback rowclient_ip.go:43—net.SplitHostPortfor both IPv4host:portand bracketed[ipv6]:port, verified byRemoteAddr_IPv4_WithPort/RemoteAddr_IPv6_Bracketed/RemoteAddr_Loopback_IPv6
CI: test job green; security job still in progress at review time — non-blocking given the audit above, but the dispatcher should still see it green before merge.
…51) Per-ticket codebase note under docs/knowledge/codebase/51.md covering the ClientIP helper, its trust-model surface, and the deferred items the wiring ticket inherits. Two patterns added to PROJECT-MEMORY.md: - Empty-string-as-deny on pure security primitives (no (value, error) ceremony when every caller would handle the error identically). - Two helpers that diverge on contract, not mechanics, get a new export rather than a mutation — preserves both contracts and defers the migration call to the wiring ticket. Three lessons added to docs/lessons.md: - r.Header.Get returns only the first XFF header value when XFF arrives as separate headers; deferred fix uses r.Header.Values + Join. - IndexByte + slice for the allocation-free first-token parse vs SplitN. - net.SplitHostPort as the canonical host:port (and [ipv6]:port) parser. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
relay.ClientIP(r *http.Request, trustForwardedFor bool) string— a pure helper that extracts the source IP from an HTTP request.trustForwardedFor=false: returns the host portion ofr.RemoteAddr(port stripped vianet.SplitHostPort). XFF is ignored even when present.trustForwardedFor=true: returns the left-most entry ofX-Forwarded-For(whitespace-trimmed). Falls back tor.RemoteAddrwhen the header is absent, empty, or has only whitespace before the first comma.""only when no usable source is available (SplitHostPortfailed and either XFF was not consulted or yielded nothing). Callers MUST treat""as deny — the rate-limit wiring ticket enforces this.Issue
Closes #51 (per the AC, the wiring change to
cmd/pyrycode-relay/main.gois explicitly out of scope for this ticket).Testing
New
internal/relay/client_ip_test.go(package relay) with a table-driven test that exercises every row from the architecture spec:RemoteAddrIPv4 with port, IPv6 bracketed, IPv6 loopback, malformed (no port), emptyXFF_Disabled_HeaderIgnoredXFF_Enabledsingle / multi-entry / leading whitespaceRemoteAddrRemoteAddrunusable →""go test -race ./...,go vet ./...,go build ./cmd/...all clean.Architecture compliance
internal/relay/, matching the spec's "Single exported function" section and the convention established byhealthz.go(short doc-comment, no init state).net.SplitHostPort(not manual splitting) per the spec and the issue's technical notes.strings.IndexByterather thanstrings.SplitN— avoids the slice allocation per the spec.r.Header.Get(single-header semantics); multi-header XFF deferred to the wiring ticket per § Open questions.package relay(notrelay_test) per repo convention.remoteHosthelper inserver_endpoint.gois left untouched, as the spec mandates (logging vs rate-limit contracts diverge).🤖 Generated with Claude Code