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) => {
624624 ),
625625 ),
626626 );
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 );
627711});
apps/server/src/auth/EnvironmentAuth.ts+78-13
......@@ -44,6 +44,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts";
4444import * as SessionStore from "./SessionStore.ts";
4545import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";
4646import { verifyRequestDpopProof } from "./dpop.ts";
47import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts";
4748import * as SqlitePersistence from "../persistence/Sqlite.ts";
4849
4950const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
......@@ -687,6 +688,47 @@ export function selectRequestCredential(
687688 return undefined;
688689}
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
690732/** @public Service construction is part of the canonical Effect module API. */
691733export const make = Effect.gen(function* () {
692734 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
......@@ -726,25 +768,49 @@ export const make = Effect.gen(function* () {
726768 })),
727769 );
728770
729 const authenticateRequest = (
730 request: HttpServerRequest.HttpServerRequest,
731 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
771 const resolveRequestCredential = (request: HttpServerRequest.HttpServerRequest) => {
732772 const selectedCredential = selectRequestCredential(
733773 request,
734774 sessions.cookieName,
735775 sessions.legacyCookieName,
736776 );
737 const dpopToken = parseDpopToken(request);
738777 const hasAuthorization = request.headers.authorization !== undefined;
739778 const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined;
740 const credential =
779 return (
741780 selectedCredential ??
742781 (!hasAuthorization && devCookieToken !== undefined
743782 ? { 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);
745801 if (!credential?.token) {
746802 return Effect.fail(new ServerAuthMissingCredentialError({}));
747803 }
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 }
748814 return authenticateToken(credential.token).pipe(
749815 Effect.flatMap((session) => {
750816 if (session.proofKeyThumbprint) {
......@@ -1248,14 +1314,13 @@ export const make = Effect.gen(function* () {
12481314 const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = (
12491315 request,
12501316 ) => {
1251 const token =
1252 request.cookies[sessions.cookieName] ??
1253 (sessions.legacyCookieName ? request.cookies[sessions.legacyCookieName] : undefined) ??
1254 (devAuth ? request.cookies[devAuth.cookieName] : undefined);
1255 if (!token) return Effect.fail(new ServerAuthMissingCredentialError({}));
1256 return authenticateToken(token).pipe(
1317 const credential = resolveRequestCredential(request);
1318 if (!credential || !isBrowserCookieCredential(credential.source)) {
1319 return Effect.fail(new ServerAuthMissingCredentialError({}));
1320 }
1321 return authenticateRequest(request).pipe(
12571322 Effect.filterOrFail(
1258 (session) => session.method === "browser-session-cookie" && !session.proofKeyThumbprint,
1323 (session) => session.method === "browser-session-cookie",
12591324 () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }),
12601325 ),
12611326 Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"),
apps/server/src/auth/http.ts+2-2
......@@ -309,8 +309,8 @@ export const layer = HttpApiBuilder.group(
309309 const result = yield* serverAuth.createBrowserSession(
310310 args.payload.credential,
311311 deriveAuthClientMetadata({ request }),
312 previousCredential?.source === "cookie" ||
313 previousCredential?.source === "legacy-cookie"
312 previousCredential &&
313 EnvironmentAuth.isBrowserCookieCredential(previousCredential.source)
314314 ? previousCredential.token
315315 : undefined,
316316 );
apps/server/src/http.ts+5-2
......@@ -48,11 +48,14 @@ import {
4848} from "./auth/http.ts";
4949import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
5050import { 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
5357const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
5458const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
55const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
5659const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
5760// HTML previews are agent output, not the app. The sandbox gives the document an
5861// 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 = [
99 "dpop",
1010 ORCHESTRATION_PROTOCOL_HEADER,
1111] 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
1313/**
1414 * Prefixes the proxy must forward with the browser's own Host. MCP OAuth
1515 * 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.
1719 */
1820export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new Set([
1921 "/oauth",
2022 "/.well-known",
2123 "/mcp",
24 "/ws",
2225]);
2326
2427export function isDevProxiedPath(pathname: string): boolean {