| 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,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"; |
| 44 | import * as SessionStore from "./SessionStore.ts"; | 44 | import * as SessionStore from "./SessionStore.ts"; |
| 45 | import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; | 45 | import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; |
| 46 | import { verifyRequestDpopProof } from "./dpop.ts"; | 46 | import { verifyRequestDpopProof } from "./dpop.ts"; |
| 47 | import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; | ||
| 47 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 48 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 48 | 49 | ||
| 49 | const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | 50 | const 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 | } |
| 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 | /** @public Service construction is part of the canonical Effect module API. */ | 732 | /** @public Service construction is part of the canonical Effect module API. */ |
| 691 | export const make = Effect.gen(function* () { | 733 | export 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 | ); |
| 728 | 770 | ||
| 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 !== undefined | 781 | (!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.token | 314 | ? 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"; |
| 49 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | 49 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 50 | import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts"; | 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 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; | 57 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 54 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 58 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 55 | const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; | ||
| 56 | const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; | 59 | const 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 an | 60 | // 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 are | 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,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. */ | ||
| 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,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 OAuth | 14 | * 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 client | 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 | export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new Set([ | 20 | export 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 | ]); |
| 23 | 26 | ||
| 24 | export function isDevProxiedPath(pathname: string): boolean { | 27 | export function isDevProxiedPath(pathname: string): boolean { |