mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
fix(runner): accept browsers without Fetch Metadata
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
}
|
||||
Reference in new issue
Block a user