From 962e98b1bef31c9881d70ff4ed23b2cf6d5d2762 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 17 Aug 2026 17:46:45 -0700 Subject: [PATCH] feat(server): operator declaration for platform edge TLS termination on the Claude login guard (#11579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Claude local adapter supports a setup-token subscription login, and its confidential routes pass a fail-closed transport guard > - The guard accepts direct socket TLS, a local_trusted loopback peer, or an allowlisted proxy peer that forwards https — and deliberately never reads the global `TRUST_PROXY` > - On a managed platform the edge terminates TLS, the app socket is always plain HTTP, and the edge-proxy peer addresses are not stable or documented, so none of the three cases can hold > - Every login on such a deployment shows the clear-text transport warning although the user's connection is HTTPS, and the agent-scoped confidential routes fail closed entirely > - This pull request adds a dedicated operator declaration that the platform edge terminates TLS, as a fourth guard case > - The benefit is a correct transport decision on managed platforms with the default posture unchanged everywhere else ## Linked Issues or Issue Description No public GitHub issue covers this. The problem is described in-PR following the enhancement template. Related public PRs: [#11347](https://github.com/paperclipai/paperclip/pull/11347) added the new-agent login flow and the non-blocking transport advisory, and [#11286](https://github.com/paperclipai/paperclip/pull/11286) added the setup-token login and the guard with its `CLAUDE_LOGIN_TRUSTED_PROXIES` allowlist. **Subsystem affected** server/ — the confidential transport guard for the Claude setup-token login (`services/setup-token-session.ts`, `routes/agents.ts`, `app.ts`). **Current behavior** The guard allows a confidential response on direct socket TLS, on a `local_trusted` loopback peer, or when the immediate peer is on the dedicated `CLAUDE_LOGIN_TRUSTED_PROXIES` allowlist and forwards `https`. Behind a managed platform's TLS-terminating edge (Railway, Render, Fly, and similar), the app socket is plain HTTP and the edge-proxy peer addresses are not operator-visible or stable, so the allowlist cannot express them — IPv6 entries match by exact string only. The result: the login panel shows "This connection is not encrypted" for a connection that is HTTPS to the user, and the agent-scoped confidential routes return the fixed no-secret error. **Proposed behavior** `CLAUDE_LOGIN_EDGE_TLS_TERMINATED=true` is an explicit, single-purpose operator declaration that every client request reaches the server through the platform's TLS-terminating edge. Under the declaration the guard treats a request as confidential unless the edge itself labels the client hop as plain `http` in `X-Forwarded-Proto`. The declaration is never derived from the global `TRUST_PROXY` setting, which the guard still never reads. Without the declaration, nothing changes. **Reason and benefit** The guard's spoofing concern does not apply to this deployment shape: a client cannot pick its transport, because the platform admits HTTPS only, and the header the guard consults is set by the platform edge, not the client. A blanket warning that is always wrong teaches users to ignore it. The declaration keeps the strict default for every deployment that does not opt in, and it keeps the allowlist as the precise tool for operators who do know their proxy addresses. ## What Changed - `ConfidentialTransportConfig` gains optional `edgeTlsTerminated` (default false), documented as the operator declaration for platform edge TLS termination. - `evaluateConfidentialTransport` adds the declaration as a guard case: allowed unless the forwarded protocol's first hop is explicitly `http` (reason `edge_labeled_plain_http` then; `operator_edge_tls_termination` when allowed). - `assessConfidentialStartup` reports `edge_tls_termination_declared`, so the startup log shows why forwarded requests pass. - `app.ts` parses `CLAUDE_LOGIN_EDGE_TLS_TERMINATED` (truthy: `1/true/yes/on`) and passes it to the agent routes; the routes build the guard config from it. - The SR-7 operator-requirement comment on the setup-token routes documents the new variable next to the allowlist. - Tests: five new guard unit cases and a route case asserting the prompt and code responses carry no `transportAdvisory` under the declaration. ## Verification ```sh cd server npx tsc --noEmit # clean npx vitest run src/services/setup-token-session.test.ts \ src/routes/setup-token-route.test.ts \ src/__tests__/openapi-routes.test.ts # 3 files, 89 passed ``` The new "keeps failing closed when the declaration is absent" case pins the unchanged default posture. ## Risks The declaration is an operator statement the server cannot verify; an operator who sets it on a deployment whose edge does not terminate TLS re-labels plain-HTTP requests as confidential. This is the same trust class as `CLAUDE_LOGIN_TRUSTED_PROXIES` (a wrong allowlist entry has the same effect) and is opt-in, off by default, and scoped to the login routes only. The guard still fails closed when the edge explicitly labels a request `http`. No schema change, no API shape change — `transportAdvisory` was already nullable. ## Model Used Claude Fable 5 (`claude-fable-5`), extended thinking, with tool use and code execution — investigation, implementation, and tests. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- server/src/app.ts | 11 ++++ server/src/routes/agents.ts | 18 +++++-- server/src/routes/setup-token-route.test.ts | 33 ++++++++++++ .../src/services/setup-token-session.test.ts | 53 +++++++++++++++++++ server/src/services/setup-token-session.ts | 35 +++++++++++- 5 files changed, 144 insertions(+), 6 deletions(-) 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" }; }