diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index a85db0fd14923a4c0803eb0b9694e3cace684b51..c25b35a61cc09e4b307b3d608ca101cf735a51cb 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -624,4 +624,88 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ), ), ); + + it.effect("accepts cookie sessions on socket upgrades only from the app's own origins", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const pairing = yield* serverAuth.issuePairingCredential(); + const exchanged = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata); + const upgrade = (headers: Record) => + serverAuth.authenticateWebSocketUpgrade({ + url: "/ws", + cookies: { [sessions.cookieName]: exchanged.sessionToken }, + headers: { host: "192.168.1.50:3773", ...headers }, + } as never); + + // Same host, a proxy-reported host, a trusted app origin, or no browser at all. + yield* upgrade({ origin: "http://192.168.1.50:3773" }); + yield* upgrade({ + origin: "https://box.tail1234.ts.net", + "x-forwarded-host": "box.tail1234.ts.net", + }); + yield* upgrade({ origin: "t3code://app" }); + yield* upgrade({ origin: "http://localhost:5733" }); + yield* upgrade({}); + + // Another port on the same machine is the same site but not the app. + const otherPort = yield* Effect.flip(upgrade({ origin: "http://192.168.1.50:8080" })); + expect(otherPort._tag).toBe("ServerAuthInvalidCredentialError"); + const opaque = yield* Effect.flip(upgrade({ origin: "null" })); + expect(opaque._tag).toBe("ServerAuthInvalidCredentialError"); + }).pipe( + Effect.provide( + layerEnvironmentAuth({ + mode: "web", + host: "192.168.1.50", + devUrl: new URL("http://localhost:5733"), + }), + ), + ), + ); + + it.effect("accepts cookie sessions on HTTP requests only from the app's own origins", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const pairing = yield* serverAuth.issuePairingCredential(); + const exchanged = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata); + const post = (headers: Record) => + serverAuth.authenticateHttpRequest({ + cookies: { [sessions.cookieName]: exchanged.sessionToken }, + headers: { host: "localhost:13773", ...headers }, + } as never); + + // The dev proxy rewrites Host, so a LAN page relies on the browser's own verdict. + yield* post({ origin: "http://192.168.1.50:5733", "sec-fetch-site": "same-origin" }); + yield* post({ origin: "http://localhost:13773" }); + + const otherPort = yield* Effect.flip( + post({ origin: "http://localhost:3000", "sec-fetch-site": "same-site" }), + ); + expect(otherPort._tag).toBe("ServerAuthInvalidCredentialError"); + const state = yield* serverAuth.getSessionState({ + cookies: { [sessions.cookieName]: exchanged.sessionToken }, + headers: { host: "localhost:13773", origin: "http://localhost:3000" }, + } as never); + expect(state.authenticated).toBe(false); + }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web" }))), + ); + + it.effect("keeps bearer socket upgrades independent of the page origin", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const bearer = yield* serverAuth.issueSession(); + const verified = yield* serverAuth.authenticateWebSocketUpgrade({ + url: "/ws", + cookies: {}, + headers: { + host: "192.168.1.50:3773", + origin: "http://192.168.1.50:8080", + authorization: `Bearer ${bearer.token}`, + }, + } as never); + expect(verified.sessionId).toBe(bearer.sessionId); + }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))), + ); }); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index d66cdbb62c3650f0fd3317daa93b8915773d941a..8a59f22fca9f9bada6443dbd0c57ba1a845f7d7e 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -44,6 +44,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; +import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; @@ -687,6 +688,47 @@ export function selectRequestCredential( return undefined; } +type RequestCredentialSource = NonNullable>["source"]; + +/** Whether a credential came from a browser cookie rather than an Authorization header. */ +export const isBrowserCookieCredential = (source: RequestCredentialSource | "dev-cookie") => + source === "cookie" || source === "legacy-cookie" || source === "dev-cookie"; + +function firstHeaderValue(value: string | undefined): string | undefined { + const first = value?.split(",")[0]?.trim().toLowerCase(); + return first && first.length > 0 ? first : undefined; +} + +/** + * Whether a cookie-carrying request comes from a page allowed to use it. + * Browsers send `Origin` on WebSocket upgrades, cross-origin requests, and + * non-GET requests, so an absent header means a same-origin read or a + * non-browser client. Otherwise the request must be same-origin by the + * browser's own `Sec-Fetch-Site` (which survives the dev proxy rewriting + * `Host`), come from this server's own host (directly or behind a proxy that + * reports `X-Forwarded-Host`), or come from one of the trusted app origins. + */ +function isBrowserOriginAllowed( + request: HttpServerRequest.HttpServerRequest, + trustedOrigins: ReadonlySet, +): boolean { + const origin = request.headers.origin; + if (origin === undefined) return true; + if (request.headers["sec-fetch-site"] === "same-origin") return true; + if (trustedOrigins.has(origin)) return true; + let originHost: string; + try { + originHost = new URL(origin).host.toLowerCase(); + } catch { + return false; + } + if (originHost === "") return false; + return ( + originHost === firstHeaderValue(request.headers.host) || + originHost === firstHeaderValue(request.headers["x-forwarded-host"]) + ); +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; @@ -726,25 +768,49 @@ export const make = Effect.gen(function* () { })), ); - const authenticateRequest = ( - request: HttpServerRequest.HttpServerRequest, - ): Effect.Effect => { + const resolveRequestCredential = (request: HttpServerRequest.HttpServerRequest) => { const selectedCredential = selectRequestCredential( request, sessions.cookieName, sessions.legacyCookieName, ); - const dpopToken = parseDpopToken(request); const hasAuthorization = request.headers.authorization !== undefined; const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined; - const credential = + return ( selectedCredential ?? (!hasAuthorization && devCookieToken !== undefined ? { token: devCookieToken, source: "dev-cookie" as const } - : undefined); + : undefined) + ); + }; + + // Pages the server or its dev setup serves itself. Other origins on the same + // site (another localhost port, a LAN IP's other ports) still get the cookie, + // so cookie sessions must not authenticate their requests or sockets. + const trustedBrowserOrigins = new Set([ + ...(config.devUrl ? [config.devUrl.origin] : []), + ...DESKTOP_RENDERER_ORIGINS, + ...config.devAllowedOrigins, + ]); + + const authenticateRequest = ( + request: HttpServerRequest.HttpServerRequest, + ): Effect.Effect => { + const dpopToken = parseDpopToken(request); + const credential = resolveRequestCredential(request); if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } + if ( + isBrowserCookieCredential(credential.source) && + !isBrowserOriginAllowed(request, trustedBrowserOrigins) + ) { + return Effect.fail( + new ServerAuthInvalidCredentialError({ + diagnostic: "Browser sessions are only accepted from the app's own origin.", + }), + ); + } return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { @@ -1248,14 +1314,13 @@ export const make = Effect.gen(function* () { const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = ( request, ) => { - const token = - request.cookies[sessions.cookieName] ?? - (sessions.legacyCookieName ? request.cookies[sessions.legacyCookieName] : undefined) ?? - (devAuth ? request.cookies[devAuth.cookieName] : undefined); - if (!token) return Effect.fail(new ServerAuthMissingCredentialError({})); - return authenticateToken(token).pipe( + const credential = resolveRequestCredential(request); + if (!credential || !isBrowserCookieCredential(credential.source)) { + return Effect.fail(new ServerAuthMissingCredentialError({})); + } + return authenticateRequest(request).pipe( Effect.filterOrFail( - (session) => session.method === "browser-session-cookie" && !session.proofKeyThumbprint, + (session) => session.method === "browser-session-cookie", () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }), ), Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"), diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index c0d2946813f914de325359b83c4a33cc444cabcc..40dee5b6bcfd5e09e28d12726cb0d0cce03474da 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -309,8 +309,8 @@ export const layer = HttpApiBuilder.group( const result = yield* serverAuth.createBrowserSession( args.payload.credential, deriveAuthClientMetadata({ request }), - previousCredential?.source === "cookie" || - previousCredential?.source === "legacy-cookie" + previousCredential && + EnvironmentAuth.isBrowserCookieCredential(previousCredential.source) ? previousCredential.token : undefined, ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 13f54ef352998efc9c9dda091c1304cb120f3b0b..60a5ae22972ea651872c72ddadeb73071a5e4c96 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -48,11 +48,14 @@ import { } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts"; -import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; +import { + browserApiCorsAllowedHeaders, + browserApiCorsAllowedMethods, + DESKTOP_RENDERER_ORIGINS, +} from "./httpCors.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); -const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; // HTML previews are agent output, not the app. The sandbox gives the document an // opaque origin: scripts run, but same-origin cookies, storage, and API calls are diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts index 8fe7c226485552995ab28ef342b4d23e2fed0fc3..a6c702979efa25fe0d49224e54438b6ed0695d74 100644 --- a/apps/server/src/httpCors.ts +++ b/apps/server/src/httpCors.ts @@ -9,3 +9,6 @@ export const browserApiCorsAllowedHeaders = [ "dpop", ORCHESTRATION_PROTOCOL_HEADER, ] as const; + +/** Origins of the Electron renderer, packaged and dev. */ +export const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"] as const; diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts index e7e487c9f1b65c4f033296511714ee5428216183..709a5449c22e989d2e6211b44a8f870e10480272 100644 --- a/packages/shared/src/devProxy.ts +++ b/packages/shared/src/devProxy.ts @@ -13,12 +13,15 @@ export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws /** * Prefixes the proxy must forward with the browser's own Host. MCP OAuth * derives its issuer and resource URLs from the request, and a client - * rejects metadata naming a different origin than the one it fetched. + * rejects metadata naming a different origin than the one it fetched. The + * server accepts a cookie session on `/ws` only when the page's Origin matches + * the Host it sees, so whatever origin the page was opened on keeps working. */ export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet = new Set([ "/oauth", "/.well-known", "/mcp", + "/ws", ]); export function isDevProxiedPath(pathname: string): boolean {