From 684165e3ae52c16f80088bd27e29d610fc4e4664 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 17 Aug 2026 20:01:02 -0500 Subject: [PATCH] fix(server): sweep dead dev session cookies at pairing Every loopback dev server minted a unique 30-day session cookie on localhost and nothing ever removed one, so a few dozen paired worktrees pushed request headers past Node's 16KB limit and Vite answered 431 for every dev server on the machine. Dev cookie names now encode the state directory instead of a hash. When a dev server pairs a browser, it expires sibling dev cookies whose state directory no longer exists on disk, plus legacy-hash cookies on its own port. Live parallel worktrees are untouched; hosted and desktop cookie names are unchanged. Built by GPT-5.6 Sol via Codex CLI from a Claude Fable 5 spec; reviewed and verified by Claude Fable 5 in Claude Code. --- apps/server/src/auth/http.ts | 62 +++++++++++++++++++++--- apps/server/src/auth/utils.test.ts | 75 ++++++++++++++++++++++++++--- apps/server/src/auth/utils.ts | 73 ++++++++++++++++++++++++---- apps/server/src/server.test.ts | 76 ++++++++++++++++++++++++++++++ docs/internals/environment-auth.md | 5 ++ 5 files changed, 269 insertions(+), 22 deletions(-) diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde251..c23298ba14d8 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -27,8 +27,10 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; import * as Cookies from "effect/unstable/http/Cookies"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; @@ -37,7 +39,12 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; -import { deriveAuthClientMetadata } from "./utils.ts"; +import * as ServerConfig from "../config.ts"; +import { + decodeDevelopmentSessionCookieName, + deriveAuthClientMetadata, + planStaleDevelopmentSessionCookieSweep, +} from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; const CREDENTIAL_RESPONSE_HEADERS = { @@ -203,6 +210,8 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; return handlers .handle( @@ -228,13 +237,52 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); + const requestCookieNames = Object.keys(request.cookies); + const cookieNamesToExpire = + serverConfig.devUrl === undefined + ? [] + : yield* Effect.gen(function* () { + const stateDirs = new Set( + requestCookieNames.flatMap((name) => { + const decoded = decodeDevelopmentSessionCookieName(name); + return decoded !== null && "stateDir" in decoded ? [decoded.stateDir] : []; + }), + ); + const stateDirExistence = new Map( + yield* Effect.all( + Array.from(stateDirs, (stateDir) => + fileSystem.exists(stateDir).pipe( + Effect.orElseSucceed(() => true), + Effect.map((exists) => [stateDir, exists] as const), + ), + ), + { concurrency: "unbounded" }, + ), + ); + return planStaleDevelopmentSessionCookieSweep({ + ownCookieName: sessions.cookieName, + ownPort: serverConfig.port, + requestCookieNames, + stateDirExists: (stateDir) => stateDirExistence.get(stateDir) ?? true, + }); + }); const sessionCookies = yield* Effect.fromResult( - Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { - expires: DateTime.toDate(result.response.expiresAt), - httpOnly: true, - path: "/", - sameSite: "lax", - }), + cookieNamesToExpire.reduce( + (cookies, name) => + Result.flatMap(cookies, (current) => + Cookies.expireCookie(current, name, { + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ), + Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { + expires: DateTime.toDate(result.response.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ), ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); yield* HttpEffect.appendPreResponseHandler((_request, response) => diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index edc58f71131f..3ad8fd7d9cb0 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { + base64UrlEncode, + decodeDevelopmentSessionCookieName, deriveAuthClientMetadata, isRemoteReachableHost, + planStaleDevelopmentSessionCookieSweep, resolveSessionCookieName, } from "./utils.ts"; @@ -58,24 +61,34 @@ describe("deriveAuthClientMetadata", () => { }); describe("session cookie isolation", () => { - it("isolates loopback web servers by port and server state", () => { + it("isolates loopback web servers by port and encoded server state", () => { + const firstStateDir = "/tmp/t3-agent-one"; + const secondStateDir = "/tmp/t3-agent-two"; const first = resolveSessionCookieName({ mode: "web", port: 5775, host: "127.0.0.1", - instanceKey: "/tmp/t3-agent-one", + instanceKey: firstStateDir, development: true, }); const second = resolveSessionCookieName({ mode: "web", port: 5775, host: "127.0.0.1", - instanceKey: "/tmp/t3-agent-two", + instanceKey: secondStateDir, development: true, }); - expect(first).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); - expect(second).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + expect(first).toBe(`t3_session_5775_${base64UrlEncode(firstStateDir)}`); + expect(second).toBe(`t3_session_5775_${base64UrlEncode(secondStateDir)}`); + expect(decodeDevelopmentSessionCookieName(first)).toEqual({ + port: 5775, + stateDir: firstStateDir, + }); + expect(decodeDevelopmentSessionCookieName(second)).toEqual({ + port: 5775, + stateDir: secondStateDir, + }); expect(first).not.toBe(second); }); @@ -113,15 +126,16 @@ describe("session cookie isolation", () => { }); it("isolates development servers even when they bind a wildcard host", () => { + const stateDir = "/tmp/t3-wildcard-dev"; expect( resolveSessionCookieName({ mode: "web", port: 5775, host: "0.0.0.0", - instanceKey: "/tmp/t3-wildcard-dev", + instanceKey: stateDir, development: true, }), - ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + ).toBe(`t3_session_5775_${base64UrlEncode(stateDir)}`); }); it("classifies loopback aliases separately from remotely reachable hosts", () => { @@ -133,3 +147,50 @@ describe("session cookie isolation", () => { expect(isRemoteReachableHost("192.168.1.50")).toBe(true); }); }); + +describe("development session cookie decoding", () => { + it("classifies encoded state directories, legacy hashes, and unrelated names", () => { + const stateDir = "/tmp/t3-agent-state"; + + expect( + decodeDevelopmentSessionCookieName(`t3_session_5775_${base64UrlEncode(stateDir)}`), + ).toEqual({ port: 5775, stateDir }); + expect(decodeDevelopmentSessionCookieName("t3_session_5775_0123456789ab")).toEqual({ + port: 5775, + legacyHash: "0123456789ab", + }); + expect(decodeDevelopmentSessionCookieName("t3_session")).toBeNull(); + expect(decodeDevelopmentSessionCookieName("t3_session_5775")).toBeNull(); + expect( + decodeDevelopmentSessionCookieName(`t3_session_5775_${base64UrlEncode("relative/state")}`), + ).toBeNull(); + expect(decodeDevelopmentSessionCookieName("other_5775_0123456789ab")).toBeNull(); + }); +}); + +describe("stale development session cookie sweep", () => { + it("expires only dead encoded siblings and same-port legacy cookies", () => { + const ownCookieName = `t3_session_5775_${base64UrlEncode("/tmp/own")}`; + const liveSibling = `t3_session_5776_${base64UrlEncode("/tmp/live")}`; + const staleSibling = `t3_session_5777_${base64UrlEncode("/tmp/stale")}`; + const samePortLegacy = "t3_session_5775_0123456789ab"; + const otherPortLegacy = "t3_session_5778_abcdef012345"; + + expect( + planStaleDevelopmentSessionCookieSweep({ + ownCookieName, + ownPort: 5775, + requestCookieNames: [ + ownCookieName, + liveSibling, + staleSibling, + samePortLegacy, + otherPortLegacy, + "t3_session", + "t3_session_3773", + ], + stateDirExists: (stateDir) => stateDir === "/tmp/live", + }), + ).toEqual([staleSibling, samePortLegacy]); + }); +}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 32a6799b01f4..fd6da64f6b88 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -22,6 +22,9 @@ const SESSION_COOKIE_NAME = "t3_session"; * - **Desktop**, which scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. * + * Dev names encode the state directory so a server can recognize and expire + * cookies belonging to worktrees whose state directories no longer exist. + * * Hosted deployments keep the stable production name: their public port can * change between releases, and scoping it would log every user out. */ @@ -40,14 +43,68 @@ export function resolveSessionCookieName(input: { return SESSION_COOKIE_NAME; } - // Cookies are scoped by host, not port. Loopback development servers need an - // instance-specific name or parallel agents overwrite each other's session, - // and a server that later reuses the port receives a token signed elsewhere. - const instanceHash = NodeCrypto.createHash("sha256") - .update(input.instanceKey) - .digest("hex") - .slice(0, 12); - return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; + return `${SESSION_COOKIE_NAME}_${input.port}_${base64UrlEncode(input.instanceKey)}`; +} + +export type DevelopmentSessionCookieName = + | { readonly port: number; readonly stateDir: string } + | { readonly port: number; readonly legacyHash: string }; + +export function decodeDevelopmentSessionCookieName( + name: string, +): DevelopmentSessionCookieName | null { + const match = /^t3_session_(\d+)_([A-Za-z0-9_-]+)$/.exec(name); + const port = Number(match?.[1]); + const suffix = match?.[2]; + if (!Number.isSafeInteger(port) || suffix === undefined) { + return null; + } + + if (/^[0-9a-f]{12}$/.test(suffix)) { + return { port, legacyHash: suffix }; + } + + const decoded = Encoding.decodeBase64UrlString(suffix); + if (Result.isFailure(decoded) || !isAbsolutePathLike(decoded.success)) { + return null; + } + return { port, stateDir: decoded.success }; +} + +// POSIX root, Windows drive, or UNC. Format only: the sweep decides existence. +function isAbsolutePathLike(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); +} + +/** + * Plans cookie expiry from already-observed directory existence. Encoding the + * path creates a localhost-only path-existence oracle for pages that craft + * cookie names and observe expiry. This is accepted for development servers, + * where such a page already runs code on the same machine. + */ +export function planStaleDevelopmentSessionCookieSweep(input: { + readonly ownCookieName: string; + readonly ownPort: number; + readonly requestCookieNames: Iterable; + readonly stateDirExists: (stateDir: string) => boolean; +}): ReadonlyArray { + const namesToExpire: Array = []; + for (const name of input.requestCookieNames) { + if (name === input.ownCookieName) { + continue; + } + const decoded = decodeDevelopmentSessionCookieName(name); + if (decoded === null) { + continue; + } + if ( + ("legacyHash" in decoded && decoded.port === input.ownPort) || + ("stateDir" in decoded && !input.stateDirExists(decoded.stateDir)) + ) { + namesToExpire.push(name); + } + } + return namesToExpire; } export function isRemoteReachableHost(host: string | undefined): boolean { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f339ff84fae9..b1a351d092ac 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -60,6 +60,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Cookies from "effect/unstable/http/Cookies"; import { FetchHttpClient, HttpBody, @@ -141,6 +142,7 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { resolveSessionCookieName } from "./auth/utils.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -1594,6 +1596,80 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("sweeps stale development session cookies when pairing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const liveStateDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-live-dev-cookie-", + }); + const staleStateDir = yield* fileSystem.makeTempDirectory({ + prefix: "t3-stale-dev-cookie-", + }); + yield* fileSystem.remove(staleStateDir, { recursive: true }); + + const config = yield* buildAppUnderTest({ + config: { + mode: "web", + port: 5775, + devUrl: new URL("http://127.0.0.1:5173"), + }, + }); + const ownCookieName = resolveSessionCookieName({ + mode: config.mode, + port: config.port, + host: config.host, + instanceKey: config.stateDir, + development: true, + }); + const staleCookieName = resolveSessionCookieName({ + mode: "web", + port: 5776, + host: "127.0.0.1", + instanceKey: staleStateDir, + development: true, + }); + const liveCookieName = resolveSessionCookieName({ + mode: "web", + port: 5777, + host: "127.0.0.1", + instanceKey: liveStateDir, + development: true, + }); + const legacyCookieName = `t3_session_${config.port}_0123456789ab`; + + const { response } = yield* bootstrapBrowserSession(defaultDesktopBootstrapToken, { + headers: { + cookie: [ + `${staleCookieName}=stale-token`, + `${liveCookieName}=live-token`, + `${legacyCookieName}=legacy-token`, + ].join("; "), + }, + }); + const setCookieHeaders = Cookies.toSetCookieHeaders(response.cookies); + const ownSetCookie = setCookieHeaders.find((header) => + header.startsWith(`${ownCookieName}=`), + ); + const staleSetCookie = setCookieHeaders.find((header) => + header.startsWith(`${staleCookieName}=`), + ); + const legacySetCookie = setCookieHeaders.find((header) => + header.startsWith(`${legacyCookieName}=`), + ); + + assert.equal(response.status, 200); + assert.isDefined(ownSetCookie); + assert.notInclude(ownSetCookie ?? "", "Max-Age=0"); + for (const expiredCookie of [staleSetCookie, legacySetCookie]) { + assert.isDefined(expiredCookie); + assert.include(expiredCookie ?? "", "Max-Age=0"); + assert.include(expiredCookie ?? "", "Path=/"); + assert.include(expiredCookie ?? "", "HttpOnly"); + } + assert.notInclude(setCookieHeaders.join("\n"), liveCookieName); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 5f4f5b6e9607..430cdd143921 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -37,6 +37,11 @@ browser session cookie. The cookie is an HTTP transport adapter for the same scoped session model; the response never exposes the session secret to browser JavaScript. +Loopback development servers use a per-instance cookie name containing the +server port and a base64url-encoded state directory, so parallel worktrees do +not overwrite each other's sessions. Pairing also expires cookies whose encoded +state directories no longer exist, while preserving live sibling worktrees. + ### Bearer Access Token Non-browser clients use `POST /oauth/token` with an