authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 22:31:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 05:31:24+00:00
log48595c787cf5ffe3fcc089ce36754d7c601e4007
treec383827bb98f66d35813dc491598dc03700a0d53
parentabc80c80a73fdda78600184276da9e36cca89703
signature Signed by PGP key B5690EEEBB952194

fix(server): consolidate browser credential handling (#18509)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

6 files changed, 176 insertions(+), 18 deletions(-)

apps/server/src/auth/EnvironmentAuth.test.ts+84
...@@ -624,4 +624,88 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -624,4 +624,88 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
624 ),624 ),
625 ),625 ),
626 );626 );
627
628 it.effect("accepts cookie sessions on socket upgrades only from the app's own origins", () =>
629 Effect.gen(function* () {
630 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
631 const sessions = yield* SessionStore.SessionStore;
632 const pairing = yield* serverAuth.issuePairingCredential();
633 const exchanged = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata);
634 const upgrade = (headers: Record<string, string>) =>
635 serverAuth.authenticateWebSocketUpgrade({
636 url: "/ws",
637 cookies: { [sessions.cookieName]: exchanged.sessionToken },
638 headers: { host: "192.168.1.50:3773", ...headers },
639 } as never);
640
641 // Same host, a proxy-reported host, a trusted app origin, or no browser at all.
642 yield* upgrade({ origin: "http://192.168.1.50:3773" });
643 yield* upgrade({
644 origin: "https://box.tail1234.ts.net",
645 "x-forwarded-host": "box.tail1234.ts.net",
646 });
647 yield* upgrade({ origin: "t3code://app" });
648 yield* upgrade({ origin: "http://localhost:5733" });
649 yield* upgrade({});
650
651 // Another port on the same machine is the same site but not the app.
652 const otherPort = yield* Effect.flip(upgrade({ origin: "http://192.168.1.50:8080" }));
653 expect(otherPort._tag).toBe("ServerAuthInvalidCredentialError");
654 const opaque = yield* Effect.flip(upgrade({ origin: "null" }));
655 expect(opaque._tag).toBe("ServerAuthInvalidCredentialError");
656 }).pipe(
657 Effect.provide(
658 layerEnvironmentAuth({
659 mode: "web",
660 host: "192.168.1.50",
661 devUrl: new URL("http://localhost:5733"),
662 }),
663 ),
664 ),
665 );
666
667 it.effect("accepts cookie sessions on HTTP requests only from the app's own origins", () =>
668 Effect.gen(function* () {
669 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
670 const sessions = yield* SessionStore.SessionStore;
671 const pairing = yield* serverAuth.issuePairingCredential();
672 const exchanged = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata);
673 const post = (headers: Record<string, string>) =>
674 serverAuth.authenticateHttpRequest({
675 cookies: { [sessions.cookieName]: exchanged.sessionToken },
676 headers: { host: "localhost:13773", ...headers },
677 } as never);
678
679 // The dev proxy rewrites Host, so a LAN page relies on the browser's own verdict.
680 yield* post({ origin: "http://192.168.1.50:5733", "sec-fetch-site": "same-origin" });
681 yield* post({ origin: "http://localhost:13773" });
682
683 const otherPort = yield* Effect.flip(
684 post({ origin: "http://localhost:3000", "sec-fetch-site": "same-site" }),
685 );
686 expect(otherPort._tag).toBe("ServerAuthInvalidCredentialError");
687 const state = yield* serverAuth.getSessionState({
688 cookies: { [sessions.cookieName]: exchanged.sessionToken },
689 headers: { host: "localhost:13773", origin: "http://localhost:3000" },
690 } as never);
691 expect(state.authenticated).toBe(false);
692 }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web" }))),
693 );
694
695 it.effect("keeps bearer socket upgrades independent of the page origin", () =>
696 Effect.gen(function* () {
697 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
698 const bearer = yield* serverAuth.issueSession();
699 const verified = yield* serverAuth.authenticateWebSocketUpgrade({
700 url: "/ws",
701 cookies: {},
702 headers: {
703 host: "192.168.1.50:3773",
704 origin: "http://192.168.1.50:8080",
705 authorization: `Bearer ${bearer.token}`,
706 },
707 } as never);
708 expect(verified.sessionId).toBe(bearer.sessionId);
709 }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))),
710 );
627});711});
apps/server/src/auth/EnvironmentAuth.ts+78-13
...@@ -44,6 +44,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts";...@@ -44,6 +44,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts";
44import * as SessionStore from "./SessionStore.ts";44import * as SessionStore from "./SessionStore.ts";
45import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";45import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";
46import { verifyRequestDpopProof } from "./dpop.ts";46import { verifyRequestDpopProof } from "./dpop.ts";
47import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts";
47import * as SqlitePersistence from "../persistence/Sqlite.ts";48import * as SqlitePersistence from "../persistence/Sqlite.ts";
4849
49const DEFAULT_SESSION_SUBJECT = "cli-issued-session";50const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
...@@ -687,6 +688,47 @@ export function selectRequestCredential(...@@ -687,6 +688,47 @@ export function selectRequestCredential(
687 return undefined;688 return undefined;
688}689}
689690
691type RequestCredentialSource = NonNullable<ReturnType<typeof selectRequestCredential>>["source"];
692
693/** Whether a credential came from a browser cookie rather than an Authorization header. */
694export const isBrowserCookieCredential = (source: RequestCredentialSource | "dev-cookie") =>
695 source === "cookie" || source === "legacy-cookie" || source === "dev-cookie";
696
697function firstHeaderValue(value: string | undefined): string | undefined {
698 const first = value?.split(",")[0]?.trim().toLowerCase();
699 return first && first.length > 0 ? first : undefined;
700}
701
702/**
703 * Whether a cookie-carrying request comes from a page allowed to use it.
704 * Browsers send `Origin` on WebSocket upgrades, cross-origin requests, and
705 * non-GET requests, so an absent header means a same-origin read or a
706 * non-browser client. Otherwise the request must be same-origin by the
707 * browser's own `Sec-Fetch-Site` (which survives the dev proxy rewriting
708 * `Host`), come from this server's own host (directly or behind a proxy that
709 * reports `X-Forwarded-Host`), or come from one of the trusted app origins.
710 */
711function isBrowserOriginAllowed(
712 request: HttpServerRequest.HttpServerRequest,
713 trustedOrigins: ReadonlySet<string>,
714): boolean {
715 const origin = request.headers.origin;
716 if (origin === undefined) return true;
717 if (request.headers["sec-fetch-site"] === "same-origin") return true;
718 if (trustedOrigins.has(origin)) return true;
719 let originHost: string;
720 try {
721 originHost = new URL(origin).host.toLowerCase();
722 } catch {
723 return false;
724 }
725 if (originHost === "") return false;
726 return (
727 originHost === firstHeaderValue(request.headers.host) ||
728 originHost === firstHeaderValue(request.headers["x-forwarded-host"])
729 );
730}
731
690/** @public Service construction is part of the canonical Effect module API. */732/** @public Service construction is part of the canonical Effect module API. */
691export const make = Effect.gen(function* () {733export const make = Effect.gen(function* () {
692 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;734 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
...@@ -726,25 +768,49 @@ export const make = Effect.gen(function* () {...@@ -726,25 +768,49 @@ export const make = Effect.gen(function* () {
726 })),768 })),
727 );769 );
728770
729 const authenticateRequest = (771 const resolveRequestCredential = (request: HttpServerRequest.HttpServerRequest) => {
730 request: HttpServerRequest.HttpServerRequest,
731 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
732 const selectedCredential = selectRequestCredential(772 const selectedCredential = selectRequestCredential(
733 request,773 request,
734 sessions.cookieName,774 sessions.cookieName,
735 sessions.legacyCookieName,775 sessions.legacyCookieName,
736 );776 );
737 const dpopToken = parseDpopToken(request);
738 const hasAuthorization = request.headers.authorization !== undefined;777 const hasAuthorization = request.headers.authorization !== undefined;
739 const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined;778 const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined;
740 const credential =779 return (
741 selectedCredential ??780 selectedCredential ??
742 (!hasAuthorization && devCookieToken !== undefined781 (!hasAuthorization && devCookieToken !== undefined
743 ? { token: devCookieToken, source: "dev-cookie" as const }782 ? { token: devCookieToken, source: "dev-cookie" as const }
744 : undefined);783 : undefined)
784 );
785 };
786
787 // Pages the server or its dev setup serves itself. Other origins on the same
788 // site (another localhost port, a LAN IP's other ports) still get the cookie,
789 // so cookie sessions must not authenticate their requests or sockets.
790 const trustedBrowserOrigins = new Set<string>([
791 ...(config.devUrl ? [config.devUrl.origin] : []),
792 ...DESKTOP_RENDERER_ORIGINS,
793 ...config.devAllowedOrigins,
794 ]);
795
796 const authenticateRequest = (
797 request: HttpServerRequest.HttpServerRequest,
798 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
799 const dpopToken = parseDpopToken(request);
800 const credential = resolveRequestCredential(request);
745 if (!credential?.token) {801 if (!credential?.token) {
746 return Effect.fail(new ServerAuthMissingCredentialError({}));802 return Effect.fail(new ServerAuthMissingCredentialError({}));
747 }803 }
804 if (
805 isBrowserCookieCredential(credential.source) &&
806 !isBrowserOriginAllowed(request, trustedBrowserOrigins)
807 ) {
808 return Effect.fail(
809 new ServerAuthInvalidCredentialError({
810 diagnostic: "Browser sessions are only accepted from the app's own origin.",
811 }),
812 );
813 }
748 return authenticateToken(credential.token).pipe(814 return authenticateToken(credential.token).pipe(
749 Effect.flatMap((session) => {815 Effect.flatMap((session) => {
750 if (session.proofKeyThumbprint) {816 if (session.proofKeyThumbprint) {
...@@ -1248,14 +1314,13 @@ export const make = Effect.gen(function* () {...@@ -1248,14 +1314,13 @@ export const make = Effect.gen(function* () {
1248 const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = (1314 const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = (
1249 request,1315 request,
1250 ) => {1316 ) => {
1251 const token =1317 const credential = resolveRequestCredential(request);
1252 request.cookies[sessions.cookieName] ??1318 if (!credential || !isBrowserCookieCredential(credential.source)) {
1253 (sessions.legacyCookieName ? request.cookies[sessions.legacyCookieName] : undefined) ??1319 return Effect.fail(new ServerAuthMissingCredentialError({}));
1254 (devAuth ? request.cookies[devAuth.cookieName] : undefined);1320 }
1255 if (!token) return Effect.fail(new ServerAuthMissingCredentialError({}));1321 return authenticateRequest(request).pipe(
1256 return authenticateToken(token).pipe(
1257 Effect.filterOrFail(1322 Effect.filterOrFail(
1258 (session) => session.method === "browser-session-cookie" && !session.proofKeyThumbprint,1323 (session) => session.method === "browser-session-cookie",
1259 () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }),1324 () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }),
1260 ),1325 ),
1261 Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"),1326 Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"),
apps/server/src/auth/http.ts+2-2
...@@ -309,8 +309,8 @@ export const layer = HttpApiBuilder.group(...@@ -309,8 +309,8 @@ export const layer = HttpApiBuilder.group(
309 const result = yield* serverAuth.createBrowserSession(309 const result = yield* serverAuth.createBrowserSession(
310 args.payload.credential,310 args.payload.credential,
311 deriveAuthClientMetadata({ request }),311 deriveAuthClientMetadata({ request }),
312 previousCredential?.source === "cookie" ||312 previousCredential &&
313 previousCredential?.source === "legacy-cookie"313 EnvironmentAuth.isBrowserCookieCredential(previousCredential.source)
314 ? previousCredential.token314 ? previousCredential.token
315 : undefined,315 : undefined,
316 );316 );
apps/server/src/http.ts+5-2
...@@ -48,11 +48,14 @@ import {...@@ -48,11 +48,14 @@ import {
48} from "./auth/http.ts";48} from "./auth/http.ts";
49import * as ServerEnvironment from "./environment/ServerEnvironment.ts";49import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
50import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts";50import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts";
51import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";51import {
52 browserApiCorsAllowedHeaders,
53 browserApiCorsAllowedMethods,
54 DESKTOP_RENDERER_ORIGINS,
55} from "./httpCors.ts";
5256
53const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";57const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
54const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);58const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
55const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
56const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";59const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
57// HTML previews are agent output, not the app. The sandbox gives the document an60// HTML previews are agent output, not the app. The sandbox gives the document an
58// opaque origin: scripts run, but same-origin cookies, storage, and API calls are61// opaque origin: scripts run, but same-origin cookies, storage, and API calls are
apps/server/src/httpCors.ts+3
...@@ -9,3 +9,6 @@ export const browserApiCorsAllowedHeaders = [...@@ -9,3 +9,6 @@ export const browserApiCorsAllowedHeaders = [
9 "dpop",9 "dpop",
10 ORCHESTRATION_PROTOCOL_HEADER,10 ORCHESTRATION_PROTOCOL_HEADER,
11] as const;11] as const;
12
13/** Origins of the Electron renderer, packaged and dev. */
14export const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"] as const;
packages/shared/src/devProxy.ts+4-1
...@@ -13,12 +13,15 @@ export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws...@@ -13,12 +13,15 @@ export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws
13/**13/**
14 * Prefixes the proxy must forward with the browser's own Host. MCP OAuth14 * Prefixes the proxy must forward with the browser's own Host. MCP OAuth
15 * derives its issuer and resource URLs from the request, and a client15 * derives its issuer and resource URLs from the request, and a client
16 * rejects metadata naming a different origin than the one it fetched.16 * rejects metadata naming a different origin than the one it fetched. The
17 * server accepts a cookie session on `/ws` only when the page's Origin matches
18 * the Host it sees, so whatever origin the page was opened on keeps working.
17 */19 */
18export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new Set([20export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new Set([
19 "/oauth",21 "/oauth",
20 "/.well-known",22 "/.well-known",
21 "/mcp",23 "/mcp",
24 "/ws",
22]);25]);
2326
24export function isDevProxiedPath(pathname: string): boolean {27export function isDevProxiedPath(pathname: string): boolean {