diff --git a/server/src/app.ts b/server/src/app.ts index d9f2e6d833..90e51c4a41 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -435,6 +435,16 @@ export async function createApp( .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); + // The explicit operator declaration that a platform edge terminates TLS for + // every client request (SR-7). This complements the allowlist for managed + // platforms (Railway, Render, Fly, and the like) where the app socket is + // always plain HTTP and the edge-proxy peer addresses are not stable or + // documented, so `CLAUDE_LOGIN_TRUSTED_PROXIES` cannot express them. It is a + // dedicated, single-purpose setting; the guard still never reads the global + // `TRUST_PROXY` value. + const setupTokenLoginEdgeTlsTerminated = /^(1|true|yes|on)$/i.test( + (process.env.CLAUDE_LOGIN_EDGE_TLS_TERMINATED ?? "").trim(), + ); // Bind the production setup-token login transport. It carries the live lease // manager, the login-process factory over the sandbox pseudo-terminal, and the // durable cleanup store. The factory passes only the fixed command @@ -480,6 +490,7 @@ export async function createApp( pluginWorkerManager: workerManager, deploymentMode: opts.deploymentMode, confidentialProxyAllowlist: setupTokenLoginProxyAllowlist, + confidentialEdgeTlsTerminated: setupTokenLoginEdgeTlsTerminated, setupTokenLogin: setupTokenLoginTransport, onSetupTokenLoginService: (service) => { setupTokenLoginService = service; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 25d2e16071..a86780c6a0 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -234,6 +234,13 @@ export function agentRoutes( * guard; only a peer on this explicit allowlist may forward a TLS protocol. */ confidentialProxyAllowlist?: string[]; + /** + * The explicit operator declaration that a platform edge terminates TLS for + * every client request (SR-7). Set from `CLAUDE_LOGIN_EDGE_TLS_TERMINATED`. + * Use it on a managed PaaS where the app socket is always plain HTTP and + * the edge-proxy peer addresses cannot be allowlisted. + */ + confidentialEdgeTlsTerminated?: boolean; /** * Receives the setup-token login session service once the router builds it. * The caller registers the startup reaper and the graceful-shutdown cleanup. @@ -325,6 +332,7 @@ export function agentRoutes( const setupTokenConfidentialConfig: ConfidentialTransportConfig = { deploymentMode: options.deploymentMode ?? "local_trusted", trustedProxies: options.confidentialProxyAllowlist ?? [], + edgeTlsTerminated: options.confidentialEdgeTlsTerminated ?? false, }; // Rate-limit the start route: a small window per company and owner (SR-4). @@ -4429,10 +4437,12 @@ export function agentRoutes( // // Operator requirement (SR-7): to serve the confidential responses behind a // TLS-terminating reverse proxy, set `CLAUDE_LOGIN_TRUSTED_PROXIES` to the - // explicit proxy IP or CIDR allowlist. The global `TRUST_PROXY` setting, - // including `TRUST_PROXY=true` and a hop-count value, does not satisfy the - // guard. A direct TLS request is always valid; a non-TLS request is valid only - // on a loopback peer in the `local_trusted` deployment mode. + // explicit proxy IP or CIDR allowlist — or, on a managed platform whose edge + // always terminates TLS and whose proxy peer addresses cannot be allowlisted, + // declare `CLAUDE_LOGIN_EDGE_TLS_TERMINATED=true`. The global `TRUST_PROXY` + // setting, including `TRUST_PROXY=true` and a hop-count value, does not + // satisfy the guard. A direct TLS request is always valid; a non-TLS request + // is valid only on a loopback peer in the `local_trusted` deployment mode. // // Each route below writes its full path as a plain string literal. The static // OpenAPI coverage test reads the route paths from the source text; it does diff --git a/server/src/routes/setup-token-route.test.ts b/server/src/routes/setup-token-route.test.ts index 3a9e3fac98..4b0ca17b9a 100644 --- a/server/src/routes/setup-token-route.test.ts +++ b/server/src/routes/setup-token-route.test.ts @@ -329,6 +329,7 @@ interface AppHandle { async function createApp(opts: { deploymentMode?: "local_trusted" | "authenticated"; confidentialProxyAllowlist?: string[]; + confidentialEdgeTlsTerminated?: boolean; transport?: TransportHandle; } = {}): Promise { const [{ agentRoutes }, { errorHandler }, pinoModule, pinoHttpModule, redactModule] = @@ -401,6 +402,7 @@ async function createApp(opts: { agentRoutes({} as never, { deploymentMode: opts.deploymentMode, confidentialProxyAllowlist: opts.confidentialProxyAllowlist, + confidentialEdgeTlsTerminated: opts.confidentialEdgeTlsTerminated, setupTokenLogin: opts.transport ? { factory: opts.transport.factory, @@ -905,6 +907,37 @@ describe("company-and-environment setup-token route — advisory transport", () expect(transport.submittedCodes).toEqual([BROWSER_CODE]); expectNoSecret(JSON.stringify(codeRes.body)); }); + + it("attaches no advisory when the operator declares platform edge TLS termination", async () => { + // A managed-platform deployment: TLS terminates at the platform edge, the + // app socket is plain HTTP, and the operator set + // CLAUDE_LOGIN_EDGE_TLS_TERMINATED. The prompt and code responses carry no + // advisory, so the client shows no clear-text warning for a connection that + // is HTTPS to the user. + const transport = buildTransport({ onSubmit: "complete" }); + const { app } = await createApp({ + transport, + deploymentMode: "authenticated", + confidentialProxyAllowlist: [], + confidentialEdgeTlsTerminated: true, + }); + + const startRes = await startCompanySession(app); + const sessionId = startRes.body.sessionId as string; + + const promptRes = await request(app).get(`${COMPANY_BASE}/${sessionId}/prompt`).send(); + expect(promptRes.status).toBe(200); + expect(promptRes.body.authorizationUrl).toBe(FULL_LOGIN_URL); + expect(promptRes.body.transportAdvisory).toBeNull(); + + const codeRes = await request(app) + .post(`${COMPANY_BASE}/${sessionId}/code`) + .send({ browserCode: BROWSER_CODE }); + expect(codeRes.status).toBe(200); + expect(codeRes.body.transportAdvisory).toBeNull(); + expect(transport.submittedCodes).toEqual([BROWSER_CODE]); + expectNoSecret(JSON.stringify(codeRes.body)); + }); }); // The stored-token status route and the overwrite capture are the two deltas of diff --git a/server/src/services/setup-token-session.test.ts b/server/src/services/setup-token-session.test.ts index 10d9fac3c0..03b0448d04 100644 --- a/server/src/services/setup-token-session.test.ts +++ b/server/src/services/setup-token-session.test.ts @@ -1030,6 +1030,59 @@ describe("confidential transport guard (SR-6, SR-7)", () => { expect(assessConfidentialStartup(authenticatedWithProxy).proxyForwardingEnabled).toBe(true); }); + it("allows a forwarded request under the operator edge-TLS declaration (SR-7)", () => { + const declared = { ...authenticatedNoProxy, edgeTlsTerminated: true }; + // The platform edge labels the client hop https. + expect( + evaluateConfidentialTransport(declared, { + socketEncrypted: false, + remoteAddress: "203.0.113.7", + forwardedProto: "https", + }).allowed, + ).toBe(true); + // A platform edge that strips or never sets the header still counts: the + // declaration asserts TLS for every request the platform admits. + expect( + evaluateConfidentialTransport(declared, { + socketEncrypted: false, + remoteAddress: "203.0.113.7", + forwardedProto: undefined, + }).allowed, + ).toBe(true); + }); + + it("still denies a request the edge itself labels plain http under the declaration", () => { + const declared = { ...authenticatedNoProxy, edgeTlsTerminated: true }; + const decision = evaluateConfidentialTransport(declared, { + socketEncrypted: false, + remoteAddress: "203.0.113.7", + forwardedProto: "http", + }); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("edge_labeled_plain_http"); + }); + + it("keeps failing closed when the declaration is absent, so the default is unchanged", () => { + // The same request that the declaration admits fails closed without it — + // this pins that adding the option does not loosen the default posture. + expect( + evaluateConfidentialTransport(authenticatedNoProxy, { + socketEncrypted: false, + remoteAddress: "203.0.113.7", + forwardedProto: "https", + }).allowed, + ).toBe(false); + }); + + it("reports the edge-TLS declaration in the startup assessment", () => { + const assessment = assessConfidentialStartup({ + ...authenticatedNoProxy, + edgeTlsTerminated: true, + }); + expect(assessment.proxyForwardingEnabled).toBe(true); + expect(assessment.reason).toBe("edge_tls_termination_declared"); + }); + it("denies a direct non-loopback HTTP receive-token request, so it delivers no token (SR-6)", () => { // The route calls this guard before receive-token. A denied decision makes // the route return the fixed no-secret error and never read the token. diff --git a/server/src/services/setup-token-session.ts b/server/src/services/setup-token-session.ts index 617d02d295..796a37d740 100644 --- a/server/src/services/setup-token-session.ts +++ b/server/src/services/setup-token-session.ts @@ -343,6 +343,18 @@ export function toSanitizedLoginUrl(rawUrl: string): string { export interface ConfidentialTransportConfig { deploymentMode: "local_trusted" | "authenticated"; trustedProxies: string[]; + /** + * The explicit operator declaration that every client request reaches this + * server through a platform edge that terminates TLS (a managed PaaS such as + * Railway, Render, or Fly, where the app socket is always plain HTTP and the + * edge-proxy peer addresses are not operator-visible, so `trustedProxies` + * cannot express them). Unlike the global `TRUST_PROXY` setting, which the + * guard deliberately never reads (SR-7), this is a dedicated, single-purpose + * statement about the confidential login routes only. When declared, a + * request is confidential unless the edge itself labels the client hop as + * plain `http` in `X-Forwarded-Proto`. Defaults to false. + */ + edgeTlsTerminated?: boolean; } /** The per-request transport signals the guard reads from the raw socket. */ @@ -438,7 +450,12 @@ function forwardedProtoFirstHop(forwardedProto: string | undefined): string | nu * 1. The immediate socket is TLS. A direct TLS request is always valid (SR-6). * 2. The deployment is `local_trusted` and the peer is loopback. This is the * only local exception (SR-6). - * 3. The peer is on the dedicated proxy allowlist and the forwarded protocol's + * 3. The operator declared platform edge TLS termination + * (`edgeTlsTerminated`) and the edge does not label the client hop as + * plain `http`. The declaration is a deliberate, single-purpose operator + * statement about these routes; it is never derived from `TRUST_PROXY` + * (SR-7). + * 4. The peer is on the dedicated proxy allowlist and the forwarded protocol's * first hop is `https`. A `TRUST_PROXY=true` or hop-count value does not * reach this branch, because the guard never reads it (SR-7). * @@ -455,6 +472,16 @@ export function evaluateConfidentialTransport( if (config.deploymentMode === "local_trusted" && isLoopbackAddress(request.remoteAddress)) { return { allowed: true, reason: "local_trusted_loopback" }; } + if (config.edgeTlsTerminated === true) { + // The declaration asserts the client hop is TLS for every request the + // platform admits. Believe the edge when it explicitly says otherwise: a + // first-hop `http` label means the platform accepted a plain-HTTP client + // connection, so that request still fails closed. + if (forwardedProtoFirstHop(request.forwardedProto) !== "http") { + return { allowed: true, reason: "operator_edge_tls_termination" }; + } + return { allowed: false, reason: "edge_labeled_plain_http" }; + } if ( config.trustedProxies.length > 0 && peerMatchesAllowlist(request.remoteAddress, config.trustedProxies) && @@ -467,7 +494,8 @@ export function evaluateConfidentialTransport( /** * Assesses the confidential transport at startup (SR-7). The server disables - * proxy-forwarded confidential responses when the dedicated allowlist is empty. + * proxy-forwarded confidential responses when the dedicated allowlist is empty + * and the operator has not declared platform edge TLS termination. * A direct TLS request and a `local_trusted` loopback request still pass at * runtime, because the runtime guard checks them first. The server logs the * returned reason so an operator can see why forwarded requests fail closed. @@ -476,6 +504,9 @@ export function assessConfidentialStartup(config: ConfidentialTransportConfig): proxyForwardingEnabled: boolean; reason: string; } { + if (config.edgeTlsTerminated === true) { + return { proxyForwardingEnabled: true, reason: "edge_tls_termination_declared" }; + } if (config.trustedProxies.length > 0) { return { proxyForwardingEnabled: true, reason: "proxy_allowlist_configured" }; }