From 7d6ec9fbf30adf3d1d4e237639fec56d6dead2d7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 9 Aug 2026 17:10:17 +0000 Subject: [PATCH] fix(runner): support direct tailnet HTTP demo Co-Authored-By: Paperclip --- .../scripts/run-phase7i-demo.sh | 3 + .../src/phase7/phase7i-demo-server.test.ts | 29 +++++++ .../src/phase7/phase7i-demo-server.ts | 64 +++++++++++++--- .../src/phase7/phase7i-loopback-proxy.ts | 56 +++----------- .../src/phase7/phase7i-proxy-config.test.ts | 65 ++++++++++++++++ .../src/phase7/phase7i-proxy-config.ts | 75 +++++++++++++++++++ 6 files changed, 234 insertions(+), 58 deletions(-) create mode 100644 packages/paperclip-runner/src/phase7/phase7i-proxy-config.test.ts create mode 100644 packages/paperclip-runner/src/phase7/phase7i-proxy-config.ts diff --git a/packages/paperclip-runner/scripts/run-phase7i-demo.sh b/packages/paperclip-runner/scripts/run-phase7i-demo.sh index d4f75b79ae9..9a056eff529 100755 --- a/packages/paperclip-runner/scripts/run-phase7i-demo.sh +++ b/packages/paperclip-runner/scripts/run-phase7i-demo.sh @@ -42,6 +42,7 @@ unshare -Un \ PHASE7I_PUBLIC_ORIGIN="$PHASE7I_PUBLIC_ORIGIN" \ PHASE7I_BASE_PATH="${PHASE7I_BASE_PATH:-}" \ PHASE7I_COMMIT_SHA="$PHASE7I_COMMIT_SHA" \ + PHASE7I_ALLOW_INSECURE_HTTP="${PHASE7I_ALLOW_INSECURE_HTTP:-}" \ PHASE7I_SOCKET_PATH="$socket_path" \ node \ --max-old-space-size=256 \ @@ -71,6 +72,8 @@ env -i \ NODE_ENV=production \ PHASE7I_PUBLIC_ORIGIN="$PHASE7I_PUBLIC_ORIGIN" \ PHASE7I_PROXY_PORT="$PHASE7I_PROXY_PORT" \ + PHASE7I_PROXY_HOST="${PHASE7I_PROXY_HOST:-127.0.0.1}" \ + PHASE7I_ALLOW_INSECURE_HTTP="${PHASE7I_ALLOW_INSECURE_HTTP:-}" \ PHASE7I_SOCKET_PATH="$socket_path" \ node \ --max-old-space-size=64 \ diff --git a/packages/paperclip-runner/src/phase7/phase7i-demo-server.test.ts b/packages/paperclip-runner/src/phase7/phase7i-demo-server.test.ts index 8f9e3032cec..01e38c754be 100644 --- a/packages/paperclip-runner/src/phase7/phase7i-demo-server.test.ts +++ b/packages/paperclip-runner/src/phase7/phase7i-demo-server.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { assertPhase7iEnvironmentSafe, forbiddenPhase7iEnvironmentNames, + phase7iCapabilityCookieForOrigin, Phase7iDemoRuntime, } from "./phase7i-demo-server.js"; @@ -36,6 +37,34 @@ describe("Phase 7I deployment boundary", () => { await runtime.shutdown(); }); + it("requires an explicit opt-in for tailnet HTTP and uses an HTTP-compatible capability cookie", async () => { + expect(() => new Phase7iDemoRuntime({ + publicOrigin: "http://phase7i.example.ts.net:4197", + basePath: "/phase7i", + commitSha: "deadbeef", + packageRoot, + log: () => undefined, + })).toThrow(/explicitly enabled/); + + const publicOrigin = "http://phase7i.example.ts.net:4197"; + const runtime = new Phase7iDemoRuntime({ + publicOrigin, + basePath: "/phase7i", + commitSha: "deadbeef", + allowInsecureHttp: true, + packageRoot, + log: () => undefined, + }); + await runtime.initialize(); + expect(runtime.health()).toMatchObject({ ready: true, commitSha: "deadbeef" }); + const setCookie = phase7iCapabilityCookieForOrigin(new URL(publicOrigin), "capability"); + expect(setCookie).toContain("paperclip_phase7i=capability"); + expect(setCookie).not.toContain("__Host-"); + expect(setCookie).not.toMatch(/; Secure(?:;|$)/); + expect(setCookie).toContain("SameSite=Strict"); + await runtime.shutdown(); + }); + it("rejects ambient credential families by name without inspecting values", () => { const unsafe = { PATH: "/usr/bin", diff --git a/packages/paperclip-runner/src/phase7/phase7i-demo-server.ts b/packages/paperclip-runner/src/phase7/phase7i-demo-server.ts index 09e7c6ec4ca..14f7415f876 100644 --- a/packages/paperclip-runner/src/phase7/phase7i-demo-server.ts +++ b/packages/paperclip-runner/src/phase7/phase7i-demo-server.ts @@ -22,7 +22,8 @@ const SESSION_LIFETIME_MS = 30 * 60_000; const MAX_SESSION_EVENTS = 1_024; const MAX_SERIALIZED_STATE_BYTES = 1024 * 1024; const TURN_DEADLINE_MS = 30_000; -const CAPABILITY_COOKIE = "__Host-paperclip_phase7i"; +const SECURE_CAPABILITY_COOKIE = "__Host-paperclip_phase7i"; +const TAILNET_HTTP_CAPABILITY_COOKIE = "paperclip_phase7i"; const FORBIDDEN_ENV_NAME = /^(?:PAPERCLIP_|GITHUB_|AWS_|GOOGLE_|AZURE_)|(?:API_KEY|TOKEN|SECRET|PASSWORD|DATABASE_URL|CODEX_HOME)$/i; @@ -53,6 +54,7 @@ type RuntimeOptions = { publicOrigin: string; basePath: string; commitSha: string; + allowInsecureHttp?: boolean; packageRoot?: string; now?: () => number; log?: (record: Record) => void; @@ -104,8 +106,28 @@ function parseCookies(req: IncomingMessage): Map { return result; } -function capabilityCookie(value: string, maxAgeSeconds = 1_800): string { - return `${CAPABILITY_COOKIE}=${value}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; Secure; SameSite=Strict`; +function capabilityCookie( + name: string, + value: string, + secure: boolean, + maxAgeSeconds = 1_800, +): string { + const secureAttribute = secure ? "; Secure" : ""; + return `${name}=${value}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly${secureAttribute}; SameSite=Strict`; +} + +export function phase7iCapabilityCookieForOrigin( + publicOrigin: URL, + value: string, + maxAgeSeconds = 1_800, +): string { + const secure = publicOrigin.protocol === "https:"; + return capabilityCookie( + secure ? SECURE_CAPABILITY_COOKIE : TAILNET_HTTP_CAPABILITY_COOKIE, + value, + secure, + maxAgeSeconds, + ); } function applySecurityHeaders(res: ServerResponse): void { @@ -218,6 +240,8 @@ export class Phase7iDemoRuntime { readonly basePath: string; readonly commitSha: string; + private readonly secureTransport: boolean; + private readonly capabilityCookieName: string; private readonly packageRoot: string; private readonly staticRoot: string; private readonly now: () => number; @@ -230,9 +254,16 @@ export class Phase7iDemoRuntime { constructor(options: RuntimeOptions) { this.publicOrigin = new URL(options.publicOrigin); - if (this.publicOrigin.protocol !== "https:") { - throw new Error("PHASE7I_PUBLIC_ORIGIN must use HTTPS"); + this.secureTransport = this.publicOrigin.protocol === "https:"; + if ( + !this.secureTransport + && !(this.publicOrigin.protocol === "http:" && options.allowInsecureHttp === true) + ) { + throw new Error("PHASE7I_PUBLIC_ORIGIN must use HTTPS unless tailnet HTTP is explicitly enabled"); } + this.capabilityCookieName = this.secureTransport + ? SECURE_CAPABILITY_COOKIE + : TAILNET_HTTP_CAPABILITY_COOKIE; this.basePath = normalizeBasePath(options.basePath); this.commitSha = options.commitSha.trim() || "unknown"; this.packageRoot = options.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -328,8 +359,9 @@ export class Phase7iDemoRuntime { if (header(req, "x-phase7i-proxy") !== "v1") { throw new RequestFailure(403, "trusted_proxy_required", "Request did not arrive through the trusted proxy."); } - if (header(req, "x-forwarded-proto").split(",")[0]?.trim().toLowerCase() !== "https") { - throw new RequestFailure(403, "https_required", "HTTPS is required."); + const forwardedProtocol = header(req, "x-forwarded-proto").split(",")[0]?.trim().toLowerCase(); + if (forwardedProtocol !== this.publicOrigin.protocol.slice(0, -1)) { + throw new RequestFailure(403, "transport_denied", "Forwarded transport is not allowed."); } const forwardedHost = header(req, "x-forwarded-host").split(",")[0]?.trim().toLowerCase(); if (forwardedHost !== this.publicOrigin.host.toLowerCase()) { @@ -361,7 +393,7 @@ export class Phase7iDemoRuntime { private requireSession(req: IncomingMessage, id: string): SessionRecord { const record = this.sessions.get(id); if (!record) throw new RequestFailure(404, "session_not_found", "Session was not found."); - const capability = parseCookies(req).get(CAPABILITY_COOKIE) ?? ""; + const capability = parseCookies(req).get(this.capabilityCookieName) ?? ""; const capabilityHash = sha256(capability); const identityHash = shortHash(requestIdentity(req)); if ( @@ -408,7 +440,10 @@ export class Phase7iDemoRuntime { chat, }; this.sessions.set(record.id, record); - res.setHeader("Set-Cookie", capabilityCookie(capability)); + res.setHeader( + "Set-Cookie", + phase7iCapabilityCookieForOrigin(this.publicOrigin, capability), + ); sendJson(res, 201, { sessionId: record.id, artifact, nextPrompt: chat.nextPrompt() }); } @@ -479,14 +514,20 @@ export class Phase7iDemoRuntime { chat, }; this.sessions.set(replacement.id, replacement); - res.setHeader("Set-Cookie", capabilityCookie(capability)); + res.setHeader( + "Set-Cookie", + phase7iCapabilityCookieForOrigin(this.publicOrigin, capability), + ); sendJson(res, 200, { sessionId: replacement.id, artifact, nextPrompt: chat.nextPrompt() }); } private async deleteSession(res: ServerResponse, record: SessionRecord): Promise { this.sessions.delete(record.id); await record.chat.close(); - res.setHeader("Set-Cookie", capabilityCookie("deleted", 0)); + res.setHeader( + "Set-Cookie", + phase7iCapabilityCookieForOrigin(this.publicOrigin, "deleted", 0), + ); sendJson(res, 200, { closed: true }); } @@ -612,6 +653,7 @@ async function main(): Promise { publicOrigin, basePath: process.env.PHASE7I_BASE_PATH ?? "", commitSha: process.env.PHASE7I_COMMIT_SHA ?? "unknown", + allowInsecureHttp: process.env.PHASE7I_ALLOW_INSECURE_HTTP === "1", }); await runtime.initialize(); const server = runtime.createServer(); diff --git a/packages/paperclip-runner/src/phase7/phase7i-loopback-proxy.ts b/packages/paperclip-runner/src/phase7/phase7i-loopback-proxy.ts index 9e2281897ca..fef5a0fac33 100644 --- a/packages/paperclip-runner/src/phase7/phase7i-loopback-proxy.ts +++ b/packages/paperclip-runner/src/phase7/phase7i-loopback-proxy.ts @@ -1,52 +1,14 @@ -import { request as proxyRequest, createServer, type IncomingHttpHeaders } from "node:http"; +import { request as proxyRequest, createServer } from "node:http"; + +import { + resolvePhase7iProxyConfig, + selectedPhase7iProxyHeaders, +} from "./phase7i-proxy-config.js"; const MAX_BODY_BYTES = 64 * 1024; -const FORWARDED_HEADERS = new Set([ - "accept", - "accept-encoding", - "content-length", - "content-type", - "cookie", - "host", - "origin", - "sec-fetch-dest", - "sec-fetch-mode", - "sec-fetch-site", - "tailscale-user-login", - "tailscale-user-name", - "user-agent", - "x-forwarded-for", -]); - -function selectedHeaders( - headers: IncomingHttpHeaders, - publicHost: string, -): Record { - const selected: Record = {}; - for (const [name, value] of Object.entries(headers)) { - if (!FORWARDED_HEADERS.has(name) || value === undefined) continue; - selected[name] = value; - } - selected.host = publicHost; - selected["x-forwarded-proto"] = "https"; - selected["x-forwarded-host"] = publicHost; - selected["x-phase7i-proxy"] = "v1"; - return selected; -} function main(): void { - const socketPath = process.env.PHASE7I_SOCKET_PATH?.trim(); - const port = Number(process.env.PHASE7I_PROXY_PORT); - const publicOrigin = new URL(process.env.PHASE7I_PUBLIC_ORIGIN ?? ""); - if ( - !socketPath - || !Number.isInteger(port) - || port < 1 - || port > 65_535 - || publicOrigin.protocol !== "https:" - ) { - throw new Error("PHASE7I_SOCKET_PATH, PHASE7I_PUBLIC_ORIGIN, and a valid PHASE7I_PROXY_PORT are required"); - } + const { socketPath, port, bindHost, publicOrigin } = resolvePhase7iProxyConfig(process.env); const server = createServer((req, res) => { const declaredLength = Number(req.headers["content-length"]); @@ -60,7 +22,7 @@ function main(): void { socketPath, method: req.method, path: req.url, - headers: selectedHeaders(req.headers, publicOrigin.host), + headers: selectedPhase7iProxyHeaders(req.headers, publicOrigin, req.socket.remoteAddress), timeout: 35_000, }, (upstreamResponse) => { res.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); @@ -81,7 +43,7 @@ function main(): void { server.requestTimeout = 35_000; server.headersTimeout = 10_000; server.keepAliveTimeout = 5_000; - server.listen(port, "127.0.0.1"); + server.listen(port, bindHost); const stop = (): void => { server.close(() => process.exit(0)); diff --git a/packages/paperclip-runner/src/phase7/phase7i-proxy-config.test.ts b/packages/paperclip-runner/src/phase7/phase7i-proxy-config.test.ts new file mode 100644 index 00000000000..a40183dd104 --- /dev/null +++ b/packages/paperclip-runner/src/phase7/phase7i-proxy-config.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + resolvePhase7iProxyConfig, + selectedPhase7iProxyHeaders, +} from "./phase7i-proxy-config.js"; + +const baseEnv = { + PHASE7I_SOCKET_PATH: "/tmp/phase7i.sock", + PHASE7I_PROXY_PORT: "4197", +}; + +describe("Phase 7I proxy configuration", () => { + it("keeps HTTPS loopback-only by default", () => { + expect(resolvePhase7iProxyConfig({ + ...baseEnv, + PHASE7I_PUBLIC_ORIGIN: "https://phase7i.example.ts.net", + })).toMatchObject({ + bindHost: "127.0.0.1", + port: 4197, + socketPath: "/tmp/phase7i.sock", + }); + expect(() => resolvePhase7iProxyConfig({ + ...baseEnv, + PHASE7I_PUBLIC_ORIGIN: "https://phase7i.example.ts.net", + PHASE7I_PROXY_HOST: "0.0.0.0", + })).toThrow(/allowed .*bind mode/); + }); + + it("requires explicit tailnet HTTP opt-in before binding beyond loopback", () => { + expect(() => resolvePhase7iProxyConfig({ + ...baseEnv, + PHASE7I_PUBLIC_ORIGIN: "http://phase7i.example.ts.net:4197", + PHASE7I_PROXY_HOST: "0.0.0.0", + })).toThrow(/allowed .*bind mode/); + expect(resolvePhase7iProxyConfig({ + ...baseEnv, + PHASE7I_PUBLIC_ORIGIN: "http://phase7i.example.ts.net:4197", + PHASE7I_PROXY_HOST: "0.0.0.0", + PHASE7I_ALLOW_INSECURE_HTTP: "1", + })).toMatchObject({ + bindHost: "0.0.0.0", + port: 4197, + }); + }); + + it("derives direct-tailnet identity from the socket instead of spoofable headers", () => { + const headers = selectedPhase7iProxyHeaders({ + cookie: "paperclip_phase7i=capability", + "tailscale-user-login": "spoof@example.com", + "tailscale-user-name": "Spoofed User", + "x-forwarded-for": "203.0.113.10", + }, new URL("http://phase7i.example.ts.net:4197"), "100.64.0.2"); + expect(headers).toMatchObject({ + host: "phase7i.example.ts.net:4197", + cookie: "paperclip_phase7i=capability", + "x-forwarded-for": "100.64.0.2", + "x-forwarded-host": "phase7i.example.ts.net:4197", + "x-forwarded-proto": "http", + "x-phase7i-proxy": "v1", + }); + expect(headers).not.toHaveProperty("tailscale-user-login"); + expect(headers).not.toHaveProperty("tailscale-user-name"); + }); +}); diff --git a/packages/paperclip-runner/src/phase7/phase7i-proxy-config.ts b/packages/paperclip-runner/src/phase7/phase7i-proxy-config.ts new file mode 100644 index 00000000000..67922dd1018 --- /dev/null +++ b/packages/paperclip-runner/src/phase7/phase7i-proxy-config.ts @@ -0,0 +1,75 @@ +import { isIP } from "node:net"; +import type { IncomingHttpHeaders } from "node:http"; + +const FORWARDED_HEADERS = new Set([ + "accept", + "accept-encoding", + "content-length", + "content-type", + "cookie", + "host", + "origin", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "tailscale-user-login", + "tailscale-user-name", + "user-agent", + "x-forwarded-for", +]); + +export type Phase7iProxyConfig = { + socketPath: string; + port: number; + bindHost: string; + publicOrigin: URL; +}; + +export function resolvePhase7iProxyConfig(env: NodeJS.ProcessEnv): Phase7iProxyConfig { + const socketPath = env.PHASE7I_SOCKET_PATH?.trim() ?? ""; + const port = Number(env.PHASE7I_PROXY_PORT); + const bindHost = env.PHASE7I_PROXY_HOST?.trim() || "127.0.0.1"; + const allowInsecureHttp = env.PHASE7I_ALLOW_INSECURE_HTTP === "1"; + const publicOrigin = new URL(env.PHASE7I_PUBLIC_ORIGIN ?? ""); + const directTailnetHttp = publicOrigin.protocol === "http:" && allowInsecureHttp; + const loopbackBind = bindHost === "127.0.0.1" || bindHost === "::1"; + if ( + !socketPath + || !Number.isInteger(port) + || port < 1 + || port > 65_535 + || isIP(bindHost) === 0 + || (publicOrigin.protocol !== "https:" && !directTailnetHttp) + || (!loopbackBind && !directTailnetHttp) + ) { + throw new Error( + "PHASE7I_SOCKET_PATH, an allowed PHASE7I_PUBLIC_ORIGIN/bind mode, and a valid PHASE7I_PROXY_PORT are required", + ); + } + return { socketPath, port, bindHost, publicOrigin }; +} + +export function selectedPhase7iProxyHeaders( + headers: IncomingHttpHeaders, + publicOrigin: URL, + remoteAddress: string | undefined, +): Record { + const selected: Record = {}; + const directTailnetHttp = publicOrigin.protocol === "http:"; + for (const [name, value] of Object.entries(headers)) { + if (!FORWARDED_HEADERS.has(name) || value === undefined) continue; + if ( + directTailnetHttp + && (name === "tailscale-user-login" || name === "tailscale-user-name" || name === "x-forwarded-for") + ) { + continue; + } + selected[name] = value; + } + selected.host = publicOrigin.host; + selected["x-forwarded-proto"] = publicOrigin.protocol.slice(0, -1); + selected["x-forwarded-host"] = publicOrigin.host; + if (directTailnetHttp) selected["x-forwarded-for"] = remoteAddress ?? "tailnet-peer"; + selected["x-phase7i-proxy"] = "v1"; + return selected; +}