mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
fix(runner): support direct tailnet HTTP demo
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4bc1c8e133
commit
7d6ec9fbf3
6 files changed
+234
-58
No files matched your search
@@ -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 \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>) => void;
|
||||
@@ -104,8 +106,28 @@ function parseCookies(req: IncomingMessage): Map<string, string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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<string, string | string[]> {
|
||||
const selected: Record<string, string | string[]> = {};
|
||||
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));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | string[]> {
|
||||
const selected: Record<string, string | string[]> = {};
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user