Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
15 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| pending | 2026-08-17 | Kandev |
Isolate Kandev cookies between instances on one host
Why
Browsers match cookies by host, domain, and path; port is not part of
cookie scope (the Secure attribute controls transmission over TLS, not
scheme partitioning). Every kandev instance bound to the same host (same IP,
different ports) therefore shares one cookie jar: a cookie written by the
instance the user touched last is sent to every other instance on the next
request. localStorage and sessionStorage are origin-scoped (scheme +
host + port) and therefore port-segregated; cookies are the only browser
storage that leaks between instances (empirically verified: a cookie written
on port 18111 is readable on port 18222 of the same host, while
localStorage/sessionStorage are not). Known limit: instances served on the
same host at default ports over different schemes (http://host on :80 and
https://host on :443) both carry no port in their Host, so they keep the
plain cookie names and are not isolated by this fix; the reported scenario
(same scheme, different ports) is fully covered.
Three kandev cookies carry per-instance identity and are all written with
Path=/ and no Domain/Port scoping:
kandev_session— the auth session token (HttpOnly). Two auth-enabled instances on one host overwrite each other's token. The losing instance rejects the foreign token (per-instance session store), 401s every API call, and the SPA'sonUnauthorizedhandler clears auth state and redirects to/login. With two or more instances this ping-pongs: every instance keeps breaking ("crashes / blanks"in the report). Reproduced live with two auth-enabled instances (A on :48429, B on :48529): logging into B yanked A's open UI to the Sign in page; each instance rejects the other's token with{"mode":"enabled","authenticated":false}. The maintainers documented the same caveat in kdlbs/kandev#2689 ("Cookies do not include ports in their scope. Two authenticated Kandev instances on the same hostname can have akandev_sessioncookie conflict").kandev-active-workspaceandoffice-active-workspace— the remembered active workspace. A foreign workspace id is read by every other instance at boot. Today both backend (firstValidID) and frontend resolvers validate the value against the instance's own workspace list and fall back to the first workspace, so this path is graceful rather than fatal — but it constantly churns which workspace (and, since ADR 2026-08-15-office-mode-follows-active-workspace, which mode) each instance boots into, and any unvalidated read of these values would hard-fail.
Private-browsing the demo servers "fixes" the report because the browser gives private contexts an isolated cookie partition — consistent with cookies being the shared state.
What
Port-scope the instance-identity cookie names so each instance reads and writes only its own cookie. The browser already keeps all names in one jar; each instance simply ignores the foreign-named ones.
kandev_sessionbecomeskandev_session_<port>where<port>is derived from the request Host (backend-side set and read), honoringX-Forwarded-Hostwhen present (same pattern as the existingX-Forwarded-ProtoTLS detection). A request without a port in its host keeps the plain name (kandev_session), so default-port deployments are unchanged. The backend resolves the name through a request-aware service method (CookieNameForRequest(r)). The config default forauth.cookieNameis empty (the service keeps thekandev_sessionbase fallback internally, so the effective name for default deployments is unchanged): when the configured value is non-empty it is returned verbatim (never suffixed); when empty the default base name is port-suffixed. This lets the resolver distinguish explicit configuration from the default — a seeded non-empty default would defeat suffixing in production. The generichttpcookie.ScopedNamehelper only appends the suffix and is never fed a configured name.kandev-active-workspace/office-active-workspacebecomekandev-active-workspace_<port>/office-active-workspace_<port>. Both sides derive the suffix from the API origin port (one canonical port): the SPA takes it fromgetBackendConfig().apiBaseUrl— equal towindow.location.portin same-origin deployments (Go serves SPA + API on one port) and equal to the backend port in split-origin dev (Vite SPA on the web port, API on the backend port, no Vite proxy — the browser calls the backend directly), which is the same port the backend sees on its request Host; the backend derives it from the request Host (X-Forwarded-Hostprecedence). Deriving fromwindow.location.portalone would mismatch the backend in split-origin dev. The unprefixed legacy names remain readable as a fallback (their values are still validated against the workspace list), so a pre-upgrade selection survives; new writes only use the scoped names.- The session cookie has no legacy fallback: after upgrade each instance requires one re-login. A fallback would re-open the cross-instance bleed for any mixed-version period.
- Workspace isolation scope: all-new builds. The validated legacy fallback means an upgraded instance still reads the unprefixed workspace cookie while old builds keep writing it, so during a mixed-version deployment an old instance can overwrite the legacy value a new instance reads (when the id validates against the new instance's workspace list). Workspace-selection isolation therefore applies once all instances on the host are upgraded; the legacy fallback exists so pre-upgrade selections survive, and it is safe because resolution validates against the instance's own workspace set (a foreign id falls back gracefully). Sessions are isolated even during mixed versions (no legacy session fallback).
Observable behavior: with two or more upgraded kandev instances on one host (same IP, different ports), logging into one no longer logs the others out, and selecting a workspace in one no longer changes what the others boot into. Single-instance and default-port behavior is unchanged. No HTTP endpoint, WebSocket protocol, or data contract changes.
API surface
Cookie names are browser-observable (DevTools, response Set-Cookie
headers): kandev_session_<port>, kandev-active-workspace_<port>,
office-active-workspace_<port>. No API, route, or wire change.
Failure modes
- Proxy drops
X-Forwarded-Hostor rewrites a non-loopback hostname. Two distinct failures: (1) when the effective host the backend derives the suffix from differs from the browser-visible origin (missing or incorrectX-Forwarded-Hostunder a port rewrite), the cookie name mismatches and every request 401s (login loop); (2) when the proxy rewrites a non-loopback hostname, the CORS/WS origin gate rejects the request with 403 before auth (loopback-alias rewrites such aslocalhost→127.0.0.1pass the existing gate and are unchanged). Primary deployment contract: the proxy MUST preserve the browser Origin hostname. The CORS/WS origin gate (httpmw.AllowedOrigin) compares the browserOriginhostname withr.Hostand does not consultX-Forwarded-Host, so a proxy that rewrites the non-loopback hostname is rejected with 403 before auth regardless of cookie naming;X-Forwarded-Hostmust carry the browser host:port for cookie naming. Two compliant setups: (a) preserveHostfully (XFH absent or identical); (b) rewrite only the port (same hostname) and forward a correctX-Forwarded-Host— CORS hostname (ports are not compared) while the cookie resolver takes the public port from XFH. When both Host and X-Forwarded-Host carry conflicting ports,X-Forwarded-Hostwins for cookie naming (it represents the original browser Host across the chain); that conflict is a proxy misconfiguration and is pinned by a resolver regression test. Rewritten-hostname deployments require an independently configured CORS trust extension, which is out of scope here — qualified to non-loopback hostnames:AllowedOriginalready permits any origin/request pair whose normalized hosts are both loopback (e.g.localhost→127.0.0.1), an existing exception for dev reverse proxies that this fix keeps unchanged and does not regression-test as a change. A full-router test drives the boundary: preservedHost+ publicOriginreaches login; same-hostname port-rewrite + correct XFH passes and scopes to the public port; rewritten non-loopback hostname + publicX-Forwarded-Host+ publicOriginis rejected 403. - Port in URL vs port in Host disagree (rare; browsers normalize
default ports out of Host). Suffix only when the Host actually carries a
port, so
http://hostandhttp://host:80agree in practice. - IPv6 hosts.
net.SplitHostPorthandles bracketed forms ([::1]:8080); unbracketed::1has no port and gets no suffix. - Custom cookie names disable automatic port isolation. A non-empty
auth.cookieNameis used verbatim (never suffixed), so two auth-enabled instances on one cookie host configured with the same custom name overwrite each other exactly like the original bug — the isolation promise applies to default-named instances. Deployments that set a custom name must make it unique per cookie host (per instance), and should treat it as single-instance-oriented. The canonical environment override isKANDEV_AUTH_COOKIE_NAME(explicitBindEnv; the automatic camelCase mapping would otherwise yield the undocumentedKANDEV_AUTH_COOKIENAME). - Mixed-version migration. Old builds keep writing the unprefixed names.
The upgraded instance ignores legacy session cookies (clean one-time
re-login) and reads legacy workspace cookies only as a validated fallback,
but an old build still conflicts with other old builds, and a rollback
resurrects the original bleed (old binaries read/write the shared
unprefixed
kandev_session). Operational contract: all auth-enabled instances on one host must upgrade together; after a rollback every instance needs a fresh login. On re-upgrade the guarantee is mechanical: the new build reads only scoped cookie names, which the old build never wrote, so no valid scoped session exists until the user logs in again (old session rows remain in the DB but are unreachable via the scoped name). The upgraded instance does not proactively delete the legacy cookie — scrubbing the shared jar from one instance would just log other (old) instances out and they would re-write it.
Scenarios
- GIVEN two auth-enabled kandev instances A (port 8080) and B (port 8081) on the same host, WHEN the user logs into B, THEN A's open session keeps working, and A's next page load shows A's authenticated UI rather than the Sign in page.
- GIVEN the same pair, WHEN the user selects a workspace in B, THEN A's next boot resolves A's own workspace selection, not B's workspace id.
- GIVEN an instance upgraded from a version that wrote the unprefixed workspace cookie, WHEN the instance boots, THEN the legacy value is honored when it names one of the instance's own workspaces.
- GIVEN an instance upgraded from a version that wrote the unprefixed session cookie, WHEN the user loads the instance, THEN the user is asked to sign in once (the legacy token is not accepted), and the new scoped cookie is used afterwards.
- GIVEN a single instance on the default port (no port in the URL),
WHEN the user logs in, THEN the cookie keeps the plain
kandev_sessionname and the flow is unchanged. - GIVEN two auth-enabled instances A and B on one host where B is still an old build, WHEN both users are logged in, THEN the old build still conflicts with other old builds (documented limitation — old-build wire behavior, not regression-tested).
- GIVEN the same pair after B upgrades and all instances re-login, THEN the conflict is gone — recovery is inferred from independently tested invariants (the new build rejects the legacy session token; two new builds keep isolated sessions per port).
- GIVEN an old instance A and an upgraded instance B on one host, WHEN the user selects a workspace in A, THEN B may read A's legacy unprefixed workspace value (validated fallback — resolved only when it names one of B's own workspaces, else B falls back); workspace isolation is a property of all-new-build deployments (documented limitation, invariant-tested).
- GIVEN an auth-enabled instance behind a reverse proxy that preserves
the browser
Host(public.example:8443), WHEN the user logs in, THEN the session cookie is set under the public-origin port suffix (kandev_session_8443) and subsequent requests authenticate. - GIVEN an auth-enabled instance behind a reverse proxy that rewrites
a non-loopback hostname (even with correct
X-Forwarded-Host), WHEN the browser sends a request from the public origin, THEN the CORS/WS origin gate rejects it with 403 before auth (the gate comparesOrigintoHostand ignoresX-Forwarded-Host); the cookie-name resolver would derive theX-Forwarded-Hostsuffix in isolation, but the request never reaches it. Rewritten-hostname deployments require an independently configured CORS trust extension, which is out of scope for this fix. Loopback-alias rewrites (e.g.localhost→127.0.0.1) pass the gate by existingAllowedOrigindesign (dev reverse proxies) and are unchanged. A same-hostname port-rewrite with correct XFH remains compliant. - GIVEN split-origin dev (SPA on the web port, API on the backend port, no Vite proxy), WHEN the user selects a workspace in the SPA, THEN the workspace cookie is written with the backend port suffix and the backend's boot reads the same scoped name (no legacy fallback needed).
- GIVEN an unconfigured (default) deployment on a ported host, WHEN
the user logs in, THEN the session cookie is
kandev_session_<port>— the empty config default must not suppress suffixing. - GIVEN two auth-enabled instances on one cookie host both configured
with the same custom
auth.cookieName, WHEN both users are logged in, THEN the instances still conflict (documented exception: custom names disable automatic port isolation and must be unique per host).
Out of scope
kandev_locale(validated, benign) and the write-onlylayout:*cookies keep their names.- The path-scoped port-proxy capability cookie (
kandev_port_proxy) is unchanged. - Personal access tokens and the WS
?token=path are unaffected. - No legacy fallback for the session cookie (deliberate; see What).