b0ca65971c
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
7.7 KiB
7.7 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| building | 2026-08-19 | tbd |
Session IP Refresh
Why
A browser session's IP is written once at login and never updated. Sessions
can live for weeks (the sliding TTL is refreshed on activity), so the account
security page (Settings > Account > Security, backed by
GET /api/v1/auth/sessions) keeps showing the login-time address forever.
Roaming/mobile clients, DHCP re-leases, or a proxy that only started
forwarding X-Forwarded-For after login leave a stale IP on display, which
users read as a security problem (a proxy IP attributed to their client).
What
- The recorded session IP SHALL be refreshed, after creation, to the current request's resolved client IP when the two differ.
- The refresh SHALL ride the existing throttled session-touch path
(
TouchSession, interval 60 seconds —sessionTouchInterval = time.Minuteininternal/auth/service.go; nominally at most once per minute per session; the throttle is best-effort — concurrent resolves that all read a pre-intervalLastSeenAteach write, and the last write wins, exactly as the timestamp touch already behaves): no per-request writes are added. - An empty or unresolvable client IP SHALL NEVER overwrite a recorded value.
- Login-time recording (and setup / invite-accept / plugin-SSO recording) SHALL remain unchanged: the IP captured when the session is minted is the starting value.
- The client IP used for the refresh is the same
ClientIP()value that records the IP at login, includingKANDEV_TRUSTED_PROXIESresolution (see trusted-proxies.md). - Middleware-level conformance tests MUST clear gin's trusted proxies on
their router (
SetTrustedProxies(nil), exactly as production'sconfigureTrustedProxiesdoes): a baregin.New()trustsX-Forwarded-Forfrom ANY peer, so an untrusted-peer test would resolve the forwarded header instead of the TCP peer and could be misread as evidence to wire the middleware to raw headers — the exact bug the untrusted-header scenario exists to catch. - Conformance tests that READ BACK a session's IP over HTTP become
time-sensitive under this refresh: a >60s gap between the login POST and
the read-back lets the read-back request's own touch overwrite the
asserted IP. Any such read-back MUST re-apply the login request's full
transport closure (RemoteAddr + forwarded headers) so the touch is
idempotent. This applies to the landed
TestLoginSessionIP*suite inhttpapi/handlers_test.goand to any new middleware-level read-back. - The refresh applies to EVERY request the global middleware authenticates,
including WebSocket upgrades and deferred-path routes (
/ws,/terminal/,/lsp/,/vscode/,/port-proxy/,/mcp, SPA-shell; office-with-bearer — refresh applies only when the request ALSO carries a session cookie, since the global middleware never authenticates bearer-only office JWTs):httpmw.MiddlewarerunsResolveRequestbefore theisDeferredPathcheck, so cookie-authenticated requests to those paths touch too. A middleware-level conformance test MUST exercise a cookie-authenticated deferred-path request (e.g.GET /ws) and assert the stored IP refreshed — this pins the skip-resolve-for-deferred-paths regression.
Data model
auth_sessions
ip TEXT NOT NULL DEFAULT '' existing column; updated in place on touch
No migration. The ip column already exists; only its write path changes.
API surface
- HTTP surface is unchanged:
GET /api/v1/auth/sessionskeeps returning the session rows with theiripfield; the value now tracks the client's current address instead of the login-time one. - Internal Go contracts change (both
internal/authonly):Service.ResolveSessionToken(ctx, token)becomesResolveSessionToken(ctx, token, ip string)— the caller passes the request's resolved client IP. Callers without a request IP (unit tests, non-HTTP resolvers) MUST pass""; an emptyipmeans "no IP refresh" (timestamps still touch). The mechanical test call sites pass""(service_test.go and service_sso_test.go).Store.TouchSession(ctx, id, lastSeen, expires)becomesTouchSession(ctx, id, lastSeen, expires, ip string)— timestamps always update;iprefreshes the stored value only when non-empty.
Failure modes
- Empty client IP (no peer address resolvable): the recorded IP is preserved; only timestamps update.
- Touch write fails (DB error): the resolution still succeeds — the session authenticates as today; the IP simply stays stale until the next successful touch. This matches the existing best-effort touch behavior.
- IP differs but the touch interval has not elapsed: the refresh is deferred to the next touch, which happens on the next authenticated request after the interval (if the client goes idle, that can be arbitrarily later — the throttle bounds WRITE FREQUENCY, not refresh latency). Writes stay at most once per minute per session; this is the bounded-write tradeoff the issue asks for.
- Textual IP comparison: the refresh gate compares
ClientIP()strings verbatim (no canonicalization). A trusted proxy forwardingX-Forwarded-For: ::ffff:2.2.2.2DOES reach the comparison in mapped form — gin v1.9.1 returns raw header strings verbatim on the trusted-header path (validateHeader/ClientIP);net.IP.String()normalization applies only to the RemoteAddr fallback. A client presented once as2.2.2.2(non-mapped header) and later as::ffff:2.2.2.2therefore triggers one spurious rewrite, bounded to the touch interval and self-healing (the stored value becomes the new form); a session alternating between a mapped-form forwarded header and a plain RemoteAddr transport flips textual forms on successive touches, again bounded by the 60-second throttle. Accepted: the written value always comes from gin'sClientIP(), so no wrong address is ever recorded.
Scenarios
- GIVEN a session created from IP
203.0.113.7whoselast_seen_atis older than the touch interval, WHEN an authenticated request arrives from IP198.51.100.4, THEN the stored session IP becomes198.51.100.4andGET /api/v1/auth/sessionsreturns198.51.100.4. - GIVEN a session created from IP
203.0.113.7whoselast_seen_atis older than the touch interval, WHEN an authenticated request arrives from the same IP203.0.113.7, THEN the stored IP is unchanged (only timestamps update). - GIVEN a session whose stored IP is
203.0.113.7, WHEN an authenticated request with an empty client IP triggers the touch, THEN the stored IP stays203.0.113.7. - GIVEN a session whose stored IP is empty (e.g. minted by a caller without a request IP), WHEN an authenticated request with a non-empty client IP triggers the touch, THEN the stored IP is backfilled with that client IP.
- GIVEN a freshly created session, WHEN an authenticated request arrives from a different IP before the touch interval elapses, THEN the stored IP is unchanged; it updates on the next request after the interval.
- GIVEN a login from IP
203.0.113.7, WHEN the session is minted, THEN the recorded IP is203.0.113.7(login-time recording unchanged).
Out of scope
- No UI changes: the security page keeps rendering the stored
ipvalue. - Personal access tokens are untouched (they carry no IP).
- No schema migration, no new configuration, no new endpoint.
- No E2E test: a browser cannot change its own client IP mid-session, and the user-visible surface is a stored value rendered verbatim; the behavior is covered by backend integration tests through the real middleware chain.