mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e14ed3031
commit
ec7adcaf3d
36 files changed
+3293
No files matched your search
@@ -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<OwnedListener["purpose"]> = 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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new ProtocolError("malformed_request", "request must be a JSON object");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireStringField(
|
||||
obj: Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user