8.9 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/auth/trusted-proxies.md | 2026-08-15 | building |
Implementation Plan: Trusted Proxies for X-Forwarded-For
Overview
Make the backend honor X-Forwarded-For only when the request's TCP peer is
one of the proxies listed in KANDEV_TRUSTED_PROXIES (comma-separated IPs /
CIDRs), while keeping the current secure default (no trusted proxies) when the
variable is unset, empty, or contains an unparsable entry. The change is one
env-var read plus a SetTrustedProxies call at the single router construction
site in buildHTTPServer; every ClientIP() consumer (login / setup / invite-accept / plugin-SSO
session IPs and the login rate-limiter key) picks up the behavior with no
per-callsite changes; RequestLogger never reads ClientIP() and stays
untouched.
Order: Tasks 01 (parser + wiring with backendapp unit tests) and 02 (auth HTTP behavioral tests that pin the observable session-IP contract; they configure the router at the fixture level and are independent of the wiring) are parallel-safe Wave-1 candidates with disjoint files, sequential by default absent user authorization; Task 03 (public configuration docs) lands as Wave 2 after them.
Backend
Env parsing and router wiring (apps/backend/internal/backendapp/trustedproxies.go, new)
const trustedProxiesEnv = "KANDEV_TRUSTED_PROXIES".func resolveTrustedProxies(raw string) (trusted []string, invalid []string): an entirely blank raw value is unset (nil, no invalid). Otherwise split on,, trim each entry, and validate each withnet.ParseCIDRwhen it contains/, elsenet.ParseIP; a blank component inside a nonblank value is an invalid entry (named<empty>), because empty is not an IP or CIDR. Any invalid entry makes the whole list fail closed:trustedisnilandinvalidnames the bad values. (Reasons: gin v1.9.1SetTrustedProxieserrors on an unparsable entry, and the task requires never partially trusting a list.)func configureTrustedProxies(router *gin.Engine, log *logger.Logger): readsos.Getenv(trustedProxiesEnv), callsresolveTrustedProxies, logs aWarnnaming the invalid values when any exist (and forcestrusted = nil), then callsrouter.SetTrustedProxies(trusted)with the existinglog.Warnfallback on error.nilmatches the current production call.- Env-reading follows the repo pattern of direct
os.Getenvat the call site (compareKANDEV_WEB_DIST_DIRinhelpers.go,KANDEV_CONSOLE_LOG_LEVELinmain.go). No new config framework, no YAML key.
Router construction site (apps/backend/internal/backendapp/main.go)
- In
buildHTTPServer, replace the comment block at lines 1924-1930 and therouter.SetTrustedProxies(nil)call with the new explanatory comment (documentsKANDEV_TRUSTED_PROXIES, the spoofing tradeoff for a directly reachable backend, and the no-trusted-proxies default) and a call toconfigureTrustedProxies(router, log). - No other router construction sites exist for the main HTTP surface; the agentctl control server is loopback-only and out of scope.
Frontend
No UI changes. ClientIP() feeds the session IP shown in
Settings > Account > Security, which renders the already-stored value.
Tests
- Parse rules (
apps/backend/internal/backendapp/trustedproxies_test.go): table test overresolveTrustedProxiescovering entirely blank raw value, single CIDR, multiple CIDRs with whitespace, bare IPv4/IPv6, IPv6 CIDR, unparsable entry, mixed valid+invalid (must returntrusted == nil), and blank components inside a nonblank value (leading/trailing/doubled commas) must returntrusted == nilwith<empty>ininvalid. - Env wiring + warning (same file):
configureTrustedProxieswitht.Setenv; behavioral assertion via agin.New()router serving a handler that returnsc.ClientIP(): trusted peer +X-Forwarded-Forheader resolves the header value; unset env and not-matching CIDR return the TCP peer; invalid env logs a warning naming the bad value (zap observer pattern:zaptest/observer+logger.NewFromZap, used across the repo) and behaves like unset. - Session IP contract (
apps/backend/internal/auth/httpapi/handlers_test.go): add a request-mutation seam to the test client (variadic mutator onapiClient.do) so tests can setRemoteAddrand forwarded headers, and extendnewAPIFixtureto clear trusted proxies by default (router. SetTrustedProxies(nil), mirroring production) and accept an optional trusted-proxy list. Named tests (anchored-run '^TestLoginSessionIP'), login withreq.RemoteAddr = "10.0.0.5:1234", then read the stored session IP viaGET /api/v1/auth/sessions:- trusted
10.0.0.0/8+X-Forwarded-For: 203.0.113.7→ session IP203.0.113.7; - no trusted proxies + the same header → session IP
10.0.0.5; - trusted
192.168.0.0/16(peer not in list) + the same header → session IP10.0.0.5; - trusted peer +
X-Real-IP: 203.0.113.7only → session IP203.0.113.7; - trusted peer +
X-Forwarded-For: not-an-ip+ validX-Real-IP→ session IP fromX-Real-IP; - trusted peer + neither header → session IP
10.0.0.5. In listed order these exercise spec scenarios 2 (trusted peer + XFF), 1 (unset), 3 (untrusted CIDR), 8 (X-Real-IP only), 9 (malformed XFF + valid X-Real-IP), 7 (neither header). Spec scenario 4 (invalid env → warning + nil trust) is covered by Task 01's backendapp tests; scenarios 5-6 (spoofing) are the trust mechanics of scenarios 2/3 from an attacker framing and need no separate test: a peer inside a trusted range is believed, a peer outside every range is ignored. The pre-fixture RED case is the no-trusted-proxies assertion (gin's trust-all default would honor the header); the trusted cases are GREEN at the fixture level regardless of Task 01, so Task 02 does not depend on it. Acceptance c (invalid value) is covered by the backendapp warning + nil-fallback test combined with the no-trusted-proxies session test.
- trusted
- No E2E: no user-facing flow changes (the Settings page renders a stored value; the behavior difference is only which IP is stored).
- Manual integration smoke (post-implementation gate, after Tasks 01-02
land): run the backend with
KANDEV_TRUSTED_PROXIES=<proxy-cidr>, log in through the proxy, and confirm Settings > Account > Security shows the real client IP. If the environment cannot run a proxied instance, record the concrete blocker in the plan's Verification Results instead of dropping the check.
Verification Results
- Task 01 parser + wiring:
cd apps/backend && go test ./internal/backendapp -run '^Test(ResolveTrustedProxies|ConfigureTrustedProxies)' -count=1— RED (build failed: undefined symbols) then GREEN (13 subtests + 4 behavioral tests). Regressiongo test ./internal/backendapp/... -count=1— ok.go build ./...— ok. - Task 02 session-IP contract:
cd apps/backend && go test ./internal/auth/httpapi -run '^TestLoginSessionIP' -count=1— RED (unset case stored forwarded IP under gin's trust-all default) then GREEN (6 tests). Full auth suitego test ./internal/auth/... -count=1— ok. - Task 03 docs:
node scripts/validate-public-docs.mjs && git diff --check— "Validated 41 published docs pages." + clean diff. - Combined gate:
cd apps/backend && go build ./... && go test ./internal/auth/... ./internal/backendapp/... -count=1— all ok (auth, auth/httpapi, auth/httpmw, auth/store, backendapp, backendapp/ownershiplock). - Manual integration smoke: BLOCKED — no proxied kandev deployment available in this harness (needs a running backend + reverse proxy + browser login). The observable contract is covered by the session-IP tests above; run the smoke in the Test step against a real proxy deployment.
Implementation Waves And Parallel Candidates
Wave 1 (parallel candidates — user authorization required; files are disjoint:
internal/backendapp/* vs internal/auth/httpapi/*):
Wave 2:
Task 02 exercises the router at the fixture level and does not depend on Task 01's code; Task 03 is disjoint from both. All sequential by default per repo convention; no subagent execution is authorized by this plan.
Risks and out of scope
- gin v1.9.1
New()trusts all proxies (0.0.0.0/0,::/0) by default, so both production and the auth test fixture must keep an explicitSetTrustedProxies(nil)default; the fixture change is part of Task 02. - Setting the variable while the backend is directly reachable allows
X-Forwarded-Forspoofing and defeats the ClientIP-keyed login rate limiter; this is the documented tradeoff, not a defect. X-Forwarded-Proto, WebSocketremote_addrlogging, andRequestLoggerfields stay untouched.