feat(server): operator declaration for platform edge TLS termination on the Claude login guard (#11579)

## 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
This commit is contained in:
Devin Foley 2026-08-17 17:46:45 -07:00 committed by GitHub
parent 2ec984502a
commit 962e98b1be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 144 additions and 6 deletions

View File

@ -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;

View File

@ -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

View File

@ -329,6 +329,7 @@ interface AppHandle {
async function createApp(opts: {
deploymentMode?: "local_trusted" | "authenticated";
confidentialProxyAllowlist?: string[];
confidentialEdgeTlsTerminated?: boolean;
transport?: TransportHandle;
} = {}): Promise<AppHandle> {
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

View File

@ -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.

View File

@ -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" };
}