From 6394ecfd01976da0c269569532493092b8561320 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 01:17:21 +0000 Subject: [PATCH] fix(runner): accept browsers without Fetch Metadata Co-Authored-By: Paperclip --- .../docs/phase-07-clean-room-chat.md | 14 + .../src/phase7/tailnet-gateway.test.ts | 69 ++++ .../src/phase7/tailnet-gateway.ts | 384 ++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 packages/paperclip-runner/src/phase7/tailnet-gateway.test.ts create mode 100644 packages/paperclip-runner/src/phase7/tailnet-gateway.ts diff --git a/packages/paperclip-runner/docs/phase-07-clean-room-chat.md b/packages/paperclip-runner/docs/phase-07-clean-room-chat.md index 62c91600ce3..07e074eb0bc 100644 --- a/packages/paperclip-runner/docs/phase-07-clean-room-chat.md +++ b/packages/paperclip-runner/docs/phase-07-clean-room-chat.md @@ -106,6 +106,20 @@ The child environment is allowlisted by `createSanitizedCodexEnvironment`, so no `PAPERCLIP_*` value reaches runnerd or Codex, and the browser receives no provider, runner, or control-plane credential. +## Remote preview gateway + +The tailnet preview keeps the package server on loopback and exposes it through +`dist/phase7/tailnet-gateway.js`. Every API call requires the gateway's +high-entropy HttpOnly capability cookie. Mutations additionally require the +exact configured `Origin` and `application/json`. + +Fetch Metadata is checked as defense in depth: any supplied `Sec-Fetch-Site`, +`Sec-Fetch-Mode`, or `Sec-Fetch-Dest` value must describe the expected +same-origin `fetch()` request. Browsers that omit one or all of those optional +headers are still accepted after the capability, Origin, and content-type +checks pass. This keeps Safari and embedded/private browser clients working +without weakening the explicit cross-site denial. + ## Verification | Surface | Command | diff --git a/packages/paperclip-runner/src/phase7/tailnet-gateway.test.ts b/packages/paperclip-runner/src/phase7/tailnet-gateway.test.ts new file mode 100644 index 00000000000..21520aa521f --- /dev/null +++ b/packages/paperclip-runner/src/phase7/tailnet-gateway.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { phase7TailnetAdmissionFailure } from "./tailnet-gateway.js"; + +const expectedHost = "paperclip-dev.example.ts.net:4196"; +const expectedOrigin = `http://${expectedHost}`; + +function mutation(headers: Record = {}) { + return phase7TailnetAdmissionFailure({ + headers: { + host: expectedHost, + origin: expectedOrigin, + "content-type": "application/json", + ...headers, + }, + method: "POST", + pathname: "/api/phase7/ui/message", + expectedHost, + expectedOrigin, + capabilityValid: true, + }); +} + +describe("Phase 7 tailnet gateway admission", () => { + it("accepts an exact-origin capability mutation when Fetch Metadata is absent", () => { + expect(mutation()).toBeNull(); + }); + + it("accepts complete same-origin Fetch Metadata", () => { + expect( + mutation({ + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }), + ).toBeNull(); + }); + + it.each([ + ["sec-fetch-site", "cross-site"], + ["sec-fetch-mode", "no-cors"], + ["sec-fetch-dest", "document"], + ])("rejects an explicitly unsafe %s value", (name, value) => { + expect(mutation({ [name]: value })).toMatchObject({ + status: 403, + code: "fetch_metadata_denied", + }); + }); + + it("still requires the exact mutation origin", () => { + expect(mutation({ origin: "http://attacker.example" })).toMatchObject({ + status: 403, + code: "origin_denied", + }); + }); + + it("still requires the preview capability", () => { + expect( + phase7TailnetAdmissionFailure({ + headers: { host: expectedHost, origin: expectedOrigin, "content-type": "application/json" }, + method: "POST", + pathname: "/api/phase7/ui/message", + expectedHost, + expectedOrigin, + capabilityValid: false, + }), + ).toMatchObject({ status: 403, code: "capability_denied" }); + }); +}); diff --git a/packages/paperclip-runner/src/phase7/tailnet-gateway.ts b/packages/paperclip-runner/src/phase7/tailnet-gateway.ts new file mode 100644 index 00000000000..f0eb0bbe33d --- /dev/null +++ b/packages/paperclip-runner/src/phase7/tailnet-gateway.ts @@ -0,0 +1,384 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { + createServer, + request as requestHttp, + type IncomingHttpHeaders, + type OutgoingHttpHeaders, + type Server, + type ServerResponse, +} from "node:http"; +import { isIP } from "node:net"; +import { fileURLToPath } from "node:url"; + +const CAPABILITY_BYTES = 32; + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +export type Phase7TailnetGatewayConfig = { + bindHost: string; + listenPort: number; + upstreamHost: "127.0.0.1" | "::1"; + upstreamPort: number; + publicOrigin: URL; + buildIdentifier: string; + sourceCommit: string; + bundleSha256: string; + staticSetSha256: string; +}; + +export type Phase7TailnetAdmissionFailure = { + status: number; + code: string; + message: string; +}; + +type AdmissionInput = { + headers: IncomingHttpHeaders; + method: string; + pathname: string; + expectedHost: string; + expectedOrigin: string; + capabilityValid: boolean; +}; + +function firstHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +/** + * Fetch Metadata is defense in depth, not the primary CSRF boundary. + * + * Safari and embedded/private browsers may omit one or all `Sec-Fetch-*` + * headers. Exact Origin validation, a SameSite capability cookie, and the + * high-entropy capability remain mandatory. When a Fetch Metadata header is + * present, however, it must explicitly describe the expected same-origin + * `fetch()` request. + */ +export function phase7TailnetFetchMetadataAllowed(headers: IncomingHttpHeaders): boolean { + const expectations = [ + ["sec-fetch-site", "same-origin"], + ["sec-fetch-mode", "cors"], + ["sec-fetch-dest", "empty"], + ] as const; + + return expectations.every(([name, expected]) => { + const actual = firstHeader(headers[name]); + return actual === undefined || actual === expected; + }); +} + +export function phase7TailnetAdmissionFailure( + input: AdmissionInput, +): Phase7TailnetAdmissionFailure | null { + const host = firstHeader(input.headers.host)?.toLowerCase() ?? ""; + if (host !== input.expectedHost) { + return { status: 403, code: "host_denied", message: "Request host is not allowed." }; + } + + const isApi = input.pathname.startsWith("/api/phase7/ui/"); + if (isApi && !input.capabilityValid) { + return { + status: 403, + code: "capability_denied", + message: "Preview capability is missing or expired.", + }; + } + + const mutation = isApi && input.method !== "GET" && input.method !== "HEAD"; + if (!mutation) return null; + + if (firstHeader(input.headers.origin) !== input.expectedOrigin) { + return { status: 403, code: "origin_denied", message: "Mutation origin is not allowed." }; + } + + if (!phase7TailnetFetchMetadataAllowed(input.headers)) { + return { + status: 403, + code: "fetch_metadata_denied", + message: "Mutation request metadata is not allowed.", + }; + } + + const contentType = (firstHeader(input.headers["content-type"]) ?? "") + .split(";", 1)[0] + ?.trim() + .toLowerCase(); + if (contentType !== "application/json") { + return { + status: 415, + code: "content_type_denied", + message: "Mutation body must be JSON.", + }; + } + + return null; +} + +function parsePort(value: string | undefined): number { + const port = Number(value ?? "0"); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("Phase 7 tailnet gateway requires valid listener and upstream ports"); + } + return port; +} + +export function resolvePhase7TailnetGatewayConfig( + env: NodeJS.ProcessEnv, +): Phase7TailnetGatewayConfig { + const bindHost = env.PHASE7_GATEWAY_BIND_HOST ?? ""; + const upstreamHost = env.PHASE7_UPSTREAM_HOST ?? "127.0.0.1"; + const publicOrigin = new URL(env.PHASE7_PUBLIC_ORIGIN ?? ""); + + if ( + isIP(bindHost) === 0 || + (upstreamHost !== "127.0.0.1" && upstreamHost !== "::1") || + !["http:", "https:"].includes(publicOrigin.protocol) || + publicOrigin.pathname !== "/" || + publicOrigin.search !== "" || + publicOrigin.hash !== "" || + publicOrigin.username !== "" || + publicOrigin.password !== "" + ) { + throw new Error("Phase 7 tailnet gateway requires an exact bind, loopback upstream, and public origin"); + } + + return { + bindHost, + listenPort: parsePort(env.PHASE7_GATEWAY_PORT), + upstreamHost, + upstreamPort: parsePort(env.PHASE7_UPSTREAM_PORT), + publicOrigin, + buildIdentifier: env.PHASE7_BUILD_IDENTIFIER ?? "unknown", + sourceCommit: env.PHASE7_SOURCE_COMMIT ?? "unknown", + bundleSha256: env.PHASE7_BUNDLE_SHA256 ?? "unknown", + staticSetSha256: env.PHASE7_STATIC_SET_SHA256 ?? "unknown", + }; +} + +function securityHeaders(secureTransport: boolean, headers: OutgoingHttpHeaders = {}): OutgoingHttpHeaders { + return { + ...headers, + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "content-security-policy": + "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'", + "permissions-policy": "camera=(), geolocation=(), microphone=(), payment=(), usb=()", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + ...(secureTransport + ? { "strict-transport-security": "max-age=31536000; includeSubDomains" } + : {}), + }; +} + +export function createPhase7TailnetGateway(config: Phase7TailnetGatewayConfig): Server { + const capability = randomBytes(CAPABILITY_BYTES).toString("base64url"); + const secureTransport = config.publicOrigin.protocol === "https:"; + const capabilityCookieName = secureTransport + ? "__Host-paperclip_phase7r" + : "paperclip_phase7r"; + const upstreamAuthority = config.upstreamHost.includes(":") + ? `[${config.upstreamHost}]:${config.upstreamPort}` + : `${config.upstreamHost}:${config.upstreamPort}`; + const expectedHost = config.publicOrigin.host.toLowerCase(); + + const sendJson = ( + response: ServerResponse, + status: number, + payload: unknown, + ): void => { + const body = Buffer.from(JSON.stringify(payload)); + response.writeHead( + status, + securityHeaders(secureTransport, { + "cache-control": "no-store", + "content-length": String(body.length), + "content-type": "application/json; charset=utf-8", + }), + ); + response.end(body); + }; + + const capabilityValid = (cookieHeader: string | undefined): boolean => { + let candidate = ""; + for (const segment of (cookieHeader ?? "").split(";")) { + const index = segment.indexOf("="); + if (index < 1) continue; + if (segment.slice(0, index).trim() === capabilityCookieName) { + candidate = segment.slice(index + 1).trim(); + break; + } + } + if (candidate.length !== capability.length) return false; + return timingSafeEqual(Buffer.from(candidate), Buffer.from(capability)); + }; + + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", config.publicOrigin); + + if (url.pathname === "/__phase7r/health") { + const probe = requestHttp( + { + host: config.upstreamHost, + port: config.upstreamPort, + method: "GET", + path: "/", + headers: { host: upstreamAuthority }, + timeout: 2_000, + }, + (upstreamResponse) => { + const upstreamStatus = upstreamResponse.statusCode ?? 0; + upstreamResponse.resume(); + upstreamResponse.once("end", () => { + const healthy = upstreamStatus >= 200 && upstreamStatus < 400; + sendJson(response, healthy ? 200 : 503, { + status: healthy ? "ok" : "unhealthy", + buildIdentifier: config.buildIdentifier, + sourceCommit: config.sourceCommit, + bundleSha256: config.bundleSha256, + staticSetSha256: config.staticSetSha256, + runtime: "phase7-clean-room", + upstream: `loopback:${config.upstreamPort}`, + upstreamStatus, + }); + }); + }, + ); + probe.once("timeout", () => probe.destroy(new Error("health probe timeout"))); + probe.once("error", () => { + if (!response.headersSent) { + sendJson(response, 503, { + status: "unhealthy", + buildIdentifier: config.buildIdentifier, + sourceCommit: config.sourceCommit, + bundleSha256: config.bundleSha256, + staticSetSha256: config.staticSetSha256, + runtime: "phase7-clean-room", + upstream: `loopback:${config.upstreamPort}`, + upstreamStatus: null, + }); + } + }); + probe.end(); + return; + } + + const failure = phase7TailnetAdmissionFailure({ + headers: request.headers, + method: request.method ?? "GET", + pathname: url.pathname, + expectedHost, + expectedOrigin: config.publicOrigin.origin, + capabilityValid: capabilityValid(firstHeader(request.headers.cookie)), + }); + if (failure !== null) { + sendJson(response, failure.status, { error: { code: failure.code, message: failure.message } }); + return; + } + + const headers = { ...request.headers }; + for (const name of HOP_BY_HOP_HEADERS) delete headers[name]; + headers.host = upstreamAuthority; + headers["x-forwarded-host"] = expectedHost; + headers["x-forwarded-proto"] = config.publicOrigin.protocol.slice(0, -1); + headers["x-forwarded-for"] = request.socket.remoteAddress ?? "tailnet-peer"; + + const isTurnStream = + request.method === "POST" && url.pathname === "/api/phase7/ui/message"; + if (isTurnStream) delete headers["accept-encoding"]; + + const upstream = requestHttp( + { + host: config.upstreamHost, + port: config.upstreamPort, + method: request.method, + path: request.url, + headers, + timeout: 125_000, + }, + (upstreamResponse) => { + const responseHeaders: OutgoingHttpHeaders = {}; + for (const [name, value] of Object.entries(upstreamResponse.headers)) { + if (!HOP_BY_HOP_HEADERS.has(name) && value !== undefined) responseHeaders[name] = value; + } + if (isTurnStream) { + delete responseHeaders["content-encoding"]; + delete responseHeaders["content-length"]; + responseHeaders["x-accel-buffering"] = "no"; + } + if ( + request.method === "GET" && + url.pathname === "/" && + (upstreamResponse.statusCode ?? 500) < 400 + ) { + responseHeaders["set-cookie"] = `${capabilityCookieName}=${capability}; Path=/; HttpOnly; SameSite=Strict${secureTransport ? "; Secure" : ""}`; + } + response.writeHead( + upstreamResponse.statusCode ?? 502, + securityHeaders(secureTransport, responseHeaders), + ); + response.flushHeaders(); + upstreamResponse.pipe(response); + }, + ); + + upstream.once("timeout", () => upstream.destroy(new Error("upstream timeout"))); + upstream.once("error", () => { + if (response.headersSent) { + response.destroy(); + return; + } + sendJson(response, 502, { + error: { code: "upstream_unavailable", message: "Phase 7 preview is unavailable." }, + }); + }); + request.once("aborted", () => upstream.destroy()); + request.pipe(upstream); + }); + + server.maxConnections = 32; + server.requestTimeout = 125_000; + server.headersTimeout = 10_000; + server.keepAliveTimeout = 5_000; + return server; +} + +export function startPhase7TailnetGateway(env: NodeJS.ProcessEnv = process.env): Server { + const config = resolvePhase7TailnetGatewayConfig(env); + const server = createPhase7TailnetGateway(config); + server.listen(config.listenPort, config.bindHost, () => { + process.stdout.write( + `${JSON.stringify({ + event: "phase7_tailnet_gateway_started", + bindHost: config.bindHost, + listenPort: config.listenPort, + publicOrigin: config.publicOrigin.origin, + upstreamHost: config.upstreamHost, + upstreamPort: config.upstreamPort, + buildIdentifier: config.buildIdentifier, + })}\n`, + ); + }); + return server; +} + +const isMain = process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const server = startPhase7TailnetGateway(); + const stop = (): void => { + server.close(() => process.exit(0)); + }; + process.once("SIGTERM", stop); + process.once("SIGINT", stop); +}