| author | |
| committer | |
| log | 48595c787cf5ffe3fcc089ce36754d7c601e4007 |
| tree | c383827bb98f66d35813dc491598dc03700a0d53 |
| parent | abc80c80a73fdda78600184276da9e36cca89703 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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 | 44 | import * as SessionStore from "./SessionStore.ts"; |
| 45 | 45 | import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; |
| 46 | 46 | import { verifyRequestDpopProof } from "./dpop.ts"; |
| 47 | import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; | |
| 47 | 48 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 48 | 49 | |
| 49 | 50 | const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; |
| ... | ... | @@ -687,6 +688,47 @@ export function selectRequestCredential( |
| 687 | 688 | return undefined; |
| 688 | 689 | } |
| 689 | 690 | |
| 691 | type RequestCredentialSource = NonNullable<ReturnType<typeof selectRequestCredential>>["source"]; | |
| 692 | ||
| 693 | /** Whether a credential came from a browser cookie rather than an Authorization header. */ | |
| 694 | export const isBrowserCookieCredential = (source: RequestCredentialSource | "dev-cookie") => | |
| 695 | source === "cookie" || source === "legacy-cookie" || source === "dev-cookie"; | |
| 696 | ||
| 697 | function 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 | */ | |
| 711 | function 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 | 732 | /** @public Service construction is part of the canonical Effect module API. */ |
| 691 | 733 | export const make = Effect.gen(function* () { |
| 692 | 734 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| ... | ... | @@ -726,25 +768,49 @@ export const make = Effect.gen(function* () { |
| 726 | 768 | })), |
| 727 | 769 | ); |
| 728 | 770 | |
| 729 | const authenticateRequest = ( | |
| 730 | request: HttpServerRequest.HttpServerRequest, | |
| 731 | ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => { | |
| 771 | const resolveRequestCredential = (request: HttpServerRequest.HttpServerRequest) => { | |
| 732 | 772 | const selectedCredential = selectRequestCredential( |
| 733 | 773 | request, |
| 734 | 774 | sessions.cookieName, |
| 735 | 775 | sessions.legacyCookieName, |
| 736 | 776 | ); |
| 737 | const dpopToken = parseDpopToken(request); | |
| 738 | 777 | const hasAuthorization = request.headers.authorization !== undefined; |
| 739 | 778 | const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined; |
| 740 | const credential = | |
| 779 | return ( | |
| 741 | 780 | selectedCredential ?? |
| 742 | 781 | (!hasAuthorization && devCookieToken !== undefined |
| 743 | 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 | 801 | if (!credential?.token) { |
| 746 | 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 | 814 | return authenticateToken(credential.token).pipe( |
| 749 | 815 | Effect.flatMap((session) => { |
| 750 | 816 | if (session.proofKeyThumbprint) { |
| ... | ... | @@ -1248,14 +1314,13 @@ export const make = Effect.gen(function* () { |
| 1248 | 1314 | const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = ( |
| 1249 | 1315 | request, |
| 1250 | 1316 | ) => { |
| 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( | |
| 1257 | 1322 | Effect.filterOrFail( |
| 1258 | (session) => session.method === "browser-session-cookie" && !session.proofKeyThumbprint, | |
| 1323 | (session) => session.method === "browser-session-cookie", | |
| 1259 | 1324 | () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }), |
| 1260 | 1325 | ), |
| 1261 | 1326 | Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"), |
apps/server/src/auth/http.ts+2-2| ... | ... | @@ -309,8 +309,8 @@ export const layer = HttpApiBuilder.group( |
| 309 | 309 | const result = yield* serverAuth.createBrowserSession( |
| 310 | 310 | args.payload.credential, |
| 311 | 311 | deriveAuthClientMetadata({ request }), |
| 312 | previousCredential?.source === "cookie" || | |
| 313 | previousCredential?.source === "legacy-cookie" | |
| 312 | previousCredential && | |
| 313 | EnvironmentAuth.isBrowserCookieCredential(previousCredential.source) | |
| 314 | 314 | ? previousCredential.token |
| 315 | 315 | : undefined, |
| 316 | 316 | ); |
apps/server/src/http.ts+5-2| ... | ... | @@ -48,11 +48,14 @@ import { |
| 48 | 48 | } from "./auth/http.ts"; |
| 49 | 49 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 50 | 50 | import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts"; |
| 51 | import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; | |
| 51 | import { | |
| 52 | browserApiCorsAllowedHeaders, | |
| 53 | browserApiCorsAllowedMethods, | |
| 54 | DESKTOP_RENDERER_ORIGINS, | |
| 55 | } from "./httpCors.ts"; | |
| 52 | 56 | |
| 53 | 57 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 54 | 58 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 55 | const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; | |
| 56 | 59 | const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; |
| 57 | 60 | // HTML previews are agent output, not the app. The sandbox gives the document an |
| 58 | 61 | // 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 | 9 | "dpop", |
| 10 | 10 | ORCHESTRATION_PROTOCOL_HEADER, |
| 11 | 11 | ] as const; |
| 12 | ||
| 13 | /** Origins of the Electron renderer, packaged and dev. */ | |
| 14 | export 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 | 13 | /** |
| 14 | 14 | * Prefixes the proxy must forward with the browser's own Host. MCP OAuth |
| 15 | 15 | * 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 | */ |
| 18 | 20 | export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new Set([ |
| 19 | 21 | "/oauth", |
| 20 | 22 | "/.well-known", |
| 21 | 23 | "/mcp", |
| 24 | "/ws", | |
| 22 | 25 | ]); |
| 23 | 26 | |
| 24 | 27 | export function isDevProxiedPath(pathname: string): boolean { |