fix(runner): accept browsers without Fetch Metadata

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-11 01:17:21 +00:00
1 parent 271fb7859b
commit 6394ecfd01
3 files changed
+467

No files matched your search

@@ -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 |
@@ -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<string, string> = {}) {
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" });
});
});
@@ -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);
}