fix: isolate kandev cookies between instances on one host (#2813)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
+20
-5
@@ -34,11 +34,22 @@ the local single-user install with a login screen it never asked for.
|
||||
`admin` (user management, auth settings, destructive system operations,
|
||||
feature toggles) and `member`. Users can be disabled (revokes all sessions
|
||||
and tokens); the last active admin cannot be demoted or disabled.
|
||||
- **Sessions & tokens.** Browser sessions are opaque cookies
|
||||
(`kandev_session`, HttpOnly, SameSite=Lax, sliding 30-day expiry,
|
||||
DB-backed and revocable from `Settings > Account`). Programmatic clients
|
||||
(external MCP, scripts, CI) use personal access tokens
|
||||
(`kandev_pat_…`, shown once, revocable).
|
||||
- **Sessions & tokens.** Browser sessions are opaque cookies with the base
|
||||
session-cookie name `kandev_session` (effective name is request-host
|
||||
derived — see the port-scoping bullet below), HttpOnly, SameSite=Lax,
|
||||
sliding 30-day expiry, DB-backed and revocable from `Settings > Account`.
|
||||
Programmatic clients (external MCP, scripts, CI) use personal access
|
||||
tokens (`kandev_pat_…`, shown once, revocable).
|
||||
- **Cookie name is port-scoped when the instance is served on a non-default
|
||||
port.** Cookies ignore port when deciding scope, so two auth-enabled
|
||||
instances on one host (same IP, different ports) would otherwise overwrite
|
||||
each other's `kandev_session` and yank every instance to `/login` on the
|
||||
next request. The backend derives the session cookie name from the request
|
||||
host: `kandev_session_<port>` on a ported host, plain `kandev_session` on a
|
||||
default-port host (see `fix-multi-instance-cookie-isolation`). An explicit
|
||||
`auth.cookieName` config value is used verbatim. The legacy unprefixed
|
||||
cookie is deliberately not read (one re-login after upgrade keeps the
|
||||
cross-instance conflict dead).
|
||||
- **Invites.** Admins mint tokenized invite URLs (`/invite?token=…`,
|
||||
optional pinned email, member/admin role, 7-day default expiry, single
|
||||
use). No email server required. Admins can also create accounts directly.
|
||||
@@ -124,6 +135,10 @@ the local single-user install with a login screen it never asked for.
|
||||
- **GIVEN** an authenticated Kandev browser session, **WHEN** a protected API
|
||||
request receives a Kandev session challenge, **THEN** the browser clears the
|
||||
stale Kandev identity and navigates to `/login`.
|
||||
- **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 session
|
||||
keeps working and A's next page load authenticates with A's own
|
||||
`kandev_session_8080` cookie rather than showing the Sign in page.
|
||||
- **GIVEN** an authenticated Kandev browser session and an expired or invalid
|
||||
third-party integration credential, **WHEN** GitHub, GitLab, Jira, or Linear
|
||||
data loading returns 401 without a Kandev session challenge, **THEN** the
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
status: pending
|
||||
created: 2026-08-17
|
||||
owner: 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's `onUnauthorized` handler 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 a `kandev_session` cookie conflict").
|
||||
- `kandev-active-workspace` and `office-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_session` becomes `kandev_session_<port>` where `<port>` is derived
|
||||
from the request Host (backend-side set and read), honoring
|
||||
`X-Forwarded-Host` when present (same pattern as the existing
|
||||
`X-Forwarded-Proto` TLS 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 for `auth.cookieName`
|
||||
is **empty** (the service keeps the `kandev_session` base 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 generic
|
||||
`httpcookie.ScopedName` helper only appends the suffix and is never fed a
|
||||
configured name.
|
||||
- `kandev-active-workspace` / `office-active-workspace` become
|
||||
`kandev-active-workspace_<port>` / `office-active-workspace_<port>`.
|
||||
**Both sides derive the suffix from the API origin port** (one canonical
|
||||
port): the SPA takes it from `getBackendConfig().apiBaseUrl` — equal to
|
||||
`window.location.port` in 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-Host` precedence). Deriving from `window.location.port`
|
||||
alone 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-Host` or 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 incorrect
|
||||
`X-Forwarded-Host` under 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 as `localhost` →
|
||||
`127.0.0.1` pass 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
|
||||
browser `Origin` hostname with `r.Host` and does not consult
|
||||
`X-Forwarded-Host`, so a proxy that rewrites the **non-loopback hostname**
|
||||
is rejected with 403 before auth regardless of cookie naming;
|
||||
`X-Forwarded-Host` must
|
||||
carry the browser host:port for cookie naming. Two compliant setups:
|
||||
(a) preserve `Host` fully (XFH absent or identical); (b) rewrite only the
|
||||
port (same hostname) and forward a correct `X-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-Host` wins 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**: `AllowedOrigin` already 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: preserved `Host` + public `Origin` reaches
|
||||
login; same-hostname port-rewrite + correct XFH passes and scopes to the
|
||||
public port; rewritten **non-loopback hostname** + public
|
||||
`X-Forwarded-Host` + public `Origin` is 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://host` and `http://host:80` agree in practice.
|
||||
- **IPv6 hosts.** `net.SplitHostPort` handles bracketed forms
|
||||
(`[::1]:8080`); unbracketed `::1` has no port and gets no suffix.
|
||||
- **Custom cookie names disable automatic port isolation.** A non-empty
|
||||
`auth.cookieName` is 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 is
|
||||
`KANDEV_AUTH_COOKIE_NAME` (explicit `BindEnv`; the automatic camelCase
|
||||
mapping would otherwise yield the undocumented `KANDEV_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_session` name 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 compares `Origin` to
|
||||
`Host` and ignores `X-Forwarded-Host`); the cookie-name resolver would
|
||||
derive the `X-Forwarded-Host` suffix 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
|
||||
existing `AllowedOrigin` design (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-only `layout:*` 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).
|
||||
@@ -225,8 +225,9 @@ against an external IdP (OIDC/SAML): its webhook (the callback / SAML ACS)
|
||||
validates the token, then asserts the identity to kandev via the reserved
|
||||
`X-Kandev-Auth-Login` response header (`{provider, subject, email,
|
||||
display_name}`). The host maps it to a user (link-by-email or just-in-time
|
||||
member provisioning), mints the session, and sets the `kandev_session` cookie
|
||||
itself — the plugin never receives the raw token. Requires auth enabled; new
|
||||
member provisioning), mints the session, and sets the session cookie (name
|
||||
derived from the request host) itself — the plugin never receives the raw
|
||||
token. Requires auth enabled; new
|
||||
users are members, and the host never creates an admin nor auto-links to an
|
||||
existing admin account. The plugin **must** only assert IdP-verified emails (an
|
||||
unverified email claim is an account-takeover vector the host cannot detect).
|
||||
|
||||
Reference in New Issue
Block a user