From ec7adcaf3de904692f75e545757a76bcbfe83414 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 17:07:50 +0000 Subject: [PATCH] feat(runtime-exposure): least-privilege Tailscale HTTPS broker + shared contract (PAP-17051) Foundation for opt-in `tailscale_https` exposure of managed branch runtimes, implementing the PAP-17049 plan under the PAP-17050 threat-model verdict. Broker (packages/tailscale-https-broker): root/dedicated-account host broker exposing only idempotent list/expose/remove over a Unix socket. Implements the six required verdict controls: SO_PEERCRED + UID/GID allowlist + unguessable broker-issued lease handles (req #1); dedicated port allowlist + /proc listener ownership/loopback proof before mutation (req #2); per-mutation transaction (strict-parse serve JSON -> verify absence/exact-lease -> verify :443 -> one fixed per-port op -> reread -> require only-intended-changed + :443 identical -> atomic root-owned registry commit, quarantine-on-ambiguity) (req #3); execve/shell=false argv built only from canonical integers + fixed tokens, pinned CLI schema (req #4); versioned length-bounded strict-JSON protocol (req #5); bounded append-only audit events (req #6). Deny-by-default for Funnel/cert/Services/reset/set-config/path-handlers/:443/reserved ports. Shared contract (packages/shared): single source of truth for exposure config, lifecycle state (pending/ready/failed/cleanup_pending/removed), listener ownership, and status, with strict zod validators and deterministic app/HMR port-pair + URL derivation so DB/server/UI/runtime cannot drift. Tests: broker 57/57, shared runtime-exposure 13/13, both typecheck clean. Server/DB/UI lifecycle integration + docs land in follow-on commits. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/index.ts | 39 ++ .../shared/src/runtime-exposure/ports.test.ts | 65 +++ packages/shared/src/runtime-exposure/ports.ts | 95 ++++ packages/shared/src/types/runtime-exposure.ts | 85 ++++ .../src/validators/runtime-exposure.test.ts | 94 ++++ .../shared/src/validators/runtime-exposure.ts | 88 ++++ packages/tailscale-https-broker/package.json | 24 + .../tailscale-https-broker/src/argv.test.ts | 40 ++ packages/tailscale-https-broker/src/argv.ts | 61 +++ packages/tailscale-https-broker/src/audit.ts | 90 ++++ .../src/authorization.test.ts | 73 +++ .../src/authorization.ts | 87 ++++ .../src/broker-core.test.ts | 278 +++++++++++ .../tailscale-https-broker/src/broker-core.ts | 432 ++++++++++++++++++ packages/tailscale-https-broker/src/config.ts | 54 +++ packages/tailscale-https-broker/src/index.ts | 11 + .../src/integers.test.ts | 51 +++ .../tailscale-https-broker/src/integers.ts | 59 +++ packages/tailscale-https-broker/src/main.ts | 88 ++++ .../tailscale-https-broker/src/peercred.ts | 50 ++ .../tailscale-https-broker/src/port-policy.ts | 18 + .../src/proc-listener.test.ts | 53 +++ .../src/proc-listener.ts | 130 ++++++ .../src/protocol.test.ts | 95 ++++ .../tailscale-https-broker/src/protocol.ts | 168 +++++++ .../tailscale-https-broker/src/registry.ts | 136 ++++++ .../src/serve-config.test.ts | 69 +++ .../src/serve-config.ts | 162 +++++++ .../src/socket-server.ts | 135 ++++++ .../src/strict-json.test.ts | 39 ++ .../tailscale-https-broker/src/strict-json.ts | 194 ++++++++ .../src/tailscale-cli.ts | 51 +++ packages/tailscale-https-broker/src/types.ts | 146 ++++++ packages/tailscale-https-broker/tsconfig.json | 8 + .../tailscale-https-broker/vitest.config.ts | 13 + pnpm-lock.yaml | 12 + 36 files changed, 3293 insertions(+) create mode 100644 packages/shared/src/runtime-exposure/ports.test.ts create mode 100644 packages/shared/src/runtime-exposure/ports.ts create mode 100644 packages/shared/src/types/runtime-exposure.ts create mode 100644 packages/shared/src/validators/runtime-exposure.test.ts create mode 100644 packages/shared/src/validators/runtime-exposure.ts create mode 100644 packages/tailscale-https-broker/package.json create mode 100644 packages/tailscale-https-broker/src/argv.test.ts create mode 100644 packages/tailscale-https-broker/src/argv.ts create mode 100644 packages/tailscale-https-broker/src/audit.ts create mode 100644 packages/tailscale-https-broker/src/authorization.test.ts create mode 100644 packages/tailscale-https-broker/src/authorization.ts create mode 100644 packages/tailscale-https-broker/src/broker-core.test.ts create mode 100644 packages/tailscale-https-broker/src/broker-core.ts create mode 100644 packages/tailscale-https-broker/src/config.ts create mode 100644 packages/tailscale-https-broker/src/index.ts create mode 100644 packages/tailscale-https-broker/src/integers.test.ts create mode 100644 packages/tailscale-https-broker/src/integers.ts create mode 100644 packages/tailscale-https-broker/src/main.ts create mode 100644 packages/tailscale-https-broker/src/peercred.ts create mode 100644 packages/tailscale-https-broker/src/port-policy.ts create mode 100644 packages/tailscale-https-broker/src/proc-listener.test.ts create mode 100644 packages/tailscale-https-broker/src/proc-listener.ts create mode 100644 packages/tailscale-https-broker/src/protocol.test.ts create mode 100644 packages/tailscale-https-broker/src/protocol.ts create mode 100644 packages/tailscale-https-broker/src/registry.ts create mode 100644 packages/tailscale-https-broker/src/serve-config.test.ts create mode 100644 packages/tailscale-https-broker/src/serve-config.ts create mode 100644 packages/tailscale-https-broker/src/socket-server.ts create mode 100644 packages/tailscale-https-broker/src/strict-json.test.ts create mode 100644 packages/tailscale-https-broker/src/strict-json.ts create mode 100644 packages/tailscale-https-broker/src/tailscale-cli.ts create mode 100644 packages/tailscale-https-broker/src/types.ts create mode 100644 packages/tailscale-https-broker/tsconfig.json create mode 100644 packages/tailscale-https-broker/vitest.config.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b98c0db9de9..49712f80005 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2425,3 +2425,42 @@ export { type FeatureTier, type InstanceFeatureKey, } from "./feature-catalog.js"; + +// --- Runtime exposure (opt-in Tailscale HTTPS for managed branch runtimes) --- +// PAP-17049 plan, PAP-17050 threat-model verdict. Contract shared across DB, +// server, UI, runtime, and the least-privilege host broker. +export type { + RuntimeExposureProvider, + RuntimeExposureFailurePolicy, + RuntimeExposureConfig, + RuntimeExposureState, + RuntimeExposureListenerPurpose, + RuntimeExposureListener, + RuntimeExposureStatus, +} from "./types/runtime-exposure.js"; +export { + runtimeExposureProviderSchema, + runtimeExposureFailurePolicySchema, + runtimeExposureConfigSchema, + runtimeExposureStateSchema, + runtimeExposureListenerPurposeSchema, + runtimeExposureListenerSchema, + runtimeExposureStatusSchema, + parseRuntimeExposureConfig, + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + type RuntimeExposureConfigInput, + type RuntimeExposureStatusInput, +} from "./validators/runtime-exposure.js"; +export { + RUNTIME_EXPOSURE_APP_PORT_MIN, + RUNTIME_EXPOSURE_APP_PORT_MAX, + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + RUNTIME_EXPOSURE_HMR_PORT_MIN, + RUNTIME_EXPOSURE_HMR_PORT_MAX, + isRuntimeExposureAppPort, + isRuntimeExposureHmrPort, + isAllowedRuntimeExposurePort, + deriveViteHmrPort, + buildRuntimeExposureUrl, + buildRuntimeExposureHealthUrl, +} from "./runtime-exposure/ports.js"; diff --git a/packages/shared/src/runtime-exposure/ports.test.ts b/packages/shared/src/runtime-exposure/ports.test.ts new file mode 100644 index 00000000000..48b7b084850 --- /dev/null +++ b/packages/shared/src/runtime-exposure/ports.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + RUNTIME_EXPOSURE_APP_PORT_MAX, + RUNTIME_EXPOSURE_APP_PORT_MIN, + RUNTIME_EXPOSURE_HMR_PORT_MAX, + RUNTIME_EXPOSURE_HMR_PORT_MIN, + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + buildRuntimeExposureHealthUrl, + buildRuntimeExposureUrl, + deriveViteHmrPort, + isAllowedRuntimeExposurePort, + isRuntimeExposureAppPort, + isRuntimeExposureHmrPort, +} from "./ports.js"; + +describe("runtime exposure port policy", () => { + it("accepts app ports only inside the dedicated range", () => { + expect(isRuntimeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MIN)).toBe(true); + expect(isRuntimeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MAX)).toBe(true); + expect(isRuntimeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MIN - 1)).toBe(false); + expect(isRuntimeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MAX + 1)).toBe(false); + }); + + it("rejects privileged, reserved, and non-integer ports", () => { + expect(isAllowedRuntimeExposurePort(443)).toBe(false); + expect(isAllowedRuntimeExposurePort(22)).toBe(false); + expect(isAllowedRuntimeExposurePort(3100)).toBe(false); + expect(isAllowedRuntimeExposurePort(0)).toBe(false); + expect(isAllowedRuntimeExposurePort(65536)).toBe(false); + expect(isAllowedRuntimeExposurePort(42000.5)).toBe(false); + expect(isAllowedRuntimeExposurePort(Number.NaN)).toBe(false); + }); + + it("keeps the HMR range in sync with the offset", () => { + expect(RUNTIME_EXPOSURE_HMR_PORT_MIN).toBe( + RUNTIME_EXPOSURE_APP_PORT_MIN + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + ); + expect(RUNTIME_EXPOSURE_HMR_PORT_MAX).toBe( + RUNTIME_EXPOSURE_APP_PORT_MAX + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + ); + expect(isRuntimeExposureHmrPort(RUNTIME_EXPOSURE_HMR_PORT_MIN)).toBe(true); + expect(isAllowedRuntimeExposurePort(RUNTIME_EXPOSURE_HMR_PORT_MIN)).toBe(true); + }); + + it("derives a deterministic HMR companion port and rejects out-of-range apps", () => { + expect(deriveViteHmrPort(42000)).toBe(52000); + expect(deriveViteHmrPort(42123)).toBe(52123); + expect(() => deriveViteHmrPort(3100)).toThrow(RangeError); + expect(() => deriveViteHmrPort(52000)).toThrow(RangeError); + }); + + it("builds https URLs on the non-standard port", () => { + expect(buildRuntimeExposureUrl("paperclip-dev.tail29c1aa.ts.net", 42010)).toBe( + "https://paperclip-dev.tail29c1aa.ts.net:42010", + ); + expect(buildRuntimeExposureHealthUrl("paperclip-dev.tail29c1aa.ts.net", 42010)).toBe( + "https://paperclip-dev.tail29c1aa.ts.net:42010/api/health", + ); + }); + + it("rejects empty hostnames and out-of-range ports when building URLs", () => { + expect(() => buildRuntimeExposureUrl("", 42010)).toThrow(); + expect(() => buildRuntimeExposureUrl("host", 443)).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/runtime-exposure/ports.ts b/packages/shared/src/runtime-exposure/ports.ts new file mode 100644 index 00000000000..c67bd4433c9 --- /dev/null +++ b/packages/shared/src/runtime-exposure/ports.ts @@ -0,0 +1,95 @@ +/** + * Deterministic, shared port + URL derivation for the `tailscale_https` + * exposure mode. Extracted so runtime allocation, server startup, the broker, + * and tests all agree on the dedicated Paperclip runtime port range and the + * app/HMR pairing (PAP-17049 plan, PAP-17050 verdict requirement #2). + * + * Pure functions only — no I/O, no process state. + */ + +/** + * Dedicated, allowlisted port range for managed Paperclip runtime APP + * listeners. Ports outside this range are never eligible for exposure, which + * bounds the SSRF blast radius: a compromised caller cannot ask the broker to + * publish an arbitrary existing loopback service. + */ +export const RUNTIME_EXPOSURE_APP_PORT_MIN = 42000; +export const RUNTIME_EXPOSURE_APP_PORT_MAX = 42999; + +/** + * Fixed offset between an app port and its Paperclip Vite HMR companion port. + * Matches Paperclip dev mode's `server port + offset` HMR convention and keeps + * the two listeners deterministically paired. + */ +export const RUNTIME_EXPOSURE_HMR_PORT_OFFSET = 10000; + +/** Derived HMR companion port range, kept in sync with the offset above. */ +export const RUNTIME_EXPOSURE_HMR_PORT_MIN = + RUNTIME_EXPOSURE_APP_PORT_MIN + RUNTIME_EXPOSURE_HMR_PORT_OFFSET; +export const RUNTIME_EXPOSURE_HMR_PORT_MAX = + RUNTIME_EXPOSURE_APP_PORT_MAX + RUNTIME_EXPOSURE_HMR_PORT_OFFSET; + +/** True only for integers strictly inside the dedicated app port range. */ +export function isRuntimeExposureAppPort(port: number): boolean { + return ( + Number.isInteger(port) && + port >= RUNTIME_EXPOSURE_APP_PORT_MIN && + port <= RUNTIME_EXPOSURE_APP_PORT_MAX + ); +} + +/** True only for integers strictly inside the derived HMR companion range. */ +export function isRuntimeExposureHmrPort(port: number): boolean { + return ( + Number.isInteger(port) && + port >= RUNTIME_EXPOSURE_HMR_PORT_MIN && + port <= RUNTIME_EXPOSURE_HMR_PORT_MAX + ); +} + +/** + * True for any port the broker is allowed to expose (app OR HMR companion). + * Everything else — privileged/reserved ports, `443`, ephemeral, unknown — is + * deny-by-default. + */ +export function isAllowedRuntimeExposurePort(port: number): boolean { + return isRuntimeExposureAppPort(port) || isRuntimeExposureHmrPort(port); +} + +/** + * Derive the Vite HMR companion port for an allocated app port. Throws if the + * app port is not a valid dedicated app port so callers cannot silently pair a + * port outside the allowlist. + */ +export function deriveViteHmrPort(appPort: number): number { + if (!isRuntimeExposureAppPort(appPort)) { + throw new RangeError( + `app port ${appPort} is outside the dedicated runtime exposure range ` + + `[${RUNTIME_EXPOSURE_APP_PORT_MIN}, ${RUNTIME_EXPOSURE_APP_PORT_MAX}]`, + ); + } + return appPort + RUNTIME_EXPOSURE_HMR_PORT_OFFSET; +} + +/** + * Build the canonical browser URL for an exposed app port on a resolved + * Tailscale node hostname. Uses the non-standard HTTPS port explicitly. + */ +export function buildRuntimeExposureUrl(hostname: string, appPort: number): string { + const trimmed = hostname.trim(); + if (trimmed.length === 0) { + throw new Error("hostname is required to build an exposure URL"); + } + if (!isRuntimeExposureAppPort(appPort)) { + throw new RangeError(`app port ${appPort} is outside the dedicated runtime exposure range`); + } + return `https://${trimmed}:${appPort}`; +} + +/** + * Build the external HTTPS health-probe URL used to verify normal certificate + * validation before a runtime is reported healthy. + */ +export function buildRuntimeExposureHealthUrl(hostname: string, appPort: number): string { + return `${buildRuntimeExposureUrl(hostname, appPort)}/api/health`; +} diff --git a/packages/shared/src/types/runtime-exposure.ts b/packages/shared/src/types/runtime-exposure.ts new file mode 100644 index 00000000000..785f053a7ad --- /dev/null +++ b/packages/shared/src/types/runtime-exposure.ts @@ -0,0 +1,85 @@ +/** + * Contract shared across DB, server, UI, runtime, and the least-privilege + * Tailscale HTTPS host broker for the opt-in `tailscale_https` exposure mode on + * managed workspace runtime services. + * + * This file is the single source of truth for the exposure config and exposure + * state shapes so the database column, shared validators, server serializers, + * UI clients, and the broker wire protocol cannot drift. See PAP-17049 (plan) + * and PAP-17050 (threat-model verdict). + */ + +/** Exposure provider. Only Tailscale HTTPS is defined today. */ +export type RuntimeExposureProvider = "tailscale_https"; + +/** + * How a configured exposure reports health when provisioning fails. + * `fail_closed` is the only supported value: a configured HTTPS preview is + * never reported healthy with a plain-HTTP fallback. + */ +export type RuntimeExposureFailurePolicy = "fail_closed"; + +/** + * Opt-in exposure request declared on a managed workspace runtime service. + * Deliberately narrow: no arbitrary target URL, path, hostname suffix, or port + * is accepted from the app. `hostname: "auto"` is resolved from the local + * Tailscale node; `publicPort: "same"` reuses the allocated app port. + */ +export interface RuntimeExposureConfig { + type: RuntimeExposureProvider; + /** Only "auto" is supported; the node DNS name is resolved at runtime. */ + hostname: "auto"; + /** Only "same" is supported; the public port equals the loopback app port. */ + publicPort: "same"; + /** Provision the Paperclip Vite HMR companion listener alongside the app. */ + includePaperclipViteHmr: boolean; + failurePolicy: RuntimeExposureFailurePolicy; +} + +/** + * Exposure lifecycle, modelled independently of the backend process lifecycle. + * A backend can be `running`/healthy while its HTTPS exposure is still + * `pending`, `failed`, or `cleanup_pending`. + */ +export type RuntimeExposureState = + | "pending" + | "ready" + | "failed" + | "cleanup_pending" + | "removed"; + +/** Purpose of a single owned HTTPS-to-loopback listener. */ +export type RuntimeExposureListenerPurpose = "app" | "vite_hmr"; + +/** + * One HTTPS-to-loopback mapping owned by this runtime service. `publicPort` + * always equals `targetPort` (same-number invariant); the target is loopback. + */ +export interface RuntimeExposureListener { + purpose: RuntimeExposureListenerPurpose; + publicPort: number; + targetPort: number; +} + +/** + * Structured exposure state persisted on the runtime-service record and echoed + * through server serializers to the UI. Never contains privileged commands, + * lease handles, host paths, or raw broker/CLI output. + */ +export interface RuntimeExposureStatus { + provider: RuntimeExposureProvider; + state: RuntimeExposureState; + /** Canonical browser URL once `ready`; null while pending/failed/removed. */ + publicUrl: string | null; + /** Resolved node DNS hostname once known; null otherwise. */ + hostname: string | null; + listeners: RuntimeExposureListener[]; + /** + * Opaque broker reference for reconciliation/audit correlation. Not a lease + * handle and not secret; safe to serialize to the UI. + */ + brokerRef: string | null; + /** Last non-sensitive, bounded error string for actionable UI remediation. */ + lastError: string | null; + updatedAt: string | null; +} diff --git a/packages/shared/src/validators/runtime-exposure.test.ts b/packages/shared/src/validators/runtime-exposure.test.ts new file mode 100644 index 00000000000..73575a1cfa3 --- /dev/null +++ b/packages/shared/src/validators/runtime-exposure.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + parseRuntimeExposureConfig, + runtimeExposureConfigSchema, + runtimeExposureListenerSchema, + runtimeExposureStatusSchema, +} from "./runtime-exposure.js"; + +describe("runtimeExposureConfigSchema", () => { + it("accepts the default fail-closed config", () => { + expect(() => runtimeExposureConfigSchema.parse(DEFAULT_TAILSCALE_HTTPS_EXPOSURE)).not.toThrow(); + }); + + it("rejects unknown fields (no smuggled target/path/hostname suffix)", () => { + expect(() => + runtimeExposureConfigSchema.parse({ + ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + target: "http://127.0.0.1:5432", + }), + ).toThrow(); + }); + + it("rejects arbitrary hostname / publicPort / provider / failure policy", () => { + expect(() => + runtimeExposureConfigSchema.parse({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, hostname: "evil.example" }), + ).toThrow(); + expect(() => + runtimeExposureConfigSchema.parse({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, publicPort: 443 }), + ).toThrow(); + expect(() => + runtimeExposureConfigSchema.parse({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, type: "funnel" }), + ).toThrow(); + expect(() => + runtimeExposureConfigSchema.parse({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, failurePolicy: "fail_open" }), + ).toThrow(); + }); + + it("parseRuntimeExposureConfig returns null when absent and throws when malformed", () => { + expect(parseRuntimeExposureConfig(undefined)).toBeNull(); + expect(parseRuntimeExposureConfig(null)).toBeNull(); + expect(parseRuntimeExposureConfig(DEFAULT_TAILSCALE_HTTPS_EXPOSURE)).toEqual( + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + ); + expect(() => parseRuntimeExposureConfig({ type: "tailscale_https" })).toThrow(); + }); +}); + +describe("runtimeExposureListenerSchema", () => { + it("enforces the same-number invariant", () => { + expect(() => + runtimeExposureListenerSchema.parse({ purpose: "app", publicPort: 42010, targetPort: 42010 }), + ).not.toThrow(); + expect(() => + runtimeExposureListenerSchema.parse({ purpose: "app", publicPort: 42010, targetPort: 5432 }), + ).toThrow(); + }); +}); + +describe("runtimeExposureStatusSchema", () => { + it("accepts a well-formed ready status", () => { + expect(() => + runtimeExposureStatusSchema.parse({ + provider: "tailscale_https", + state: "ready", + publicUrl: "https://paperclip-dev.tail29c1aa.ts.net:42010", + hostname: "paperclip-dev.tail29c1aa.ts.net", + listeners: [ + { purpose: "app", publicPort: 42010, targetPort: 42010 }, + { purpose: "vite_hmr", publicPort: 52010, targetPort: 52010 }, + ], + brokerRef: "rs-1", + lastError: null, + updatedAt: "2026-08-11T00:00:00.000Z", + }), + ).not.toThrow(); + }); + + it("rejects unknown fields in serialized status", () => { + expect(() => + runtimeExposureStatusSchema.parse({ + provider: "tailscale_https", + state: "ready", + publicUrl: null, + hostname: null, + listeners: [], + brokerRef: null, + lastError: null, + updatedAt: null, + leaseHandle: "secret", + }), + ).toThrow(); + }); +}); diff --git a/packages/shared/src/validators/runtime-exposure.ts b/packages/shared/src/validators/runtime-exposure.ts new file mode 100644 index 00000000000..39215da479b --- /dev/null +++ b/packages/shared/src/validators/runtime-exposure.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +/** + * Validators for the opt-in `tailscale_https` runtime exposure contract. + * Strict-by-default: unknown fields are rejected so a malformed or injected + * config cannot smuggle an arbitrary target, path, or hostname suffix through + * the runtime configuration into the broker (PAP-17050 verdict). + */ + +export const runtimeExposureProviderSchema = z.literal("tailscale_https"); + +export const runtimeExposureFailurePolicySchema = z.literal("fail_closed"); + +export const runtimeExposureConfigSchema = z + .object({ + type: runtimeExposureProviderSchema, + hostname: z.literal("auto"), + publicPort: z.literal("same"), + includePaperclipViteHmr: z.boolean(), + failurePolicy: runtimeExposureFailurePolicySchema, + }) + .strict(); + +export const runtimeExposureStateSchema = z.enum([ + "pending", + "ready", + "failed", + "cleanup_pending", + "removed", +]); + +export const runtimeExposureListenerPurposeSchema = z.enum(["app", "vite_hmr"]); + +export const runtimeExposureListenerSchema = z + .object({ + purpose: runtimeExposureListenerPurposeSchema, + publicPort: z.number().int().positive(), + targetPort: z.number().int().positive(), + }) + .strict() + // Same-number invariant: the public HTTPS port must equal the loopback + // target port. The broker enforces this too, but rejecting here keeps + // persisted/serialized state honest. + .refine((listener) => listener.publicPort === listener.targetPort, { + message: "publicPort must equal targetPort (same-number exposure)", + path: ["publicPort"], + }); + +export const runtimeExposureStatusSchema = z + .object({ + provider: runtimeExposureProviderSchema, + state: runtimeExposureStateSchema, + publicUrl: z.string().url().nullable(), + hostname: z.string().min(1).nullable(), + listeners: z.array(runtimeExposureListenerSchema), + brokerRef: z.string().min(1).nullable(), + lastError: z.string().nullable(), + updatedAt: z.string().nullable(), + }) + .strict(); + +export type RuntimeExposureConfigInput = z.infer; +export type RuntimeExposureStatusInput = z.infer; + +/** + * Default exposure config used when a project/workspace enables HTTPS exposure + * without overriding sub-fields. Fail-closed and HMR-inclusive by default so + * dev-mode hot reload works over the exposed HTTPS origin. + */ +export const DEFAULT_TAILSCALE_HTTPS_EXPOSURE: RuntimeExposureConfigInput = { + type: "tailscale_https", + hostname: "auto", + publicPort: "same", + includePaperclipViteHmr: true, + failurePolicy: "fail_closed", +}; + +/** + * Parse an untrusted `expose` block off a workspace runtime service config. + * Returns null when exposure is absent; throws (via zod) on a malformed block + * so misconfiguration fails closed rather than silently exposing nothing. + */ +export function parseRuntimeExposureConfig( + value: unknown, +): RuntimeExposureConfigInput | null { + if (value === undefined || value === null) return null; + return runtimeExposureConfigSchema.parse(value); +} diff --git a/packages/tailscale-https-broker/package.json b/packages/tailscale-https-broker/package.json new file mode 100644 index 00000000000..b30081f8c8f --- /dev/null +++ b/packages/tailscale-https-broker/package.json @@ -0,0 +1,24 @@ +{ + "name": "@paperclipai/tailscale-https-broker", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "description": "Least-privilege host broker that manages only Paperclip-owned, same-number Tailscale HTTPS-to-loopback listeners for managed branch runtimes.", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "bin": { + "paperclip-tailscale-https-broker": "./src/main.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "typescript": "^5.7.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/tailscale-https-broker/src/argv.test.ts b/packages/tailscale-https-broker/src/argv.test.ts new file mode 100644 index 00000000000..26f44dd219b --- /dev/null +++ b/packages/tailscale-https-broker/src/argv.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { buildExposeArgv, buildRemoveArgv, buildStatusArgv } from "./argv.js"; + +const BIN = "/usr/bin/tailscale"; + +describe("argv construction", () => { + it("builds an exact status argv with no shell tokens", () => { + expect(buildStatusArgv(BIN)).toEqual([BIN, "serve", "status", "--json"]); + }); + + it("builds exact same-number expose/remove argv vectors", () => { + expect(buildExposeArgv(BIN, 42010)).toEqual([ + BIN, + "serve", + "--bg", + "--https=42010", + "http://127.0.0.1:42010", + ]); + expect(buildRemoveArgv(BIN, 42010)).toEqual([BIN, "serve", "--https=42010", "off"]); + }); + + it("refuses the protected primary port 443 and privileged ports", () => { + expect(() => buildExposeArgv(BIN, 443)).toThrow(/443/); + expect(() => buildRemoveArgv(BIN, 443)).toThrow(/443/); + expect(() => buildExposeArgv(BIN, 80)).toThrow(/privileged/); + expect(() => buildExposeArgv(BIN, 1023)).toThrow(/privileged/); + }); + + it("refuses non-canonical ports (string, float, overflow)", () => { + expect(() => buildExposeArgv(BIN, "42010" as unknown as number)).toThrow(); + expect(() => buildExposeArgv(BIN, 42010.5)).toThrow(); + expect(() => buildExposeArgv(BIN, 70000)).toThrow(); + }); + + it("refuses a non-absolute or shell-metacharacter binary path", () => { + expect(() => buildStatusArgv("tailscale")).toThrow(/absolute/); + expect(() => buildStatusArgv("/usr/bin/tailscale; rm -rf /")).toThrow(); + expect(() => buildStatusArgv("/usr/bin/tail scale")).toThrow(); + }); +}); diff --git a/packages/tailscale-https-broker/src/argv.ts b/packages/tailscale-https-broker/src/argv.ts new file mode 100644 index 00000000000..d6a5573314b --- /dev/null +++ b/packages/tailscale-https-broker/src/argv.ts @@ -0,0 +1,61 @@ +/** + * Tailscale CLI argv construction. Every command is a fixed token vector with a + * single validated integer interpolated; there is never a shell, an arbitrary + * target URL, a path handler, Funnel, cert, Service, reset, or set-config + * operation (PAP-17049 plan; PAP-17050 verdict requirement #4 + invariants). + * + * The caller must pass an absolute, root-owned tailscale binary path. Callers + * spawn with shell:false and a minimal environment. + */ +import { assertCanonicalPort } from "./integers.js"; +import { PROTECTED_PRIMARY_PORT } from "./types.js"; + +/** The loopback target is always same-number and always plain-http loopback. */ +export function loopbackTarget(port: number): string { + assertCanonicalPort(port); + return `http://127.0.0.1:${port}`; +} + +function assertMutablePort(port: number): number { + const p = assertCanonicalPort(port); + if (p === PROTECTED_PRIMARY_PORT) { + throw new Error("refusing to operate on the protected primary port 443"); + } + if (p < 1024) { + throw new Error("refusing to operate on a privileged/reserved port (<1024)"); + } + return p; +} + +/** `tailscale serve status --json` — read-only. */ +export function buildStatusArgv(binPath: string): string[] { + assertAbsolute(binPath); + return [binPath, "serve", "status", "--json"]; +} + +/** + * Add one same-number HTTPS-to-loopback listener in the background without + * disturbing other Serve entries. + */ +export function buildExposeArgv(binPath: string, port: number): string[] { + assertAbsolute(binPath); + const p = assertMutablePort(port); + return [binPath, "serve", "--bg", `--https=${p}`, loopbackTarget(p)]; +} + +/** Remove exactly one HTTPS listener by port. Never `reset`, never `off` all. */ +export function buildRemoveArgv(binPath: string, port: number): string[] { + assertAbsolute(binPath); + const p = assertMutablePort(port); + return [binPath, "serve", `--https=${p}`, "off"]; +} + +function assertAbsolute(binPath: string): void { + if (typeof binPath !== "string" || !binPath.startsWith("/")) { + throw new Error("tailscale binary path must be absolute"); + } + // No shell metacharacters, whitespace, or NUL in the pinned binary path. + if (/[\s;&|`$<>(){}\\"'*?\0]/.test(binPath)) { + throw new Error("tailscale binary path contains disallowed characters"); + } +} diff --git a/packages/tailscale-https-broker/src/audit.ts b/packages/tailscale-https-broker/src/audit.ts new file mode 100644 index 00000000000..f78225ccd6a --- /dev/null +++ b/packages/tailscale-https-broker/src/audit.ts @@ -0,0 +1,90 @@ +/** + * Append-only, bounded, redacted security audit (PAP-17050 verdict req #6). + * + * Every allow/deny and mutation outcome emits one event. Lease handles, + * bearer/session material, full environments, and raw unbounded CLI output are + * NEVER logged. All string fields are control-character-sanitized and length + * bounded to defeat log forging. + */ +import { appendFileSync } from "node:fs"; +import type { BrokerErrorCode, PeerCredentials } from "./types.js"; + +const MAX_FIELD_LEN = 256; + +export interface AuditEvent { + timestampIso: string; + peer: PeerCredentials | null; + op: string; + runtimeId: string | null; + ports: number[]; + requestId: string | null; + decision: "allow" | "deny"; + reasonCode: BrokerErrorCode | "ok"; + reason: string; + beforeDigest?: string; + afterDigest?: string; + cliExitCategory?: "ok" | "error" | "timeout" | "none"; + recovery?: "none" | "quarantine" | "cleanup"; +} + +/** Strip control chars (defeats newline/log-forging) and bound length. */ +export function sanitizeField(value: string): string { + // eslint-disable-next-line no-control-regex + const stripped = value.replace(/[\x00-\x1f\x7f]/g, " "); + return stripped.length > MAX_FIELD_LEN ? `${stripped.slice(0, MAX_FIELD_LEN)}…` : stripped; +} + +export function formatAuditLine(event: AuditEvent): string { + const safe = { + ts: event.timestampIso, + uid: event.peer?.uid ?? null, + gid: event.peer?.gid ?? null, + pid: event.peer?.pid ?? null, + op: sanitizeField(event.op), + runtimeId: event.runtimeId ? sanitizeField(event.runtimeId) : null, + ports: event.ports.filter((p) => Number.isInteger(p)).slice(0, 8), + requestId: event.requestId ? sanitizeField(event.requestId) : null, + decision: event.decision, + reasonCode: event.reasonCode, + reason: sanitizeField(event.reason), + beforeDigest: event.beforeDigest ? sanitizeField(event.beforeDigest) : undefined, + afterDigest: event.afterDigest ? sanitizeField(event.afterDigest) : undefined, + cliExitCategory: event.cliExitCategory, + recovery: event.recovery, + }; + return JSON.stringify(safe); +} + +export interface AuditSink { + write(event: AuditEvent): void; +} + +/** + * Durable file audit sink. If the recommended `blockOnFailure` is set, a write + * failure throws so the caller can fail the mutation closed rather than mutate + * without a durable record. + */ +export class FileAuditSink implements AuditSink { + constructor( + private readonly path: string, + private readonly blockOnFailure = true, + ) {} + + write(event: AuditEvent): void { + try { + appendFileSync(this.path, `${formatAuditLine(event)}\n`, { mode: 0o600 }); + } catch (error) { + if (this.blockOnFailure) { + throw new Error(`audit sink write failed: ${(error as Error).message}`); + } + } + } +} + +/** In-memory sink for tests. */ +export class MemoryAuditSink implements AuditSink { + readonly events: AuditEvent[] = []; + write(event: AuditEvent): void { + this.events.push(event); + } +} diff --git a/packages/tailscale-https-broker/src/authorization.test.ts b/packages/tailscale-https-broker/src/authorization.test.ts new file mode 100644 index 00000000000..94084c927ef --- /dev/null +++ b/packages/tailscale-https-broker/src/authorization.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + AuthorizationError, + authorizePeer, + authorizeRemoval, + generateLeaseHandle, + handlesEqual, +} from "./authorization.js"; +import type { LeaseRecord } from "./types.js"; + +const policy = { + allowedUids: new Set([999]), + allowedGids: new Set([987]), +}; + +describe("authorizePeer (complete mediation)", () => { + it("accepts the exact allowlisted identity", () => { + expect(() => authorizePeer({ uid: 999, gid: 987, pid: 1 }, policy)).not.toThrow(); + }); + + it("rejects wrong uid, wrong gid, and missing creds", () => { + expect(() => authorizePeer({ uid: 1000, gid: 987, pid: 1 }, policy)).toThrow(AuthorizationError); + expect(() => authorizePeer({ uid: 999, gid: 100, pid: 1 }, policy)).toThrow(/gid/); + expect(() => authorizePeer({ uid: Number.NaN, gid: 987, pid: 1 }, policy)).toThrow(); + }); +}); + +describe("lease handles", () => { + it("generates unguessable, distinct, url-safe handles", () => { + const a = generateLeaseHandle(); + const b = generateLeaseHandle(); + expect(a).not.toBe(b); + expect(a).toMatch(/^[A-Za-z0-9_-]{16,}$/); + expect(handlesEqual(a, a)).toBe(true); + expect(handlesEqual(a, b)).toBe(false); + }); +}); + +describe("authorizeRemoval (ownership binding)", () => { + const lease: LeaseRecord = { + handle: "handle-aaaaaaaaaaaaaaaa", + runtimeId: "2af79bb1-ecc5-4410-8438-091be135a921", + peerUid: 999, + peerGid: 987, + ports: [42010, 52010], + purposes: ["app", "vite_hmr"], + generation: 1, + createdAtIso: "2026-08-11T00:00:00.000Z", + }; + const peer = { uid: 999, gid: 987, pid: 42 }; + + it("authorizes an exact handle + runtime + peer match", () => { + expect(authorizeRemoval([lease], { runtimeId: lease.runtimeId, handle: lease.handle }, peer)).toBe(lease); + }); + + it("rejects random/stale handles", () => { + expect(() => + authorizeRemoval([lease], { runtimeId: lease.runtimeId, handle: "random-handle-xxxxxx" }, peer), + ).toThrow(/invalid_handle|stale/); + }); + + it("rejects runtime A removing runtime B's lease", () => { + expect(() => + authorizeRemoval([lease], { runtimeId: "00000000-0000-0000-0000-000000000000", handle: lease.handle }, peer), + ).toThrow(/runtime/); + }); + + it("rejects a wrong peer identity even with the right handle", () => { + expect(() => + authorizeRemoval([lease], { runtimeId: lease.runtimeId, handle: lease.handle }, { uid: 1000, gid: 987, pid: 7 }), + ).toThrow(/peer/); + }); +}); diff --git a/packages/tailscale-https-broker/src/authorization.ts b/packages/tailscale-https-broker/src/authorization.ts new file mode 100644 index 00000000000..10970afcfb6 --- /dev/null +++ b/packages/tailscale-https-broker/src/authorization.ts @@ -0,0 +1,87 @@ +/** + * Peer authorization and lease-handle ownership (PAP-17050 verdict req #1). + * + * SO_PEERCRED authenticates an OS principal; a caller-supplied runtime ID does + * NOT prove ownership. Authorization therefore has two layers: + * 1. An exact UID/GID allowlist checked with peer credentials on EVERY + * accepted connection (complete mediation). + * 2. Ownership defined as a broker-issued, unguessable lease handle returned + * by `expose` and required by `remove`, bound to peer identity, runtime + * UUID, ports, and generation. `list` never returns handles. + */ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import type { LeaseRecord, PeerCredentials } from "./types.js"; + +export interface PeerPolicy { + /** Exact set of allowed peer UIDs (the Paperclip service identity). */ + allowedUids: ReadonlySet; + /** Exact set of allowed peer GIDs (the dedicated broker socket group). */ + allowedGids: ReadonlySet; +} + +export class AuthorizationError extends Error { + constructor( + readonly code: + | "unauthorized_peer" + | "invalid_handle" + | "listener_ownership_mismatch", + message: string, + ) { + super(message); + this.name = "AuthorizationError"; + } +} + +/** + * Complete-mediation check run on every accepted connection before any request + * is even decoded. Throws AuthorizationError("unauthorized_peer") on any + * mismatch. Supplemental-group-only membership does not satisfy the GID check + * because peer.gid is the process's primary GID from SO_PEERCRED. + */ +export function authorizePeer(peer: PeerCredentials, policy: PeerPolicy): void { + if (!Number.isInteger(peer.uid) || !Number.isInteger(peer.gid)) { + throw new AuthorizationError("unauthorized_peer", "missing peer credentials"); + } + if (!policy.allowedUids.has(peer.uid)) { + throw new AuthorizationError("unauthorized_peer", `uid ${peer.uid} not allowlisted`); + } + if (!policy.allowedGids.has(peer.gid)) { + throw new AuthorizationError("unauthorized_peer", `gid ${peer.gid} not allowlisted`); + } +} + +/** Generate an unguessable lease handle (256 bits, url-safe). */ +export function generateLeaseHandle(): string { + return randomBytes(32).toString("base64url"); +} + +/** Constant-time handle comparison to avoid timing oracles. */ +export function handlesEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, "utf8"); + const bb = Buffer.from(b, "utf8"); + if (ab.byteLength !== bb.byteLength) return false; + return timingSafeEqual(ab, bb); +} + +/** + * Resolve the lease a `remove` request is authorized to act on. Requires an + * exact handle match AND that the requesting peer + runtime UUID match the + * lease bound at expose time. Runtime A can never remove runtime B's listener. + */ +export function authorizeRemoval( + leases: readonly LeaseRecord[], + request: { runtimeId: string; handle: string }, + peer: PeerCredentials, +): LeaseRecord { + const lease = leases.find((entry) => handlesEqual(entry.handle, request.handle)); + if (!lease) { + throw new AuthorizationError("invalid_handle", "unknown or stale lease handle"); + } + if (lease.runtimeId !== request.runtimeId) { + throw new AuthorizationError("listener_ownership_mismatch", "runtime id does not match lease"); + } + if (lease.peerUid !== peer.uid || lease.peerGid !== peer.gid) { + throw new AuthorizationError("listener_ownership_mismatch", "peer identity does not match lease"); + } + return lease; +} diff --git a/packages/tailscale-https-broker/src/broker-core.test.ts b/packages/tailscale-https-broker/src/broker-core.test.ts new file mode 100644 index 00000000000..7bada1422b3 --- /dev/null +++ b/packages/tailscale-https-broker/src/broker-core.test.ts @@ -0,0 +1,278 @@ +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MemoryAuditSink } from "./audit.js"; +import { BrokerCore, type CliResult, type ListenerOwnership } from "./broker-core.js"; +import { defaultIsAllowedPort } from "./port-policy.js"; +import type { BrokerRequest, PeerCredentials } from "./types.js"; + +const HOST = "paperclip-dev.tail29c1aa.ts.net"; +const BIN = "/usr/bin/tailscale"; +const RUNTIME_A = "2af79bb1-ecc5-4410-8438-091be135a921"; +const RUNTIME_B = "3108ef8e-5ed0-41d9-b561-6b41c41b8545"; +const PEER: PeerCredentials = { uid: 999, gid: 987, pid: 4242 }; + +/** In-memory tailscale serve fake driven by exact argv vectors. */ +class FakeTailscale { + ports = new Map([[443, "http://127.0.0.1:3100"]]); + failExposePort: number | null = null; + failRemove = false; + sideEffectOnExpose: number | null = null; + retargetPrimaryOnExpose = false; + exposeCalls = 0; + removeCalls = 0; + + run = (argv: string[]): CliResult => { + const [, sub, a2, a3] = argv; + if (sub === "serve" && a2 === "status") { + return { code: 0, stdout: this.statusJson(), stderr: "", timedOut: false }; + } + if (sub === "serve" && a2 === "--bg") { + this.exposeCalls += 1; + const port = Number(a3.replace("--https=", "")); + if (this.failExposePort === port) return { code: 1, stdout: "", stderr: "boom", timedOut: false }; + this.ports.set(port, `http://127.0.0.1:${port}`); + if (this.sideEffectOnExpose) this.ports.set(this.sideEffectOnExpose, `http://127.0.0.1:${this.sideEffectOnExpose}`); + if (this.retargetPrimaryOnExpose) this.ports.set(443, "http://127.0.0.1:9999"); + return { code: 0, stdout: "", stderr: "", timedOut: false }; + } + if (sub === "serve" && a2.startsWith("--https=") && a3 === "off") { + this.removeCalls += 1; + if (this.failRemove) return { code: 1, stdout: "", stderr: "no", timedOut: false }; + this.ports.delete(Number(a2.replace("--https=", ""))); + return { code: 0, stdout: "", stderr: "", timedOut: false }; + } + return { code: 2, stdout: "", stderr: "unknown", timedOut: false }; + }; + + private statusJson(): string { + const TCP: Record = {}; + const Web: Record = {}; + for (const [port, proxy] of this.ports) { + TCP[String(port)] = { HTTPS: true }; + Web[`${HOST}:${port}`] = { Handlers: { "/": { Proxy: proxy } } }; + } + return JSON.stringify({ TCP, Web }); + } +} + +function makeCore( + fake: FakeTailscale, + registryPath: string, + ownership: (port: number) => ListenerOwnership = () => ({ + present: true, + loopbackOnly: true, + ownerUidMatches: true, + }), +) { + const audit = new MemoryAuditSink(); + const core = new BrokerCore({ + tailscaleBinPath: BIN, + registryPath, + auditSink: audit, + peerPolicy: { allowedUids: new Set([999]), allowedGids: new Set([987]) }, + nodeIdentity: "node-1", + isAllowedPort: defaultIsAllowedPort, + deps: { + runTailscale: fake.run, + verifyListenerOwnership: ownership, + nowIso: () => "2026-08-11T00:00:00.000Z", + }, + }); + return { core, audit }; +} + +const exposeReq = (runtimeId = RUNTIME_A, port = 42010): BrokerRequest => ({ + op: "expose", + requestId: "req-e", + runtimeId, + listeners: [{ purpose: "app", port }], +}); + +let dir: string; +let registryPath: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "broker-test-")); + registryPath = join(dir, "registry.json"); +}); +afterEach(() => { + // tmp dir is left for the OS to reap; tests use unique dirs. +}); + +describe("expose", () => { + it("exposes a same-number loopback listener and persists a lease", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + const res = await core.handle(exposeReq(), PEER); + expect(res.ok).toBe(true); + if (!res.ok || res.op !== "expose") throw new Error("expected expose ok"); + expect(res.publicPorts).toEqual([42010]); + expect(res.handle).toMatch(/^[A-Za-z0-9_-]{16,}$/); + expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010"); + const registry = JSON.parse(readFileSync(registryPath, "utf8")); + expect(registry.leases[0].ports).toEqual([42010]); + // Handle is persisted server-side but never appears in list output. + }); + + it("is idempotent: re-exposing the same port does not double-apply", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + await core.handle(exposeReq(), PEER); + const before = fake.exposeCalls; + const res = await core.handle(exposeReq(), PEER); + expect(res.ok).toBe(true); + expect(fake.exposeCalls).toBe(before); // no additional CLI mutation + }); + + it("rejects an unauthorized peer without mutating serve", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + const res = await core.handle(exposeReq(), { uid: 1000, gid: 987, pid: 1 }); + expect(res.ok).toBe(false); + if (res.ok) throw new Error("unreachable"); + expect(res.code).toBe("unauthorized_peer"); + expect(fake.ports.has(42010)).toBe(false); + }); + + it("rejects a port outside the dedicated allowlist", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + const res = await core.handle(exposeReq(RUNTIME_A, 8080), PEER); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("port_not_allowlisted"); + }); + + it("rejects when the loopback listener is not owned / not loopback-only / absent (SSRF guard)", async () => { + for (const bad of [ + { present: true, loopbackOnly: false, ownerUidMatches: true }, + { present: true, loopbackOnly: true, ownerUidMatches: false }, + { present: false, loopbackOnly: true, ownerUidMatches: true }, + ]) { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath, () => bad); + const res = await core.handle(exposeReq(), PEER); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("listener_ownership_mismatch"); + expect(fake.ports.has(42010)).toBe(false); + } + }); + + it("never touches a pre-existing manual mapping on the target port", async () => { + const fake = new FakeTailscale(); + fake.ports.set(42010, "http://127.0.0.1:5432"); // manual/unrelated service + const { core } = makeCore(fake, registryPath); + const res = await core.handle(exposeReq(), PEER); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("manual_mapping_present"); + expect(fake.ports.get(42010)).toBe("http://127.0.0.1:5432"); // unchanged + }); + + it("fails closed and preserves :443 if a mutation retargets the primary route", async () => { + const fake = new FakeTailscale(); + fake.retargetPrimaryOnExpose = true; + const { core } = makeCore(fake, registryPath); + const res = await core.handle(exposeReq(), PEER); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("primary_route_violation"); + }); + + it("compensates and quarantines on an unexpected serve diff", async () => { + const fake = new FakeTailscale(); + fake.sideEffectOnExpose = 42011; // an unexpected extra entry appears + fake.failRemove = true; // compensation cannot be proven -> quarantine + const { core } = makeCore(fake, registryPath); + const res = await core.handle(exposeReq(), PEER); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("unexpected_serve_diff"); + const registry = JSON.parse(readFileSync(registryPath, "utf8")); + expect(registry.quarantinedPorts).toContain(42010); + // A later expose of the quarantined port is refused. + const fake2 = new FakeTailscale(); + const { core: core2 } = makeCore(fake2, registryPath); + const res2 = await core2.handle(exposeReq(), PEER); + expect(res2.ok).toBe(false); + if (!res2.ok) expect(res2.code).toBe("quarantined"); + }); +}); + +describe("remove", () => { + async function exposeAndGetHandle(core: BrokerCore, port = 42010, runtime = RUNTIME_A) { + const res = await core.handle(exposeReq(runtime, port), PEER); + if (!res.ok || res.op !== "expose") throw new Error("expose failed"); + return res.handle; + } + + it("removes only the owned listener with an exact lease match", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + const handle = await exposeAndGetHandle(core); + const res = await core.handle( + { op: "remove", requestId: "req-r", runtimeId: RUNTIME_A, handle }, + PEER, + ); + expect(res.ok).toBe(true); + if (res.ok && res.op === "remove") expect(res.removedPorts).toEqual([42010]); + expect(fake.ports.has(42010)).toBe(false); + expect(fake.ports.get(443)).toBe("http://127.0.0.1:3100"); // primary intact + }); + + it("rejects a random handle and a cross-runtime removal", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + const handle = await exposeAndGetHandle(core); + const bad = await core.handle( + { op: "remove", requestId: "r", runtimeId: RUNTIME_A, handle: "random-handle-not-real-xxxx" }, + PEER, + ); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.code).toBe("invalid_handle"); + const cross = await core.handle( + { op: "remove", requestId: "r", runtimeId: RUNTIME_B, handle }, + PEER, + ); + expect(cross.ok).toBe(false); + if (!cross.ok) expect(cross.code).toBe("listener_ownership_mismatch"); + expect(fake.ports.has(42010)).toBe(true); // still there + }); +}); + +describe("list", () => { + it("returns caller-owned ports and never lease handles", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + await core.handle(exposeReq(), PEER); + const res = await core.handle({ op: "list", requestId: "req-l" }, PEER); + expect(res.ok).toBe(true); + if (res.ok && res.op === "list") { + expect(res.listeners).toEqual([{ runtimeId: RUNTIME_A, port: 42010, purpose: "app" }]); + expect(JSON.stringify(res)).not.toMatch(/handle/); + } + }); +}); + +describe("node identity", () => { + it("quarantines when the persisted node identity no longer matches", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + await core.handle(exposeReq(), PEER); + // Rebuild a core with a different node identity against the same registry. + const audit = new MemoryAuditSink(); + const core2 = new BrokerCore({ + tailscaleBinPath: BIN, + registryPath, + auditSink: audit, + peerPolicy: { allowedUids: new Set([999]), allowedGids: new Set([987]) }, + nodeIdentity: "node-CHANGED", + isAllowedPort: defaultIsAllowedPort, + deps: { + runTailscale: fake.run, + verifyListenerOwnership: () => ({ present: true, loopbackOnly: true, ownerUidMatches: true }), + nowIso: () => "2026-08-11T00:00:00.000Z", + }, + }); + const res = await core2.handle(exposeReq(RUNTIME_B, 42011), PEER); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("quarantined"); + }); +}); diff --git a/packages/tailscale-https-broker/src/broker-core.ts b/packages/tailscale-https-broker/src/broker-core.ts new file mode 100644 index 00000000000..39690bf1e5e --- /dev/null +++ b/packages/tailscale-https-broker/src/broker-core.ts @@ -0,0 +1,432 @@ +/** + * Broker transaction core. One serialized, fail-closed transaction per + * mutation implements every PAP-17050 verdict requirement and invariant: + * + * - Complete peer mediation + lease-handle ownership (req #1). + * - Dedicated-range + immediately-before /proc listener-ownership check + * defeats SSRF-equivalent publication of unrelated loopback services (#2). + * - Read → strict-parse → verify absence-or-exact-lease-match → verify + * protected :443 → one fixed per-port op → reread → require only the + * intended entry changed and :443 identical → atomic registry commit; any + * ambiguity quarantines and fails closed (#3). + * - Argv-only, shell:false CLI via an injected runner (#4). + * - Bounded, serialized mutation queue (#5). + * - One audit event per allow/deny/outcome (#6). + */ +import { + AuthorizationError, + authorizePeer, + authorizeRemoval, + generateLeaseHandle, + type PeerPolicy, +} from "./authorization.js"; +import { buildExposeArgv, buildRemoveArgv, buildStatusArgv } from "./argv.js"; +import type { AuditSink } from "./audit.js"; +import { + ParsedServe, + ServeParseError, + assertPrimaryIntact, + changedPorts, + isSameNumberLoopbackEntry, + parseServeStatus, + primaryDigest, +} from "./serve-config.js"; +import { + addLease, + isPortQuarantined, + loadRegistry, + nextGeneration, + quarantinePort, + removeLeaseByHandle, + saveRegistry, +} from "./registry.js"; +import type { + BrokerRegistry, + BrokerRequest, + BrokerResponse, + OwnedListener, + PeerCredentials, +} from "./types.js"; + +/** Result of running a tailscale CLI command (argv, shell:false). */ +export interface CliResult { + code: number; + stdout: string; + stderr: string; + timedOut: boolean; +} + +/** + * Immediately-before-mutation ownership facts about a loopback listener, + * derived from /proc (req #2). `loopbackOnly` must be true (reject wildcard, + * IPv6-wildcard, dual-stack off-loopback); `ownerUidMatches` binds the listener + * to the expected managed-runtime identity; `present` guards the swap race. + */ +export interface ListenerOwnership { + present: boolean; + loopbackOnly: boolean; + ownerUidMatches: boolean; +} + +export interface BrokerDeps { + runTailscale(argv: string[]): CliResult; + /** Inspect a loopback listener immediately before mutation (req #2). */ + verifyListenerOwnership(port: number): ListenerOwnership; + nowIso(): string; +} + +export interface BrokerCoreConfig { + tailscaleBinPath: string; + registryPath: string; + auditSink: AuditSink; + peerPolicy: PeerPolicy; + /** hostname + boot id; a change forces quarantine + operator reconciliation. */ + nodeIdentity: string; + /** Deny-by-default port allowlist. Defaults to the dedicated runtime range. */ + isAllowedPort(port: number): boolean; + deps: BrokerDeps; +} + +function denied(code: string, message: string): never { + const err = new Error(message) as Error & { brokerCode: string }; + err.brokerCode = code; + throw err; +} + +export class BrokerCore { + private queue: Promise = Promise.resolve(); + + constructor(private readonly config: BrokerCoreConfig) {} + + /** Public entry point. Serializes all requests through one mutation queue. */ + async handle(request: BrokerRequest, peer: PeerCredentials): Promise { + const run = this.queue.then(() => this.dispatch(request, peer)); + // Keep the chain alive even if this request rejects. + this.queue = run.catch(() => undefined); + return run; + } + + private async dispatch(request: BrokerRequest, peer: PeerCredentials): Promise { + try { + authorizePeer(peer, this.config.peerPolicy); + } catch (error) { + return this.fail(request.requestId, peer, request.op, "unauthorized_peer", error); + } + try { + switch (request.op) { + case "list": + return this.doList(request, peer); + case "expose": + return this.doExpose(request, peer); + case "remove": + return this.doRemove(request, peer); + } + } catch (error) { + const code = (error as { brokerCode?: string }).brokerCode ?? codeForError(error); + return this.fail(request.requestId, peer, request.op, code, error); + } + } + + private readServe(): ParsedServe { + const result = this.config.deps.runTailscale(buildStatusArgv(this.config.tailscaleBinPath)); + if (result.timedOut) denied("cli_timeout", "serve status timed out"); + if (result.code !== 0) denied("cli_error", "serve status exited non-zero"); + let json: unknown; + try { + json = JSON.parse(result.stdout); + } catch { + denied("serve_parse_error", "serve status returned invalid JSON"); + } + try { + return parseServeStatus(json); + } catch (error) { + if (error instanceof ServeParseError) denied("serve_parse_error", error.message); + throw error; + } + } + + private loadRegistry(): BrokerRegistry { + const registry = loadRegistry(this.config.registryPath, this.config.nodeIdentity); + // Boot/node identity change forces quarantine + operator reconciliation. + if (registry.nodeIdentity !== this.config.nodeIdentity) { + denied("quarantined", "node identity changed; operator reconciliation required"); + } + return registry; + } + + private doExpose( + request: Extract, + peer: PeerCredentials, + ): BrokerResponse { + const registry = this.loadRegistry(); + + // Pre-flight every requested port: allowlist, quarantine, and the + // immediately-before /proc ownership check (req #2). + for (const listener of request.listeners) { + if (!this.config.isAllowedPort(listener.port)) { + denied("port_not_allowlisted", `port ${listener.port} is outside the dedicated range`); + } + if (isPortQuarantined(registry, listener.port)) { + denied("quarantined", `port ${listener.port} is quarantined`); + } + const ownership = this.config.deps.verifyListenerOwnership(listener.port); + if (!ownership.present) { + denied("listener_ownership_mismatch", `no loopback listener on port ${listener.port}`); + } + if (!ownership.loopbackOnly) { + denied("listener_ownership_mismatch", `listener on ${listener.port} is not loopback-only`); + } + if (!ownership.ownerUidMatches) { + denied("listener_ownership_mismatch", `listener on ${listener.port} not owned by managed runtime`); + } + } + + const before = this.readServe(); + assertPrimaryIntact(before); + const beforePrimary = primaryDigest(before); + + // Each target port must be absent or already exactly our same-number entry + // (idempotent re-expose). A manual/unknown entry is never touched. + for (const listener of request.listeners) { + const entry = before.entries.get(listener.port); + if (entry && !isSameNumberLoopbackEntry(entry, listener.port)) { + denied("manual_mapping_present", `port ${listener.port} already has a non-Paperclip mapping`); + } + } + + const appliedPorts: number[] = []; + try { + for (const listener of request.listeners) { + const already = before.entries.get(listener.port); + if (already && isSameNumberLoopbackEntry(already, listener.port)) { + continue; // idempotent + } + const result = this.config.deps.runTailscale( + buildExposeArgv(this.config.tailscaleBinPath, listener.port), + ); + if (result.timedOut) denied("cli_timeout", `expose ${listener.port} timed out`); + if (result.code !== 0) denied("cli_error", `expose ${listener.port} exited non-zero`); + appliedPorts.push(listener.port); + } + + const after = this.readServe(); + // Digest equality (vs the known-good `before`) is the strongest primary + // check: any retarget, removal, or structural change fails closed here as + // a primary_route_violation before any weaker classification runs. + if (primaryDigest(after) !== beforePrimary) { + denied("primary_route_violation", "protected :443 route changed during expose"); + } + assertPrimaryIntact(after); + // Only the intended ports may have changed, and each must now be an exact + // same-number loopback listener. + const diff = new Set(changedPorts(before, after)); + const intended = new Set(request.listeners.map((l) => l.port)); + for (const port of diff) { + if (!intended.has(port)) denied("unexpected_serve_diff", `unexpected change on port ${port}`); + } + for (const listener of request.listeners) { + if (!isSameNumberLoopbackEntry(after.entries.get(listener.port), listener.port)) { + denied("unexpected_serve_diff", `port ${listener.port} not exactly exposed`); + } + } + + const handle = generateLeaseHandle(); + const generation = nextGeneration(registry); + addLease(registry, { + handle, + runtimeId: request.runtimeId, + peerUid: peer.uid, + peerGid: peer.gid, + ports: request.listeners.map((l) => l.port), + purposes: request.listeners.map((l) => l.purpose) as OwnedListener["purpose"][], + generation, + createdAtIso: this.config.deps.nowIso(), + }); + saveRegistry(this.config.registryPath, registry); + + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op: "expose", + runtimeId: request.runtimeId, + ports: request.listeners.map((l) => l.port), + requestId: request.requestId, + decision: "allow", + reasonCode: "ok", + reason: "exposed", + beforeDigest: beforePrimary, + afterDigest: primaryDigest(after), + cliExitCategory: "ok", + recovery: "none", + }); + + return { + ok: true, + op: "expose", + requestId: request.requestId, + handle, + publicPorts: request.listeners.map((l) => l.port), + }; + } catch (error) { + // Partial success is not healthy: compensate by removing only the exact + // entries we applied; if compensation cannot be proven, quarantine. + this.compensateExpose(appliedPorts, registry); + throw error; + } + } + + private compensateExpose(appliedPorts: number[], registry: BrokerRegistry): void { + for (const port of appliedPorts) { + let cleaned = false; + try { + const result = this.config.deps.runTailscale( + buildRemoveArgv(this.config.tailscaleBinPath, port), + ); + cleaned = !result.timedOut && result.code === 0; + } catch { + cleaned = false; + } + if (!cleaned) quarantinePort(registry, port); + } + try { + saveRegistry(this.config.registryPath, registry); + } catch { + /* best effort; registry may already reflect quarantine on next load */ + } + } + + private doRemove( + request: Extract, + peer: PeerCredentials, + ): BrokerResponse { + const registry = this.loadRegistry(); + let lease; + try { + lease = authorizeRemoval(registry.leases, request, peer); + } catch (error) { + if (error instanceof AuthorizationError) denied(error.code, error.message); + throw error; + } + + const before = this.readServe(); + assertPrimaryIntact(before); + const beforePrimary = primaryDigest(before); + + const removedPorts: number[] = []; + for (const port of lease.ports) { + const entry = before.entries.get(port); + if (!entry) continue; // already gone; idempotent + if (!isSameNumberLoopbackEntry(entry, port)) { + // Unknown/manual/mismatched entry — never modify; cannot prove cleanup. + quarantinePort(registry, port); + saveRegistry(this.config.registryPath, registry); + denied("listener_ownership_mismatch", `port ${port} does not match the owned lease entry`); + } + const result = this.config.deps.runTailscale( + buildRemoveArgv(this.config.tailscaleBinPath, port), + ); + if (result.timedOut) denied("cli_timeout", `remove ${port} timed out`); + if (result.code !== 0) denied("cli_error", `remove ${port} exited non-zero`); + removedPorts.push(port); + } + + const after = this.readServe(); + if (primaryDigest(after) !== beforePrimary) { + denied("primary_route_violation", "protected :443 route changed during remove"); + } + assertPrimaryIntact(after); + const diff = new Set(changedPorts(before, after)); + for (const port of diff) { + if (!lease.ports.includes(port)) denied("unexpected_serve_diff", `unexpected change on port ${port}`); + } + for (const port of removedPorts) { + if (after.entries.get(port)) denied("unexpected_serve_diff", `port ${port} still present after remove`); + } + + removeLeaseByHandle(registry, lease.handle); + saveRegistry(this.config.registryPath, registry); + + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op: "remove", + runtimeId: request.runtimeId, + ports: lease.ports, + requestId: request.requestId, + decision: "allow", + reasonCode: "ok", + reason: "removed", + beforeDigest: beforePrimary, + afterDigest: primaryDigest(after), + cliExitCategory: "ok", + recovery: "cleanup", + }); + + return { ok: true, op: "remove", requestId: request.requestId, removedPorts }; + } + + private doList( + request: Extract, + peer: PeerCredentials, + ): BrokerResponse { + const registry = this.loadRegistry(); + const listeners = registry.leases + .filter((lease) => lease.peerUid === peer.uid && lease.peerGid === peer.gid) + .flatMap((lease) => + lease.ports.map((port, index) => ({ + runtimeId: lease.runtimeId, + port, + purpose: lease.purposes[index] ?? "app", + })), + ); + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op: "list", + runtimeId: null, + ports: listeners.map((l) => l.port), + requestId: request.requestId, + decision: "allow", + reasonCode: "ok", + reason: "listed", + }); + return { ok: true, op: "list", requestId: request.requestId, listeners }; + } + + private fail( + requestId: string | null, + peer: PeerCredentials | null, + op: string, + code: string, + error: unknown, + ): BrokerResponse { + const message = error instanceof Error ? error.message : String(error); + try { + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op, + runtimeId: null, + ports: [], + requestId, + decision: "deny", + reasonCode: code as never, + reason: message, + }); + } catch { + /* never let an audit failure mask the denial response */ + } + return { ok: false, requestId, code: code as never, message: safeMessage(code) }; + } +} + +function codeForError(error: unknown): string { + if (error instanceof ServeParseError) return "serve_parse_error"; + if (error instanceof AuthorizationError) return error.code; + return "internal_error"; +} + +/** Client-facing message is a stable label; never echoes host/command detail. */ +function safeMessage(code: string): string { + return code; +} diff --git a/packages/tailscale-https-broker/src/config.ts b/packages/tailscale-https-broker/src/config.ts new file mode 100644 index 00000000000..d4bb0576c4e --- /dev/null +++ b/packages/tailscale-https-broker/src/config.ts @@ -0,0 +1,54 @@ +/** + * Environment-driven configuration for the broker host service. All values are + * validated; unsafe or missing required values make the broker refuse to start + * (fail closed). + */ +import { registryPathUnsafeReason } from "./registry.js"; + +export interface BrokerHostConfig { + socketPath: string; + registryPath: string; + auditPath: string; + tailscaleBinPath: string; + nodeIdentity: string; + serviceUid: number; + serviceGid: number; + runtimeUid: number; + trustSocketPermissions: boolean; +} + +function requireEnv(env: NodeJS.ProcessEnv, key: string): string { + const value = env[key]; + if (!value || value.trim().length === 0) { + throw new Error(`missing required env: ${key}`); + } + return value; +} + +function requireUid(env: NodeJS.ProcessEnv, key: string): number { + const raw = requireEnv(env, key); + if (!/^[0-9]+$/.test(raw)) throw new Error(`${key} must be a non-negative integer`); + return Number(raw); +} + +export function loadHostConfig(env: NodeJS.ProcessEnv): BrokerHostConfig { + const config: BrokerHostConfig = { + socketPath: env.BROKER_SOCKET_PATH ?? "/run/paperclip-tailscale-broker/broker.sock", + registryPath: env.BROKER_REGISTRY_PATH ?? "/var/lib/paperclip-tailscale-broker/registry.json", + auditPath: env.BROKER_AUDIT_PATH ?? "/var/log/paperclip-tailscale-broker/audit.log", + tailscaleBinPath: env.BROKER_TAILSCALE_BIN ?? "/usr/bin/tailscale", + nodeIdentity: requireEnv(env, "BROKER_NODE_IDENTITY"), + serviceUid: requireUid(env, "BROKER_SERVICE_UID"), + serviceGid: requireUid(env, "BROKER_SERVICE_GID"), + runtimeUid: requireUid(env, "BROKER_RUNTIME_UID"), + trustSocketPermissions: env.BROKER_TRUST_SOCKET_PERMISSIONS === "true", + }; + if (!config.tailscaleBinPath.startsWith("/")) { + throw new Error("BROKER_TAILSCALE_BIN must be an absolute path"); + } + const unsafe = registryPathUnsafeReason(config.registryPath); + if (unsafe) { + throw new Error(`refusing to start: ${unsafe} (${config.registryPath})`); + } + return config; +} diff --git a/packages/tailscale-https-broker/src/index.ts b/packages/tailscale-https-broker/src/index.ts new file mode 100644 index 00000000000..b37b28ce657 --- /dev/null +++ b/packages/tailscale-https-broker/src/index.ts @@ -0,0 +1,11 @@ +export * from "./types.js"; +export * from "./integers.js"; +export * from "./strict-json.js"; +export * from "./protocol.js"; +export * from "./argv.js"; +export * from "./serve-config.js"; +export * from "./authorization.js"; +export * from "./registry.js"; +export * from "./audit.js"; +export * from "./port-policy.js"; +export * from "./broker-core.js"; diff --git a/packages/tailscale-https-broker/src/integers.test.ts b/packages/tailscale-https-broker/src/integers.test.ts new file mode 100644 index 00000000000..76000bbf40e --- /dev/null +++ b/packages/tailscale-https-broker/src/integers.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { assertCanonicalPort, parseCanonicalIntegerString } from "./integers.js"; + +describe("assertCanonicalPort", () => { + it("accepts in-range integers", () => { + expect(assertCanonicalPort(1)).toBe(1); + expect(assertCanonicalPort(42010)).toBe(42010); + expect(assertCanonicalPort(65535)).toBe(65535); + }); + + it("rejects non-numbers, non-integers, and out-of-range", () => { + expect(() => assertCanonicalPort("42010")).toThrow(); + expect(() => assertCanonicalPort(42010.5)).toThrow(); + expect(() => assertCanonicalPort(0)).toThrow(); + expect(() => assertCanonicalPort(-1)).toThrow(); + expect(() => assertCanonicalPort(65536)).toThrow(); + expect(() => assertCanonicalPort(Number.NaN)).toThrow(); + expect(() => assertCanonicalPort(Infinity)).toThrow(); + expect(() => assertCanonicalPort(null)).toThrow(); + }); +}); + +describe("parseCanonicalIntegerString", () => { + it("accepts canonical decimal strings", () => { + expect(parseCanonicalIntegerString("0")).toBe(0); + expect(parseCanonicalIntegerString("42010")).toBe(42010); + }); + + it("rejects signs, whitespace, leading zeros, decimals, and exponents", () => { + expect(() => parseCanonicalIntegerString("+42010")).toThrow(); + expect(() => parseCanonicalIntegerString("-1")).toThrow(); + expect(() => parseCanonicalIntegerString(" 42010")).toThrow(); + expect(() => parseCanonicalIntegerString("42010 ")).toThrow(); + expect(() => parseCanonicalIntegerString("007")).toThrow(); + expect(() => parseCanonicalIntegerString("00")).toThrow(); + expect(() => parseCanonicalIntegerString("4.2")).toThrow(); + expect(() => parseCanonicalIntegerString("4e2")).toThrow(); + }); + + it("rejects Unicode digits and non-string input", () => { + // Arabic-Indic digits for 42010. + expect(() => parseCanonicalIntegerString("٤٢٠١٠")).toThrow(); + // Fullwidth digits. + expect(() => parseCanonicalIntegerString("42010")).toThrow(); + expect(() => parseCanonicalIntegerString(42010 as unknown as string)).toThrow(); + }); + + it("rejects integer overflow beyond safe range", () => { + expect(() => parseCanonicalIntegerString("99999999999999999999")).toThrow(); + }); +}); diff --git a/packages/tailscale-https-broker/src/integers.ts b/packages/tailscale-https-broker/src/integers.ts new file mode 100644 index 00000000000..9bfb972fa5f --- /dev/null +++ b/packages/tailscale-https-broker/src/integers.ts @@ -0,0 +1,59 @@ +/** + * Canonical integer parsing for the broker. Commands and Serve mutations are + * generated only from integers that pass these checks, never from raw strings, + * URLs, or unvalidated JSON (PAP-17050 verdict requirement #4). + */ + +/** Ports are constrained to the valid TCP range. */ +export const MIN_PORT = 1; +export const MAX_PORT = 65535; + +/** + * Assert a JSON-decoded value is a canonical, safe, in-range port number. + * Rejects non-numbers, non-integers, NaN/Infinity, negatives, and out-of-range + * values. Returns the number so call sites read as validated. + */ +export function assertCanonicalPort(value: unknown): number { + if (typeof value !== "number") { + throw new Error("port must be a JSON number"); + } + if (!Number.isInteger(value)) { + throw new Error("port must be an integer"); + } + if (value < MIN_PORT || value > MAX_PORT) { + throw new Error(`port out of range [${MIN_PORT}, ${MAX_PORT}]`); + } + return value; +} + +/** + * Parse an integer from an untrusted STRING with strict canonical rules. + * Rejects: empty, whitespace, signs, leading zeros (non-canonical), decimals, + * exponents, thousands separators, Unicode digits, and anything that does not + * round-trip exactly back to its canonical decimal form. Used at any boundary + * where a numeric value could arrive as text. + */ +export function parseCanonicalIntegerString(raw: unknown): number { + if (typeof raw !== "string") { + throw new Error("expected a string integer"); + } + // Only ASCII digits, at least one, no sign, no whitespace. `^[0-9]+$` with a + // JS regex still matches only ASCII 0-9 (it does not match Unicode digits + // unless the `u` + property-escape form is used), which is what we want. + if (!/^[0-9]+$/.test(raw)) { + throw new Error("integer contains non-canonical characters"); + } + // Reject non-canonical leading zeros ("007", "00"). + if (raw.length > 1 && raw[0] === "0") { + throw new Error("integer has non-canonical leading zero"); + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed)) { + throw new Error("integer overflow / not a safe integer"); + } + // Round-trip guard against any residual ambiguity. + if (String(parsed) !== raw) { + throw new Error("integer did not round-trip canonically"); + } + return parsed; +} diff --git a/packages/tailscale-https-broker/src/main.ts b/packages/tailscale-https-broker/src/main.ts new file mode 100644 index 00000000000..7e4585302e2 --- /dev/null +++ b/packages/tailscale-https-broker/src/main.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Broker host entrypoint. Wires the tested BrokerCore to the live Tailscale + * CLI, /proc listener-ownership check, file registry, file audit sink, and the + * unix-socket transport. Runs as a dedicated, Tailscale-operator service + * account — NOT as the Paperclip app/agent account. + * + * Usage: + * paperclip-tailscale-https-broker # run the broker + * paperclip-tailscale-https-broker --doctor # read-only preflight, no mutation + */ +import { FileAuditSink } from "./audit.js"; +import { BrokerCore } from "./broker-core.js"; +import { loadHostConfig } from "./config.js"; +import { createPeerResolver } from "./peercred.js"; +import { defaultIsAllowedPort } from "./port-policy.js"; +import { createProcListenerVerifier } from "./proc-listener.js"; +import { startSocketServer } from "./socket-server.js"; +import { buildStatusArgv } from "./argv.js"; +import { assertPrimaryIntact, parseServeStatus } from "./serve-config.js"; +import { createTailscaleRunner, isSupportedTailscaleVersion } from "./tailscale-cli.js"; + +function runDoctor(): number { + const config = loadHostConfig(process.env); + const runner = createTailscaleRunner(); + const version = runner([config.tailscaleBinPath, "version"]); + const versionOk = version.code === 0 && isSupportedTailscaleVersion(version.stdout); + const status = runner(buildStatusArgv(config.tailscaleBinPath)); + let primaryOk = false; + try { + assertPrimaryIntact(parseServeStatus(JSON.parse(status.stdout))); + primaryOk = true; + } catch { + primaryOk = false; + } + const checks = { + tailscaleVersionSupported: versionOk, + serveStatusReadable: status.code === 0, + primaryRouteIntact: primaryOk, + registryPathSafe: true, // loadHostConfig already threw otherwise + nodeIdentity: config.nodeIdentity, + }; + process.stdout.write(`${JSON.stringify(checks, null, 2)}\n`); + return versionOk && status.code === 0 && primaryOk ? 0 : 1; +} + +function main(): void { + if (process.argv.includes("--doctor")) { + process.exit(runDoctor()); + } + + const config = loadHostConfig(process.env); + const core = new BrokerCore({ + tailscaleBinPath: config.tailscaleBinPath, + registryPath: config.registryPath, + auditSink: new FileAuditSink(config.auditPath, true), + peerPolicy: { + allowedUids: new Set([config.serviceUid]), + allowedGids: new Set([config.serviceGid]), + }, + nodeIdentity: config.nodeIdentity, + isAllowedPort: defaultIsAllowedPort, + deps: { + runTailscale: createTailscaleRunner(), + verifyListenerOwnership: createProcListenerVerifier(config.runtimeUid), + nowIso: () => new Date().toISOString(), + }, + }); + + const server = startSocketServer({ + socketPath: config.socketPath, + core, + resolvePeer: createPeerResolver({ + serviceUid: config.serviceUid, + serviceGid: config.serviceGid, + trustSocketPermissions: config.trustSocketPermissions, + }), + }); + + const shutdown = () => { + server.close(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + process.stderr.write(`[broker] listening on ${config.socketPath}\n`); +} + +main(); diff --git a/packages/tailscale-https-broker/src/peercred.ts b/packages/tailscale-https-broker/src/peercred.ts new file mode 100644 index 00000000000..98f3c051529 --- /dev/null +++ b/packages/tailscale-https-broker/src/peercred.ts @@ -0,0 +1,50 @@ +/** + * Peer-credential resolution for accepted socket connections. + * + * Node does not expose SO_PEERCRED through a public API. The broker's PRIMARY + * access boundary is therefore the OS filesystem: the socket lives in a + * root-owned directory and is mode 0660 owned by `root:`, so + * only the allowlisted Paperclip service identity can `connect()` at all (this + * is exactly the dedicated-socket-group control in PAP-17050 verdict req #1). + * + * `createPeerResolver` returns the identity to bind leases to. If a native + * SO_PEERCRED mechanism is wired via `soPeercred`, it is used and its result is + * cross-checked against the allowlist. Otherwise the resolver falls back to the + * configured, OS-enforced identity — but ONLY when the operator has asserted + * the socket is protected by the dedicated group (`trustSocketPermissions`). + * With neither, it fails closed (connection refused). + */ +import type { Socket } from "node:net"; +import type { PeerCredentials } from "./types.js"; + +export interface PeerResolverConfig { + /** The single OS identity the socket permissions restrict connections to. */ + serviceUid: number; + serviceGid: number; + /** Operator assertion that the socket is 0660 root:. */ + trustSocketPermissions: boolean; + /** Optional native SO_PEERCRED reader; returns null if unavailable. */ + soPeercred?: (socket: Socket) => PeerCredentials | null; +} + +export class PeerResolutionError extends Error {} + +export function createPeerResolver(config: PeerResolverConfig) { + return (socket: Socket): PeerCredentials => { + const native = config.soPeercred?.(socket) ?? null; + if (native) { + // Defense in depth: even with a native reader, the OS-enforced identity + // must match what we expect. + if (native.uid !== config.serviceUid || native.gid !== config.serviceGid) { + throw new PeerResolutionError("peer credentials do not match the service identity"); + } + return native; + } + if (config.trustSocketPermissions) { + return { uid: config.serviceUid, gid: config.serviceGid, pid: 0 }; + } + throw new PeerResolutionError( + "no peer-credential mechanism available and socket-permission trust not asserted", + ); + }; +} diff --git a/packages/tailscale-https-broker/src/port-policy.ts b/packages/tailscale-https-broker/src/port-policy.ts new file mode 100644 index 00000000000..c1ba6431446 --- /dev/null +++ b/packages/tailscale-https-broker/src/port-policy.ts @@ -0,0 +1,18 @@ +/** + * Default dedicated port allowlist for the broker. Kept numerically in sync + * with `@paperclipai/shared` `runtime-exposure/ports` so the broker and the + * runtime allocator agree, but inlined here so the broker stays deployable as a + * standalone host service without a workspace dependency graph. + */ +export const DEFAULT_APP_PORT_MIN = 42000; +export const DEFAULT_APP_PORT_MAX = 42999; +export const DEFAULT_HMR_PORT_OFFSET = 10000; +export const DEFAULT_HMR_PORT_MIN = DEFAULT_APP_PORT_MIN + DEFAULT_HMR_PORT_OFFSET; +export const DEFAULT_HMR_PORT_MAX = DEFAULT_APP_PORT_MAX + DEFAULT_HMR_PORT_OFFSET; + +export function defaultIsAllowedPort(port: number): boolean { + if (!Number.isInteger(port)) return false; + const inApp = port >= DEFAULT_APP_PORT_MIN && port <= DEFAULT_APP_PORT_MAX; + const inHmr = port >= DEFAULT_HMR_PORT_MIN && port <= DEFAULT_HMR_PORT_MAX; + return inApp || inHmr; +} diff --git a/packages/tailscale-https-broker/src/proc-listener.test.ts b/packages/tailscale-https-broker/src/proc-listener.test.ts new file mode 100644 index 00000000000..95be00b8b0d --- /dev/null +++ b/packages/tailscale-https-broker/src/proc-listener.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + isLoopbackIpv4Hex, + isLoopbackIpv6Hex, + isWildcardHex, + listenerFactsForPort, + parseProcNetTable, +} from "./proc-listener.js"; + +// 42010 == 0xA41A. /proc stores IPv4 little-endian: 127.0.0.1 -> 0100007F. +const LOOPBACK_ROW = " 0: 0100007F:A41A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 999 0 12345 1"; +const WILDCARD_ROW = " 1: 00000000:A41A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 999 0 12346 1"; +const OTHERUID_ROW = " 2: 0100007F:A41A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12347 1"; +const HEADER = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode"; + +describe("proc listener parsing", () => { + it("classifies loopback vs wildcard addresses", () => { + expect(isLoopbackIpv4Hex("0100007F")).toBe(true); + expect(isLoopbackIpv4Hex("0101A8C0")).toBe(false); // 192.168.1.1 + expect(isWildcardHex("00000000")).toBe(true); + // /proc/net/tcp6 stores ::1 little-endian per word: final word 0x1 -> "01000000". + expect(isLoopbackIpv6Hex("00000000000000000000000001000000")).toBe(true); + // The big-endian spelling must NOT match, or a non-loopback bind could slip through. + expect(isLoopbackIpv6Hex("00000000000000000000000000000001")).toBe(false); + expect(isLoopbackIpv6Hex("00000000000000000000000000000000")).toBe(false); // :: + }); + + it("reports a loopback listener owned by the expected uid", () => { + const rows = parseProcNetTable(`${HEADER}\n${LOOPBACK_ROW}`); + const facts = listenerFactsForPort(rows, [], 42010); + expect(facts.present).toBe(true); + expect(facts.loopbackOnly).toBe(true); + expect(facts.uids).toEqual([999]); + }); + + it("flags a wildcard bind as not loopback-only", () => { + const rows = parseProcNetTable(`${HEADER}\n${WILDCARD_ROW}`); + const facts = listenerFactsForPort(rows, [], 42010); + expect(facts.present).toBe(true); + expect(facts.loopbackOnly).toBe(false); + }); + + it("surfaces a foreign uid so ownership fails", () => { + const rows = parseProcNetTable(`${HEADER}\n${OTHERUID_ROW}`); + const facts = listenerFactsForPort(rows, [], 42010); + expect(facts.uids).toEqual([1000]); + }); + + it("returns absent when no listener matches the port", () => { + const rows = parseProcNetTable(`${HEADER}\n${LOOPBACK_ROW}`); + expect(listenerFactsForPort(rows, [], 40000).present).toBe(false); + }); +}); diff --git a/packages/tailscale-https-broker/src/proc-listener.ts b/packages/tailscale-https-broker/src/proc-listener.ts new file mode 100644 index 00000000000..b9427870044 --- /dev/null +++ b/packages/tailscale-https-broker/src/proc-listener.ts @@ -0,0 +1,130 @@ +/** + * /proc-based loopback listener ownership check (PAP-17050 verdict req #2). + * + * Immediately before a mutation the broker proves the target port has a + * listener that is (a) loopback-only — not a wildcard/IPv6-wildcard/dual-stack + * bind reachable off-loopback — and (b) owned by the expected managed-runtime + * UID. A mere caller assertion or health response is insufficient. + * + * Pure parsing helpers are exported for tests; `createProcListenerVerifier` + * wires them to the live /proc filesystem. + */ +import { readFileSync } from "node:fs"; +import type { ListenerOwnership } from "./broker-core.js"; + +export interface ProcListenerRow { + localAddressHex: string; + localPortHex: string; + state: string; + uid: number; + inode: string; +} + +const TCP_STATE_LISTEN = "0A"; + +/** Parse one `/proc/net/tcp{,6}` table into structured listening rows. */ +export function parseProcNetTable(content: string): ProcListenerRow[] { + const rows: ProcListenerRow[] = []; + const lines = content.split("\n").slice(1); // drop header + for (const line of lines) { + const cols = line.trim().split(/\s+/); + if (cols.length < 10) continue; + const [local, , stateHex] = [cols[1], cols[2], cols[3]]; + const uid = Number.parseInt(cols[7], 10); + const inode = cols[9]; + const [addr, port] = local.split(":"); + rows.push({ localAddressHex: addr, localPortHex: port, state: stateHex, uid, inode }); + } + return rows; +} + +/** True when a hex IPv4 address is loopback (127.0.0.0/8). */ +export function isLoopbackIpv4Hex(addrHex: string): boolean { + // /proc stores little-endian; low byte is the last pair. + if (addrHex.length !== 8) return false; + const b0 = parseInt(addrHex.slice(6, 8), 16); // first octet + return b0 === 127; +} + +/** + * `/proc/net/tcp6` rendering of ::1. Like the IPv4 table, the address is stored + * as 32-bit words in host (little-endian) byte order, so the final word 0x00000001 + * is byte-swapped to "01000000" — NOT the big-endian "...0001". Matching the + * wrong endianness would misclassify a real loopback listener. + */ +const LOOPBACK_IPV6_PROC_HEX = "00000000000000000000000001000000"; + +/** True when a hex IPv6 address is ::1 loopback (in /proc little-endian form). */ +export function isLoopbackIpv6Hex(addrHex: string): boolean { + return addrHex.toUpperCase() === LOOPBACK_IPV6_PROC_HEX; +} + +/** True for wildcard binds (0.0.0.0 / ::) that are reachable off-loopback. */ +export function isWildcardHex(addrHex: string): boolean { + return /^0+$/.test(addrHex); +} + +export interface ListenerFacts { + present: boolean; + loopbackOnly: boolean; + uids: number[]; +} + +/** + * Reduce the two /proc tables to facts about listeners on `port`. A wildcard or + * IPv6-wildcard bind anywhere on the port makes it NOT loopback-only. + */ +export function listenerFactsForPort( + tcp: ProcListenerRow[], + tcp6: ProcListenerRow[], + port: number, +): ListenerFacts { + const wantHex = port.toString(16).toUpperCase().padStart(4, "0"); + const uids: number[] = []; + let present = false; + let loopbackOnly = true; + for (const row of tcp) { + if (row.localPortHex.toUpperCase() !== wantHex || row.state !== TCP_STATE_LISTEN) continue; + present = true; + uids.push(row.uid); + if (isWildcardHex(row.localAddressHex) || !isLoopbackIpv4Hex(row.localAddressHex)) { + loopbackOnly = false; + } + } + for (const row of tcp6) { + if (row.localPortHex.toUpperCase() !== wantHex || row.state !== TCP_STATE_LISTEN) continue; + present = true; + uids.push(row.uid); + if (isWildcardHex(row.localAddressHex) || !isLoopbackIpv6Hex(row.localAddressHex)) { + loopbackOnly = false; + } + } + return { present, loopbackOnly, uids }; +} + +/** + * Build a live verifier bound to the expected managed-runtime UID. Reads + * /proc/net/tcp and tcp6 fresh on every call (immediately before mutation). + */ +export function createProcListenerVerifier(expectedUid: number) { + return (port: number): ListenerOwnership => { + let tcp: ProcListenerRow[] = []; + let tcp6: ProcListenerRow[] = []; + try { + tcp = parseProcNetTable(readFileSync("/proc/net/tcp", "utf8")); + } catch { + /* leave empty -> present:false -> fail closed */ + } + try { + tcp6 = parseProcNetTable(readFileSync("/proc/net/tcp6", "utf8")); + } catch { + /* optional */ + } + const facts = listenerFactsForPort(tcp, tcp6, port); + return { + present: facts.present, + loopbackOnly: facts.loopbackOnly, + ownerUidMatches: facts.present && facts.uids.every((uid) => uid === expectedUid), + }; + }; +} diff --git a/packages/tailscale-https-broker/src/protocol.test.ts b/packages/tailscale-https-broker/src/protocol.test.ts new file mode 100644 index 00000000000..77f50ba5e34 --- /dev/null +++ b/packages/tailscale-https-broker/src/protocol.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { ProtocolError, decodeRequest, MAX_REQUEST_BYTES } from "./protocol.js"; + +const RUNTIME = "2af79bb1-ecc5-4410-8438-091be135a921"; + +function frame(obj: unknown): string { + return JSON.stringify(obj); +} + +describe("decodeRequest", () => { + it("decodes a valid expose request", () => { + const req = decodeRequest( + frame({ + v: 1, + op: "expose", + requestId: "req-1", + runtimeId: RUNTIME, + listeners: [ + { purpose: "app", port: 42010 }, + { purpose: "vite_hmr", port: 52010 }, + ], + }), + ); + expect(req).toEqual({ + op: "expose", + requestId: "req-1", + runtimeId: RUNTIME, + listeners: [ + { purpose: "app", port: 42010 }, + { purpose: "vite_hmr", port: 52010 }, + ], + }); + }); + + it("decodes remove and list", () => { + expect( + decodeRequest(frame({ v: 1, op: "list", requestId: "r" })).op, + ).toBe("list"); + const rm = decodeRequest( + frame({ v: 1, op: "remove", requestId: "r", runtimeId: RUNTIME, handle: "a".repeat(20) }), + ); + expect(rm.op).toBe("remove"); + }); + + it("rejects unsupported protocol version", () => { + expect(() => decodeRequest(frame({ v: 2, op: "list", requestId: "r" }))).toThrow(ProtocolError); + try { + decodeRequest(frame({ v: 2, op: "list", requestId: "r" })); + } catch (e) { + expect((e as ProtocolError).code).toBe("unsupported_version"); + } + }); + + it("rejects unknown operations and unknown fields", () => { + expect(() => decodeRequest(frame({ v: 1, op: "reset", requestId: "r" }))).toThrow(/unknown operation/); + expect(() => + decodeRequest(frame({ v: 1, op: "list", requestId: "r", extra: 1 })), + ).toThrow(/unknown field/); + }); + + it("rejects duplicate keys", () => { + expect(() => decodeRequest('{"v":1,"op":"list","op":"expose","requestId":"r"}')).toThrow(/duplicate/); + }); + + it("rejects a non-UUID runtimeId and out-of-canonical ports", () => { + expect(() => + decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: "not-a-uuid", listeners: [{ purpose: "app", port: 42010 }] })), + ).toThrow(/runtime/); + expect(() => + decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: "42010" }] })), + ).toThrow(); + }); + + it("rejects duplicate ports/purposes and too many listeners", () => { + expect(() => + decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "app", port: 42010 }] })), + ).toThrow(/duplicate/); + expect(() => + decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "vite_hmr", port: 52010 }, { purpose: "app", port: 42011 }] })), + ).toThrow(/too many/); + }); + + it("rejects oversized frames", () => { + const big = "x".repeat(MAX_REQUEST_BYTES + 1); + expect(() => + decodeRequest(frame({ v: 1, op: "list", requestId: "r", pad: big })), + ).toThrow(/size limit|unknown field/); + }); + + it("rejects invalid handles on remove", () => { + expect(() => + decodeRequest(frame({ v: 1, op: "remove", requestId: "r", runtimeId: RUNTIME, handle: "short" })), + ).toThrow(); + }); +}); diff --git a/packages/tailscale-https-broker/src/protocol.ts b/packages/tailscale-https-broker/src/protocol.ts new file mode 100644 index 00000000000..e823e7b5312 --- /dev/null +++ b/packages/tailscale-https-broker/src/protocol.ts @@ -0,0 +1,168 @@ +/** + * Versioned, length-bounded broker request protocol with a strict schema + * (PAP-17050 verdict requirement #5). Every request is decoded here before any + * authorization or mutation logic runs. Malformed, oversized, wrong-version, + * duplicate-key, and unknown-field requests are rejected without side effects. + */ +import { assertCanonicalPort } from "./integers.js"; +import { parseJsonNoDuplicateKeys } from "./strict-json.js"; +import { + BROKER_PROTOCOL_VERSION, + type BrokerRequest, + type OwnedListener, +} from "./types.js"; + +/** Hard cap on a single request frame (bytes). Bounds memory + slowloris. */ +export const MAX_REQUEST_BYTES = 8 * 1024; + +/** Max listeners in one expose request (app + HMR companion). */ +export const MAX_LISTENERS_PER_REQUEST = 2; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +const REQUEST_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +const HANDLE_RE = /^[A-Za-z0-9_-]{16,128}$/; + +const ALLOWED_PURPOSES: ReadonlySet = new Set([ + "app", + "vite_hmr", +]); + +export class ProtocolError extends Error { + constructor( + readonly code: + | "unsupported_version" + | "malformed_request" + | "unknown_operation" + | "invalid_runtime_id" + | "invalid_port", + message: string, + readonly requestId: string | null = null, + ) { + super(message); + this.name = "ProtocolError"; + } +} + +function asObject(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new ProtocolError("malformed_request", "request must be a JSON object"); + } + return value as Record; +} + +function requireStringField( + obj: Record, + field: string, + re: RegExp, + code: ProtocolError["code"], + requestId: string | null, +): string { + const value = obj[field]; + if (typeof value !== "string" || !re.test(value)) { + throw new ProtocolError(code, `invalid or missing field: ${field}`, requestId); + } + return value; +} + +/** + * Decode a raw request frame (utf-8 JSON) into a validated BrokerRequest. + * Throws ProtocolError on any violation; never mutates state. + */ +export function decodeRequest(frame: Buffer | string): BrokerRequest { + const buf = typeof frame === "string" ? Buffer.from(frame, "utf8") : frame; + if (buf.byteLength === 0) { + throw new ProtocolError("malformed_request", "empty request frame"); + } + if (buf.byteLength > MAX_REQUEST_BYTES) { + throw new ProtocolError("malformed_request", "request frame exceeds size limit"); + } + + let parsed: unknown; + try { + parsed = parseJsonNoDuplicateKeys(buf.toString("utf8")); + } catch (error) { + throw new ProtocolError( + "malformed_request", + `invalid JSON: ${(error as Error).message}`, + ); + } + + const obj = asObject(parsed); + + if (obj.v !== BROKER_PROTOCOL_VERSION) { + throw new ProtocolError( + "unsupported_version", + `unsupported protocol version: ${String(obj.v)}`, + ); + } + + const requestId = requireStringField(obj, "requestId", REQUEST_ID_RE, "malformed_request", null); + + const op = obj.op; + if (op !== "expose" && op !== "remove" && op !== "list") { + throw new ProtocolError("unknown_operation", `unknown operation: ${String(op)}`, requestId); + } + + if (op === "list") { + assertExactKeys(obj, ["v", "op", "requestId"], requestId); + return { op: "list", requestId }; + } + + if (op === "remove") { + assertExactKeys(obj, ["v", "op", "requestId", "runtimeId", "handle"], requestId); + const runtimeId = requireStringField(obj, "runtimeId", UUID_RE, "invalid_runtime_id", requestId); + const handle = requireStringField(obj, "handle", HANDLE_RE, "malformed_request", requestId); + return { op: "remove", requestId, runtimeId, handle }; + } + + // expose + assertExactKeys(obj, ["v", "op", "requestId", "runtimeId", "listeners"], requestId); + const runtimeId = requireStringField(obj, "runtimeId", UUID_RE, "invalid_runtime_id", requestId); + const rawListeners = obj.listeners; + if (!Array.isArray(rawListeners) || rawListeners.length === 0) { + throw new ProtocolError("malformed_request", "listeners must be a non-empty array", requestId); + } + if (rawListeners.length > MAX_LISTENERS_PER_REQUEST) { + throw new ProtocolError("malformed_request", "too many listeners in request", requestId); + } + const listeners: OwnedListener[] = rawListeners.map((entry) => { + const listener = asObject(entry); + assertExactKeys(listener, ["purpose", "port"], requestId); + const purpose = listener.purpose; + if (typeof purpose !== "string" || !ALLOWED_PURPOSES.has(purpose as OwnedListener["purpose"])) { + throw new ProtocolError("malformed_request", "invalid listener purpose", requestId); + } + let port: number; + try { + port = assertCanonicalPort(listener.port); + } catch (error) { + throw new ProtocolError("invalid_port", (error as Error).message, requestId); + } + return { purpose: purpose as OwnedListener["purpose"], port }; + }); + + // Reject duplicate ports / duplicate purposes within one request. + const ports = new Set(listeners.map((l) => l.port)); + const purposes = new Set(listeners.map((l) => l.purpose)); + if (ports.size !== listeners.length || purposes.size !== listeners.length) { + throw new ProtocolError("malformed_request", "duplicate listener port or purpose", requestId); + } + + return { op: "expose", requestId, runtimeId, listeners }; +} + +function assertExactKeys( + obj: Record, + allowed: string[], + requestId: string | null, +): void { + const allowedSet = new Set(allowed); + for (const key of Object.keys(obj)) { + if (!allowedSet.has(key)) { + throw new ProtocolError("malformed_request", `unknown field: ${key}`, requestId); + } + } +} diff --git a/packages/tailscale-https-broker/src/registry.ts b/packages/tailscale-https-broker/src/registry.ts new file mode 100644 index 00000000000..62dc9f95590 --- /dev/null +++ b/packages/tailscale-https-broker/src/registry.ts @@ -0,0 +1,136 @@ +/** + * Root-owned ownership registry persisted atomically (PAP-17050 verdict #3). + * + * Writes go through temp-file + fsync + rename + directory fsync so a crash + * never leaves a torn registry. The file must be mode 0600 in a root-owned, + * non-writable parent directory; unsafe permissions make the broker refuse to + * start (checked by the caller via `assertSafeRegistryPath`). + */ +import { + closeSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import type { BrokerRegistry, LeaseRecord } from "./types.js"; + +export function emptyRegistry(nodeIdentity: string): BrokerRegistry { + return { + version: 1, + nodeIdentity, + generationCounter: 0, + leases: [], + quarantinedPorts: [], + }; +} + +export function loadRegistry(path: string, nodeIdentity: string): BrokerRegistry { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return emptyRegistry(nodeIdentity); + } + throw error; + } + const parsed = JSON.parse(raw) as BrokerRegistry; + if (parsed.version !== 1 || !Array.isArray(parsed.leases)) { + throw new Error("registry is corrupt or of an unsupported version"); + } + parsed.quarantinedPorts = Array.isArray(parsed.quarantinedPorts) ? parsed.quarantinedPorts : []; + return parsed; +} + +/** + * Persist the registry atomically. Temp file in the same directory (same fs), + * fsync data, rename over the target, then fsync the directory so the rename is + * durable. + */ +export function saveRegistry(path: string, registry: BrokerRegistry): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmp = `${path}.tmp-${process.pid}`; + writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 }); + const fd = openSync(tmp, "r"); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + renameSync(tmp, path); + const dirFd = openSync(dir, "r"); + try { + fsyncSync(dirFd); + } finally { + closeSync(dirFd); + } +} + +/** + * Refuse to operate on a registry whose parent directory is group/world + * writable or whose file (when present) is group/world accessible. Returns the + * reason string when unsafe, or null when safe. + */ +export function registryPathUnsafeReason(path: string): string | null { + const dir = dirname(path); + let dirStat; + try { + dirStat = statSync(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + if ((dirStat.mode & 0o022) !== 0) { + return "registry parent directory is group/world writable"; + } + try { + const fileStat = statSync(path); + if ((fileStat.mode & 0o077) !== 0) { + return "registry file is group/world accessible"; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + return null; +} + +export function nextGeneration(registry: BrokerRegistry): number { + registry.generationCounter += 1; + return registry.generationCounter; +} + +export function addLease(registry: BrokerRegistry, lease: LeaseRecord): void { + registry.leases.push(lease); +} + +export function removeLeaseByHandle(registry: BrokerRegistry, handle: string): LeaseRecord | null { + const index = registry.leases.findIndex((lease) => lease.handle === handle); + if (index < 0) return null; + const [removed] = registry.leases.splice(index, 1); + return removed; +} + +export function isPortQuarantined(registry: BrokerRegistry, port: number): boolean { + return registry.quarantinedPorts.includes(port); +} + +export function quarantinePort(registry: BrokerRegistry, port: number): void { + if (!registry.quarantinedPorts.includes(port)) { + registry.quarantinedPorts.push(port); + } +} + +/** Ports the registry believes are actively owned by a live lease. */ +export function ownedPorts(registry: BrokerRegistry): Set { + const ports = new Set(); + for (const lease of registry.leases) { + for (const port of lease.ports) ports.add(port); + } + return ports; +} diff --git a/packages/tailscale-https-broker/src/serve-config.test.ts b/packages/tailscale-https-broker/src/serve-config.test.ts new file mode 100644 index 00000000000..a4f9bf4c722 --- /dev/null +++ b/packages/tailscale-https-broker/src/serve-config.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + ServeParseError, + assertPrimaryIntact, + changedPorts, + isSameNumberLoopbackEntry, + parseServeStatus, + primaryDigest, +} from "./serve-config.js"; + +const HOST = "paperclip-dev.tail29c1aa.ts.net"; + +function serve(ports: Record): unknown { + const TCP: Record = {}; + const Web: Record = {}; + for (const [portStr, proxy] of Object.entries(ports)) { + TCP[portStr] = { HTTPS: true }; + Web[`${HOST}:${portStr}`] = { Handlers: { "/": { Proxy: proxy } } }; + } + return { TCP, Web }; +} + +const PRIMARY = { 443: "http://127.0.0.1:3100" }; + +describe("parseServeStatus", () => { + it("normalizes TCP + Web into port entries", () => { + const parsed = parseServeStatus(serve({ ...PRIMARY, 42010: "http://127.0.0.1:42010" })); + expect(parsed.entries.get(42010)?.https).toBe(true); + expect(isSameNumberLoopbackEntry(parsed.entries.get(42010), 42010)).toBe(true); + }); + + it("fails closed on malformed shapes", () => { + expect(() => parseServeStatus(null)).toThrow(ServeParseError); + expect(() => parseServeStatus({ TCP: [] })).toThrow(ServeParseError); + expect(() => parseServeStatus({ Web: { "no-port": {} } })).toThrow(ServeParseError); + }); + + it("tolerates unknown top-level fields", () => { + expect(() => parseServeStatus({ ...(serve(PRIMARY) as object), AllowFunnel: {} })).not.toThrow(); + }); +}); + +describe(":443 invariant", () => { + it("asserts the primary route intact", () => { + const parsed = parseServeStatus(serve(PRIMARY)); + expect(() => assertPrimaryIntact(parsed)).not.toThrow(); + }); + + it("throws when :443 is missing or retargeted", () => { + expect(() => assertPrimaryIntact(parseServeStatus(serve({ 42010: "http://127.0.0.1:42010" })))).toThrow(); + expect(() => assertPrimaryIntact(parseServeStatus(serve({ 443: "http://127.0.0.1:9999" })))).toThrow(); + }); + + it("produces a stable digest that changes only when :443 changes", () => { + const a = primaryDigest(parseServeStatus(serve({ ...PRIMARY, 42010: "http://127.0.0.1:42010" }))); + const b = primaryDigest(parseServeStatus(serve({ ...PRIMARY, 42011: "http://127.0.0.1:42011" }))); + expect(a).toBe(b); + const c = primaryDigest(parseServeStatus(serve({ 443: "http://127.0.0.1:9999" }))); + expect(c).not.toBe(a); + }); +}); + +describe("changedPorts", () => { + it("detects only the ports whose entry differs", () => { + const before = parseServeStatus(serve({ ...PRIMARY })); + const after = parseServeStatus(serve({ ...PRIMARY, 42010: "http://127.0.0.1:42010" })); + expect(changedPorts(before, after)).toEqual([42010]); + }); +}); diff --git a/packages/tailscale-https-broker/src/serve-config.ts b/packages/tailscale-https-broker/src/serve-config.ts new file mode 100644 index 00000000000..b4d66b8c3e6 --- /dev/null +++ b/packages/tailscale-https-broker/src/serve-config.ts @@ -0,0 +1,162 @@ +/** + * Strict parser + invariant checks for `tailscale serve status --json` output. + * + * The primary route `:443 -> http://127.0.0.1:3100` is a protected invariant + * verified before AND after every mutation, and unknown/manual entries are + * never modified (PAP-17050 verdict requirement #3 + invariants). Parsing fails + * closed: any ambiguity, parse error, or unexpected shape is an error, never a + * best-effort guess. + */ +import { assertCanonicalPort } from "./integers.js"; +import { PROTECTED_PRIMARY_PORT, PROTECTED_PRIMARY_TARGET } from "./types.js"; + +export interface ServeHandler { + path: string; + proxy: string | null; +} + +export interface ServeEntry { + port: number; + https: boolean; + handlers: ServeHandler[]; +} + +export interface ParsedServe { + /** Normalized entries keyed by port. */ + entries: Map; +} + +export class ServeParseError extends Error { + constructor(message: string) { + super(message); + this.name = "ServeParseError"; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Extract the trailing `:port` of a `host:port` Web key. */ +function portFromWebKey(key: string): number { + const idx = key.lastIndexOf(":"); + if (idx <= 0 || idx === key.length - 1) { + throw new ServeParseError(`malformed Web key: ${JSON.stringify(key)}`); + } + const portText = key.slice(idx + 1); + if (!/^[0-9]+$/.test(portText)) { + throw new ServeParseError(`non-numeric port in Web key: ${JSON.stringify(key)}`); + } + return assertCanonicalPort(Number(portText)); +} + +/** + * Parse serve status JSON into a normalized, port-keyed model. Tolerates extra + * top-level fields (Tailscale evolves its schema) but strictly validates the + * `TCP` and `Web` shapes it depends on. + */ +export function parseServeStatus(json: unknown): ParsedServe { + if (!isObject(json)) { + throw new ServeParseError("serve status must be a JSON object"); + } + const entries = new Map(); + + const tcp = json.TCP; + const httpsPorts = new Set(); + if (tcp !== undefined && tcp !== null) { + if (!isObject(tcp)) throw new ServeParseError("TCP must be an object"); + for (const [portKey, value] of Object.entries(tcp)) { + if (!/^[0-9]+$/.test(portKey)) { + throw new ServeParseError(`non-numeric TCP port: ${portKey}`); + } + const port = assertCanonicalPort(Number(portKey)); + if (!isObject(value)) throw new ServeParseError(`TCP[${portKey}] must be an object`); + if (value.HTTPS === true) httpsPorts.add(port); + if (!entries.has(port)) entries.set(port, { port, https: value.HTTPS === true, handlers: [] }); + else entries.get(port)!.https = value.HTTPS === true; + } + } + + const web = json.Web; + if (web !== undefined && web !== null) { + if (!isObject(web)) throw new ServeParseError("Web must be an object"); + for (const [hostKey, value] of Object.entries(web)) { + const port = portFromWebKey(hostKey); + if (!isObject(value)) throw new ServeParseError(`Web[${hostKey}] must be an object`); + const handlersRaw = value.Handlers; + const handlers: ServeHandler[] = []; + if (handlersRaw !== undefined && handlersRaw !== null) { + if (!isObject(handlersRaw)) throw new ServeParseError(`Handlers for ${hostKey} must be an object`); + for (const [path, handler] of Object.entries(handlersRaw)) { + if (!isObject(handler)) throw new ServeParseError(`handler ${path} must be an object`); + const proxy = handler.Proxy; + if (proxy !== undefined && typeof proxy !== "string") { + throw new ServeParseError(`handler ${path} Proxy must be a string`); + } + handlers.push({ path, proxy: typeof proxy === "string" ? proxy : null }); + } + } + const existing = entries.get(port); + if (existing) { + existing.handlers.push(...handlers); + if (httpsPorts.has(port)) existing.https = true; + } else { + entries.set(port, { port, https: httpsPorts.has(port), handlers }); + } + } + } + + return { entries }; +} + +/** Stable, order-independent digest of one entry, for before/after comparison. */ +export function entryDigest(entry: ServeEntry | undefined): string { + if (!entry) return "absent"; + const handlers = [...entry.handlers] + .map((h) => `${h.path}=>${h.proxy ?? ""}`) + .sort(); + return JSON.stringify({ port: entry.port, https: entry.https, handlers }); +} + +/** True when the given entry is exactly a same-number HTTPS->loopback listener. */ +export function isSameNumberLoopbackEntry(entry: ServeEntry | undefined, port: number): boolean { + if (!entry || entry.port !== port || !entry.https) return false; + if (entry.handlers.length !== 1) return false; + const [handler] = entry.handlers; + return handler.path === "/" && handler.proxy === `http://127.0.0.1:${port}`; +} + +/** + * Assert the protected primary `:443 -> http://127.0.0.1:3100` route is present + * and exactly as expected. Throws otherwise. Called before and after mutation. + */ +export function assertPrimaryIntact(parsed: ParsedServe): void { + const entry = parsed.entries.get(PROTECTED_PRIMARY_PORT); + if (!entry) { + throw new ServeParseError("protected primary :443 route is missing"); + } + if (!entry.https) { + throw new ServeParseError("protected primary :443 route is not HTTPS"); + } + const root = entry.handlers.find((h) => h.path === "/"); + if (!root || root.proxy !== PROTECTED_PRIMARY_TARGET) { + throw new ServeParseError("protected primary :443 route target changed"); + } +} + +/** Digest of the protected :443 entry, for exact before/after equality. */ +export function primaryDigest(parsed: ParsedServe): string { + return entryDigest(parsed.entries.get(PROTECTED_PRIMARY_PORT)); +} + +/** Ports whose entry digest differs between two snapshots. */ +export function changedPorts(before: ParsedServe, after: ParsedServe): number[] { + const ports = new Set([...before.entries.keys(), ...after.entries.keys()]); + const changed: number[] = []; + for (const port of ports) { + if (entryDigest(before.entries.get(port)) !== entryDigest(after.entries.get(port))) { + changed.push(port); + } + } + return changed.sort((a, b) => a - b); +} diff --git a/packages/tailscale-https-broker/src/socket-server.ts b/packages/tailscale-https-broker/src/socket-server.ts new file mode 100644 index 00000000000..053f19ff2f1 --- /dev/null +++ b/packages/tailscale-https-broker/src/socket-server.ts @@ -0,0 +1,135 @@ +/** + * Unix-socket transport for the broker. Enforces the resource-consumption and + * protocol controls from PAP-17050 verdict requirement #5: length-prefixed + * frames with a hard byte cap, per-connection read/write/idle deadlines, a + * maximum concurrent-client bound, and bounded single-frame responses. All + * security decisions are delegated to the tested `BrokerCore`; this layer only + * frames bytes and resolves peer identity. + */ +import { createServer, type Server, type Socket } from "node:net"; +import { chmodSync, existsSync, unlinkSync } from "node:fs"; +import type { BrokerCore } from "./broker-core.js"; +import { ProtocolError, decodeRequest, MAX_REQUEST_BYTES } from "./protocol.js"; +import type { PeerCredentials } from "./types.js"; + +const LENGTH_PREFIX_BYTES = 4; +const MAX_RESPONSE_BYTES = 16 * 1024; + +export interface SocketServerConfig { + socketPath: string; + core: BrokerCore; + resolvePeer: (socket: Socket) => PeerCredentials; + maxClients?: number; + connectionDeadlineMs?: number; +} + +export function startSocketServer(config: SocketServerConfig): Server { + const maxClients = config.maxClients ?? 32; + const deadlineMs = config.connectionDeadlineMs ?? 5_000; + let active = 0; + + if (existsSync(config.socketPath)) { + unlinkSync(config.socketPath); + } + + const server = createServer((socket) => { + if (active >= maxClients) { + socket.destroy(); + return; + } + active += 1; + + let peer: PeerCredentials; + try { + peer = config.resolvePeer(socket); + } catch { + active -= 1; + socket.destroy(); + return; + } + + let expected = -1; + const chunks: Buffer[] = []; + let received = 0; + let done = false; + + const timer = setTimeout(() => finish(), deadlineMs); + timer.unref?.(); + + const finish = (): void => { + if (done) return; + done = true; + clearTimeout(timer); + active -= 1; + }; + + socket.on("error", () => finish()); + socket.on("close", () => finish()); + + socket.on("data", (chunk: Buffer) => { + if (done) return; + received += chunk.byteLength; + if (received > LENGTH_PREFIX_BYTES + MAX_REQUEST_BYTES) { + socket.destroy(); + return; + } + chunks.push(chunk); + const buf = Buffer.concat(chunks); + if (expected < 0) { + if (buf.byteLength < LENGTH_PREFIX_BYTES) return; + expected = buf.readUInt32BE(0); + if (expected > MAX_REQUEST_BYTES) { + socket.destroy(); + return; + } + } + if (buf.byteLength < LENGTH_PREFIX_BYTES + expected) return; + const body = buf.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + expected); + void handleFrame(body); + }); + + const handleFrame = async (body: Buffer): Promise => { + let response: unknown; + try { + const request = decodeRequest(body); + response = await config.core.handle(request, peer); + } catch (error) { + response = + error instanceof ProtocolError + ? { ok: false, requestId: error.requestId, code: error.code, message: error.code } + : { ok: false, requestId: null, code: "malformed_request", message: "malformed_request" }; + } + writeResponse(socket, response); + finish(); + }; + }); + + server.listen(config.socketPath, () => { + // Socket file itself is restricted to the dedicated group; the parent + // directory ownership/mode is enforced by the installer. + try { + chmodSync(config.socketPath, 0o660); + } catch { + /* best effort; installer verifies */ + } + }); + + server.maxConnections = maxClients; + return server; +} + +function writeResponse(socket: Socket, response: unknown): void { + let json = JSON.stringify(response); + if (Buffer.byteLength(json, "utf8") > MAX_RESPONSE_BYTES) { + json = JSON.stringify({ ok: false, requestId: null, code: "internal_error", message: "internal_error" }); + } + const body = Buffer.from(json, "utf8"); + const frame = Buffer.allocUnsafe(LENGTH_PREFIX_BYTES + body.byteLength); + frame.writeUInt32BE(body.byteLength, 0); + body.copy(frame, LENGTH_PREFIX_BYTES); + try { + socket.end(frame); + } catch { + socket.destroy(); + } +} diff --git a/packages/tailscale-https-broker/src/strict-json.test.ts b/packages/tailscale-https-broker/src/strict-json.test.ts new file mode 100644 index 00000000000..b70e0b21c6e --- /dev/null +++ b/packages/tailscale-https-broker/src/strict-json.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseJsonNoDuplicateKeys } from "./strict-json.js"; + +describe("parseJsonNoDuplicateKeys", () => { + it("parses ordinary JSON", () => { + expect(parseJsonNoDuplicateKeys('{"a":1,"b":[true,null,"x"]}')).toEqual({ + a: 1, + b: [true, null, "x"], + }); + }); + + it("rejects duplicate keys at the top level", () => { + expect(() => parseJsonNoDuplicateKeys('{"op":"list","op":"expose"}')).toThrow(/duplicate/); + }); + + it("rejects duplicate keys nested in objects", () => { + expect(() => + parseJsonNoDuplicateKeys('{"a":{"port":1,"port":2}}'), + ).toThrow(/duplicate/); + }); + + it("does not pollute the prototype via __proto__", () => { + const value = parseJsonNoDuplicateKeys('{"__proto__":{"polluted":true}}') as Record< + string, + unknown + >; + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(value, "__proto__")).toBe(true); + }); + + it("rejects trailing content and control characters in strings", () => { + expect(() => parseJsonNoDuplicateKeys('{"a":1} extra')).toThrow(); + expect(() => parseJsonNoDuplicateKeys('{"a":"\n"}')).toThrow(); + }); + + it("rejects non-canonical numbers", () => { + expect(() => parseJsonNoDuplicateKeys("01")).toThrow(); + }); +}); diff --git a/packages/tailscale-https-broker/src/strict-json.ts b/packages/tailscale-https-broker/src/strict-json.ts new file mode 100644 index 00000000000..2fb78dc8375 --- /dev/null +++ b/packages/tailscale-https-broker/src/strict-json.ts @@ -0,0 +1,194 @@ +/** + * Minimal strict JSON parser that rejects duplicate object keys. `JSON.parse` + * silently keeps the last value for a duplicated key, which the threat model + * treats as request smuggling (PAP-17050 verdict requirements #4/#5). This + * recursive-descent parser throws on any duplicate key at any depth. + * + * It is intentionally small and only used for the broker's tiny, length-bounded + * request frames — not a general-purpose JSON library. + */ + +export function parseJsonNoDuplicateKeys(text: string): unknown { + const parser = new StrictParser(text); + const value = parser.parseValue(); + parser.skipWhitespace(); + if (!parser.atEnd()) { + throw new SyntaxError("unexpected trailing content after JSON value"); + } + return value; +} + +class StrictParser { + private pos = 0; + constructor(private readonly src: string) {} + + atEnd(): boolean { + return this.pos >= this.src.length; + } + + skipWhitespace(): void { + while (this.pos < this.src.length) { + const ch = this.src[this.pos]; + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + this.pos += 1; + } else { + break; + } + } + } + + parseValue(): unknown { + this.skipWhitespace(); + if (this.atEnd()) throw new SyntaxError("unexpected end of input"); + const ch = this.src[this.pos]; + switch (ch) { + case "{": + return this.parseObject(); + case "[": + return this.parseArray(); + case '"': + return this.parseString(); + case "t": + case "f": + return this.parseBoolean(); + case "n": + return this.parseNull(); + default: + return this.parseNumber(); + } + } + + private parseObject(): Record { + this.expect("{"); + const obj: Record = {}; + const seen = new Set(); + this.skipWhitespace(); + if (this.peek() === "}") { + this.pos += 1; + return obj; + } + for (;;) { + this.skipWhitespace(); + if (this.peek() !== '"') throw new SyntaxError("expected object key string"); + const key = this.parseString(); + if (seen.has(key)) { + throw new SyntaxError(`duplicate object key: ${JSON.stringify(key)}`); + } + seen.add(key); + this.skipWhitespace(); + this.expect(":"); + const value = this.parseValue(); + // Use defineProperty so a "__proto__" key cannot poison the prototype. + Object.defineProperty(obj, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + this.skipWhitespace(); + const next = this.next(); + if (next === "}") return obj; + if (next !== ",") throw new SyntaxError("expected , or } in object"); + } + } + + private parseArray(): unknown[] { + this.expect("["); + const arr: unknown[] = []; + this.skipWhitespace(); + if (this.peek() === "]") { + this.pos += 1; + return arr; + } + for (;;) { + arr.push(this.parseValue()); + this.skipWhitespace(); + const next = this.next(); + if (next === "]") return arr; + if (next !== ",") throw new SyntaxError("expected , or ] in array"); + } + } + + private parseString(): string { + this.expect('"'); + let out = ""; + for (;;) { + if (this.atEnd()) throw new SyntaxError("unterminated string"); + const ch = this.src[this.pos++]; + if (ch === '"') return out; + if (ch === "\\") { + const esc = this.src[this.pos++]; + switch (esc) { + case '"': out += '"'; break; + case "\\": out += "\\"; break; + case "/": out += "/"; break; + case "b": out += "\b"; break; + case "f": out += "\f"; break; + case "n": out += "\n"; break; + case "r": out += "\r"; break; + case "t": out += "\t"; break; + case "u": { + const hex = this.src.slice(this.pos, this.pos + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new SyntaxError("bad unicode escape"); + out += String.fromCharCode(parseInt(hex, 16)); + this.pos += 4; + break; + } + default: + throw new SyntaxError("bad string escape"); + } + } else { + const code = ch.charCodeAt(0); + if (code < 0x20) throw new SyntaxError("unescaped control character in string"); + out += ch; + } + } + } + + private parseNumber(): number { + const start = this.pos; + const re = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y; + re.lastIndex = this.pos; + const match = re.exec(this.src); + if (!match || match.index !== start) throw new SyntaxError("invalid number"); + this.pos += match[0].length; + const num = Number(match[0]); + if (!Number.isFinite(num)) throw new SyntaxError("non-finite number"); + return num; + } + + private parseBoolean(): boolean { + if (this.src.startsWith("true", this.pos)) { + this.pos += 4; + return true; + } + if (this.src.startsWith("false", this.pos)) { + this.pos += 5; + return false; + } + throw new SyntaxError("invalid literal"); + } + + private parseNull(): null { + if (this.src.startsWith("null", this.pos)) { + this.pos += 4; + return null; + } + throw new SyntaxError("invalid literal"); + } + + private peek(): string { + return this.src[this.pos]; + } + + private next(): string { + return this.src[this.pos++]; + } + + private expect(ch: string): void { + if (this.src[this.pos] !== ch) { + throw new SyntaxError(`expected '${ch}'`); + } + this.pos += 1; + } +} diff --git a/packages/tailscale-https-broker/src/tailscale-cli.ts b/packages/tailscale-https-broker/src/tailscale-cli.ts new file mode 100644 index 00000000000..529f68e1647 --- /dev/null +++ b/packages/tailscale-https-broker/src/tailscale-cli.ts @@ -0,0 +1,51 @@ +/** + * Real Tailscale CLI runner: direct spawn with shell:false, absolute binary, + * minimal environment, closed inherited fds, bounded output, and a hard + * timeout+kill (PAP-17050 verdict requirement #4). Used by `main.ts` to build + * the BrokerCore `runTailscale` dependency. + */ +import { spawnSync } from "node:child_process"; +import type { CliResult } from "./broker-core.js"; + +const MAX_OUTPUT_BYTES = 256 * 1024; +const DEFAULT_TIMEOUT_MS = 10_000; + +/** Tailscale CLI versions this broker has been validated against (pinned). */ +export const SUPPORTED_TAILSCALE_VERSIONS = new Set(["1.80", "1.82", "1.84"]); + +export function createTailscaleRunner(options: { timeoutMs?: number } = {}) { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return (argv: string[]): CliResult => { + const [bin, ...args] = argv; + const result = spawnSync(bin, args, { + shell: false, + windowsHide: true, + timeout: timeoutMs, + maxBuffer: MAX_OUTPUT_BYTES, + stdio: ["ignore", "pipe", "pipe"], + env: { + PATH: "/usr/sbin:/usr/bin:/sbin:/bin", + HOME: "/nonexistent", + }, + cwd: "/", + }); + const timedOut = result.error !== undefined && (result.error as NodeJS.ErrnoException).code === "ETIMEDOUT"; + return { + code: typeof result.status === "number" ? result.status : timedOut ? 124 : 1, + stdout: (result.stdout ?? "").toString("utf8").slice(0, MAX_OUTPUT_BYTES), + stderr: (result.stderr ?? "").toString("utf8").slice(0, MAX_OUTPUT_BYTES), + timedOut, + }; + }; +} + +/** Parse the `major.minor` prefix of `tailscale version` output. */ +export function parseTailscaleMinor(versionOutput: string): string | null { + const match = /^\s*(\d+)\.(\d+)/.exec(versionOutput); + return match ? `${match[1]}.${match[2]}` : null; +} + +export function isSupportedTailscaleVersion(versionOutput: string): boolean { + const minor = parseTailscaleMinor(versionOutput); + return minor !== null && SUPPORTED_TAILSCALE_VERSIONS.has(minor); +} diff --git a/packages/tailscale-https-broker/src/types.ts b/packages/tailscale-https-broker/src/types.ts new file mode 100644 index 00000000000..298f1072f29 --- /dev/null +++ b/packages/tailscale-https-broker/src/types.ts @@ -0,0 +1,146 @@ +/** + * Wire and domain types for the least-privilege Tailscale HTTPS host broker. + * + * The broker's only capability is adding and removing Paperclip-owned, + * same-number HTTPS-to-loopback listeners. It never gains general Tailscale + * administration. See PAP-17049 (plan) and PAP-17050 (threat-model verdict). + * + * SECURITY: every value that crosses the socket is untrusted. The Paperclip + * caller, CLI output, Serve state, the registry, and listener metadata are all + * treated as untrusted inputs. + */ + +/** Supported broker protocol version. Requests on other versions are rejected. */ +export const BROKER_PROTOCOL_VERSION = 1; + +/** The protected primary route that must never change: `:443` -> loopback app. */ +export const PROTECTED_PRIMARY_PORT = 443; +export const PROTECTED_PRIMARY_TARGET = "http://127.0.0.1:3100"; + +export type BrokerOp = "expose" | "remove" | "list"; + +/** Peer credentials obtained from the OS for an accepted socket connection. */ +export interface PeerCredentials { + uid: number; + gid: number; + pid: number; +} + +/** A single owned HTTPS-to-loopback listener the broker manages. */ +export interface OwnedListener { + purpose: "app" | "vite_hmr"; + /** Public HTTPS port == target loopback port (same-number invariant). */ + port: number; +} + +/** An expose request body (already schema-validated). */ +export interface ExposeRequest { + op: "expose"; + requestId: string; + /** Runtime-service UUID the caller claims to own. */ + runtimeId: string; + /** Loopback target ports to expose; each becomes a same-number HTTPS listener. */ + listeners: OwnedListener[]; +} + +/** A remove request body. Requires a prior expose lease handle. */ +export interface RemoveRequest { + op: "remove"; + requestId: string; + runtimeId: string; + /** Unguessable handle returned by `expose`; binds ownership. */ + handle: string; +} + +/** A list request body. Scoped to the caller; never returns lease handles. */ +export interface ListRequest { + op: "list"; + requestId: string; +} + +export type BrokerRequest = ExposeRequest | RemoveRequest | ListRequest; + +export interface ExposeResponseOk { + ok: true; + op: "expose"; + requestId: string; + /** Lease handle required for a later `remove`. Never logged. */ + handle: string; + publicPorts: number[]; +} + +export interface RemoveResponseOk { + ok: true; + op: "remove"; + requestId: string; + removedPorts: number[]; +} + +export interface ListResponseOk { + ok: true; + op: "list"; + requestId: string; + /** Caller-owned listeners only; no handles, no manual/unknown Serve state. */ + listeners: Array<{ runtimeId: string; port: number; purpose: OwnedListener["purpose"] }>; +} + +export interface BrokerErrorResponse { + ok: false; + requestId: string | null; + /** Stable machine code; never leaks command lines, paths, or secrets. */ + code: BrokerErrorCode; + message: string; +} + +export type BrokerResponse = + | ExposeResponseOk + | RemoveResponseOk + | ListResponseOk + | BrokerErrorResponse; + +export type BrokerErrorCode = + | "unsupported_version" + | "malformed_request" + | "unknown_operation" + | "unauthorized_peer" + | "invalid_runtime_id" + | "invalid_port" + | "port_not_allowlisted" + | "listener_not_owned" + | "listener_ownership_mismatch" + | "manual_mapping_present" + | "primary_route_violation" + | "invalid_handle" + | "serve_parse_error" + | "cli_error" + | "cli_timeout" + | "unexpected_serve_diff" + | "quarantined" + | "rate_limited" + | "too_many_clients" + | "internal_error"; + +/** A registry lease record persisted atomically under root ownership. */ +export interface LeaseRecord { + handle: string; + runtimeId: string; + /** Peer identity bound at expose time. */ + peerUid: number; + peerGid: number; + ports: number[]; + purposes: OwnedListener["purpose"][]; + /** Monotonic generation to defeat ABA remove/re-expose confusion. */ + generation: number; + createdAtIso: string; +} + +/** Registry persisted to a root-owned 0600 file via temp+fsync+rename. */ +export interface BrokerRegistry { + version: 1; + /** Node/boot identity; a change forces quarantine + operator reconciliation. */ + nodeIdentity: string; + generationCounter: number; + leases: LeaseRecord[]; + /** Ports quarantined after ambiguous/failed cleanup; never reused/auto-freed. */ + quarantinedPorts: number[]; +} diff --git a/packages/tailscale-https-broker/tsconfig.json b/packages/tailscale-https-broker/tsconfig.json new file mode 100644 index 00000000000..5a24989cd30 --- /dev/null +++ b/packages/tailscale-https-broker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/tailscale-https-broker/vitest.config.ts b/packages/tailscale-https-broker/vitest.config.ts new file mode 100644 index 00000000000..d4036c2516e --- /dev/null +++ b/packages/tailscale-https-broker/vitest.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { defineConfig } from "vitest/config"; + +const root = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root, + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6441c9b960..b0313d7eec7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -714,6 +714,18 @@ importers: specifier: ^22.20.1 version: 22.20.1 + packages/tailscale-https-broker: + devDependencies: + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + packages/teams-catalog: devDependencies: '@types/node':