diff --git a/package.json b/package.json index ea3d16cf..cbadb166 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,7 @@ "test:browser-capture-html-diagnostics-reliability": "tsx tests/browser-capture-html-diagnostics-reliability.test.ts", "test:browser-provider-permissions": "tsx tests/browser-provider-permissions.test.ts", "test:browser-provider-bridge-inheritance": "tsx tests/browser-provider-bridge-inheritance.test.ts", + "test:host-http-transport": "tsx --test tests/host-http-transport.test.ts", "test:fixture-auth-storage-state": "tsx tests/fixture-auth-storage-state.test.ts", "test:recipe-user-session": "tsx tests/recipe-user-session.test.ts", "test:browser-scenarios": "tsx tests/browser-multi-actor-scenario.test.ts", diff --git a/packages/runtime-playground/src/host-http-transport.ts b/packages/runtime-playground/src/host-http-transport.ts new file mode 100644 index 00000000..adaa4842 --- /dev/null +++ b/packages/runtime-playground/src/host-http-transport.ts @@ -0,0 +1,266 @@ +import { randomBytes, timingSafeEqual } from "node:crypto" +import { request as httpRequest, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http" +import { request as httpsRequest } from "node:https" +import { BlockList, isIP } from "node:net" +import { lookup } from "node:dns/promises" +import type { PlaygroundCliServer } from "./preview-server.js" + +export const HOST_HTTP_TRANSPORT_SCHEMA = "wp-codebox/host-http-transport-request/v1" +export const HOST_HTTP_TRANSPORT_MAX_MESSAGE_BYTES = 64 * 1024 +const MAX_RESPONSE_BYTES = 10 * 1024 * 1024 +const MAX_TIMEOUT_MS = 60_000 +const MAX_HEADER_BYTES = 64 * 1024 + +export type HostHttpTransportMessage = { + schema: typeof HOST_HTTP_TRANSPORT_SCHEMA + id: string + method: "GET" + url: string + ips: string[] + timeoutMs: number + maxBytes: number +} + +export type HostHttpTransportResult = { + schema: "wp-codebox/host-http-transport-response/v1" + id: string + success: boolean + response?: { statusCode: number; headers: Record; bodyBase64: string; ip: string } + error?: { code: string; message: string } +} + +export type HostHttpNetworkPolicy = "allow" | "deny" | { allowHosts: string[] } +type PinnedRequester = (url: URL, ip: string, maxBytes: number, signal: AbortSignal) => Promise<{ statusCode: number; headers: Record; bodyBase64: string }> +type HostResolver = (host: string) => Promise +type HostHttpTransportDependencies = { requester?: PinnedRequester; resolveHost?: HostResolver; signal?: AbortSignal } + +export function installHostHttpTransportRoute(server: PlaygroundCliServer, networkPolicy: HostHttpNetworkPolicy): { url: string; token: string } | undefined { + if (!server.previewRoutes) return undefined + const route = `/__wp-codebox/host-http-transport-${randomBytes(12).toString("hex")}` + const token = randomBytes(32).toString("base64url") + server.previewRoutes.add(async (incoming, outgoing) => { + const requestUrl = new URL(incoming.url ?? "/", server.serverUrl) + if (requestUrl.pathname !== route) return false + if (incoming.method !== "POST" || !validBearerToken(incoming.headers.authorization, token)) { + writeJson(outgoing, 404, { error: "Not found." }) + return true + } + let body: string + try { + body = await readBoundedBody(incoming) + } catch (error) { + writeJson(outgoing, 400, { error: error instanceof Error ? error.message : "Invalid request." }) + return true + } + const message = parseHostHttpTransportMessage(body) + if (!message) { + writeJson(outgoing, 400, hostHttpError("", "invalid_request", "The host HTTP request is invalid or contains a non-public target.")) + return true + } + const controller = new AbortController() + const abort = () => controller.abort(new Error("The host HTTP bridge client disconnected.")) + incoming.once("aborted", abort) + outgoing.once("close", abort) + try { + writeJson(outgoing, 200, await executeHostHttpTransportRequest(message, networkPolicy, { signal: controller.signal })) + } finally { + incoming.off("aborted", abort) + outgoing.off("close", abort) + } + return true + }) + return { url: new URL(route, server.serverUrl).toString(), token } +} + +const privateAddresses = new BlockList() +for (const [network, prefix] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]] as const) privateAddresses.addSubnet(network, prefix, "ipv4") +for (const [network, prefix] of [["::", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8]] as const) privateAddresses.addSubnet(network, prefix, "ipv6") + +export function parseHostHttpTransportMessage(data: string): HostHttpTransportMessage | undefined { + if (Buffer.byteLength(data) > HOST_HTTP_TRANSPORT_MAX_MESSAGE_BYTES) return undefined + let value: unknown + try { + value = JSON.parse(data) + } catch { + return undefined + } + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + const message = value as Partial + if (message.schema !== HOST_HTTP_TRANSPORT_SCHEMA || message.method !== "GET" || typeof message.id !== "string" || message.id.length < 1 || message.id.length > 128 || typeof message.url !== "string" || !Array.isArray(message.ips) || message.ips.length < 1 || message.ips.length > 16 || !Number.isInteger(message.timeoutMs) || !Number.isInteger(message.maxBytes)) return undefined + if (message.timeoutMs! < 1 || message.timeoutMs! > MAX_TIMEOUT_MS || message.maxBytes! < 1 || message.maxBytes! > MAX_RESPONSE_BYTES || message.ips.some((ip) => typeof ip !== "string" || !isPublicIp(ip))) return undefined + try { + const url = new URL(message.url) + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || !url.hostname) return undefined + } catch { + return undefined + } + return message as HostHttpTransportMessage +} + +export async function executeHostHttpTransportRequest(message: HostHttpTransportMessage, networkPolicy: HostHttpNetworkPolicy, dependencies: HostHttpTransportDependencies = {}): Promise { + if (!validHostHttpTransportMessage(message)) return hostHttpError(message.id, "invalid_request", "The host HTTP request is invalid or contains a non-public target.") + const url = new URL(message.url) + if (!networkPolicyAllows(networkPolicy, url)) return hostHttpError(message.id, "network_denied", "Runtime network policy does not allow the host HTTP target.") + const deadline = Date.now() + message.timeoutMs + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new Error("The host HTTP request deadline was exhausted.")), message.timeoutMs) + const abort = () => controller.abort(dependencies.signal?.reason) + if (dependencies.signal?.aborted) abort() + else dependencies.signal?.addEventListener("abort", abort, { once: true }) + const requester = dependencies.requester ?? requestPinnedIp + let requestIps = message.ips + let lastError: Error | undefined + try { + if (typeof networkPolicy === "object") { + const resolveHost = dependencies.resolveHost ?? resolvePublicHost + let resolved: string[] + try { + resolved = await abortable(resolveHost(url.hostname), controller.signal) + } catch { + return hostHttpError(message.id, controller.signal.aborted ? "deadline_exhausted" : "host_resolution_failed", controller.signal.aborted ? "The host HTTP request deadline was exhausted." : "The allowed host could not be resolved by the host transport.") + } + requestIps = message.ips.filter((ip) => resolved.some((candidate) => sameIp(ip, candidate))) + if (requestIps.length === 0) return hostHttpError(message.id, "target_ip_mismatch", "The supplied target addresses do not match host-side resolution for the allowed host.") + } + for (const ip of requestIps) { + if (controller.signal.aborted || Date.now() >= deadline) return hostHttpError(message.id, "deadline_exhausted", "The host HTTP request deadline was exhausted.") + try { + const response = await requester(url, ip, message.maxBytes, controller.signal) + return { schema: "wp-codebox/host-http-transport-response/v1", id: message.id, success: true, response: { ...response, ip } } + } catch (error) { + lastError = error instanceof Error ? error : new Error("Host HTTP request failed.") + if (controller.signal.aborted) return hostHttpError(message.id, "deadline_exhausted", "The host HTTP request deadline was exhausted.") + if ((lastError as NodeJS.ErrnoException).code === "WP_CODEBOX_HOST_HTTP_TOO_LARGE") return hostHttpError(message.id, "response_too_large", lastError.message) + } + } + } finally { + clearTimeout(timer) + dependencies.signal?.removeEventListener("abort", abort) + } + return hostHttpError(message.id, "connect_failed", lastError?.message ?? "Could not connect to the validated public target.") +} + +async function resolvePublicHost(host: string): Promise { + return (await lookup(host, { all: true, verbatim: true })).map(({ address }) => address).filter(isPublicIp) +} + +function sameIp(left: string, right: string): boolean { + const family = isIP(left) + if (family === 0 || family !== isIP(right)) return false + const addresses = new BlockList() + addresses.addAddress(right, family === 4 ? "ipv4" : "ipv6") + return addresses.check(left, family === 4 ? "ipv4" : "ipv6") +} + +function abortable(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason) + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason) + signal.addEventListener("abort", abort, { once: true }) + promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)) + }) +} + +function requestPinnedIp(url: URL, ip: string, maxBytes: number, signal: AbortSignal): Promise<{ statusCode: number; headers: Record; bodyBase64: string }> { + return new Promise((resolve, reject) => { + const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, { + method: "GET", + headers: { Host: url.host, "User-Agent": "WP-Codebox-Host-HTTP/1.0", Accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.1", Connection: "close" }, + lookup: (_hostname, options, callback) => { + const family = isIP(ip) as 4 | 6 + const done = callback as unknown as (...args: unknown[]) => void + if (typeof options === "object" && options.all) done(null, [{ address: ip, family }]) + else done(null, ip, family) + }, + maxHeaderSize: MAX_HEADER_BYTES, + servername: url.protocol === "https:" ? url.hostname : undefined, + }, (response) => { + const chunks: Buffer[] = [] + let bytes = 0 + response.on("data", (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytes += buffer.length + if (bytes > maxBytes) { + const error = new Error("The host HTTP response exceeded the maximum allowed size.") as NodeJS.ErrnoException + error.code = "WP_CODEBOX_HOST_HTTP_TOO_LARGE" + request.destroy(error) + return + } + chunks.push(buffer) + }) + response.on("end", () => resolve({ statusCode: response.statusCode ?? 0, headers: normalizeHeaders(response.headers), bodyBase64: Buffer.concat(chunks).toString("base64") })) + response.on("error", reject) + }) + const abort = () => request.destroy(signal.reason instanceof Error ? signal.reason : new Error("The host HTTP request was aborted.")) + if (signal.aborted) abort() + else signal.addEventListener("abort", abort, { once: true }) + request.on("error", reject) + request.on("close", () => signal.removeEventListener("abort", abort)) + request.end() + }) +} + +function normalizeHeaders(headers: IncomingHttpHeaders): Record { + const normalized: Record = {} + for (const [name, value] of Object.entries(headers)) { + if (value === undefined) continue + normalized[name.toLowerCase()] = Array.isArray(value) ? value.map(String) : [String(value)] + } + return normalized +} + +function isPublicIp(ip: string): boolean { + const family = isIP(ip) + return family === 4 ? !privateAddresses.check(ip, "ipv4") : family === 6 ? !privateAddresses.check(ip, "ipv6") : false +} + +function validHostHttpTransportMessage(message: HostHttpTransportMessage): boolean { + return parseHostHttpTransportMessage(JSON.stringify(message)) !== undefined +} + +function networkPolicyAllows(policy: HostHttpNetworkPolicy, url: URL): boolean { + if (policy === "deny") return false + if (policy === "allow") return true + const host = url.hostname.toLowerCase() + const port = url.port || (url.protocol === "https:" ? "443" : "80") + return policy.allowHosts.some((candidate) => { + const normalized = candidate.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "") + return normalized === host || normalized === `${host}:${port}` + }) +} + +function hostHttpError(id: string, code: string, message: string): HostHttpTransportResult { + return { schema: "wp-codebox/host-http-transport-response/v1", id, success: false, error: { code, message } } +} + +function validBearerToken(authorization: string | undefined, token: string): boolean { + const supplied = authorization?.startsWith("Bearer ") ? authorization.slice(7) : "" + const left = Buffer.from(supplied) + const right = Buffer.from(token) + return left.length === right.length && timingSafeEqual(left, right) +} + +function readBoundedBody(incoming: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let bytes = 0 + incoming.on("data", (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytes += buffer.length + if (bytes > HOST_HTTP_TRANSPORT_MAX_MESSAGE_BYTES) { + reject(new Error("The host HTTP bridge request is too large.")) + incoming.destroy() + return + } + chunks.push(buffer) + }) + incoming.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + incoming.on("error", reject) + }) +} + +function writeJson(outgoing: ServerResponse, statusCode: number, payload: unknown): void { + const body = Buffer.from(`${JSON.stringify(payload)}\n`) + outgoing.writeHead(statusCode, { "content-type": "application/json", "content-length": String(body.length), "cache-control": "no-store" }) + outgoing.end(body) +} diff --git a/packages/runtime-playground/src/php-bootstrap.ts b/packages/runtime-playground/src/php-bootstrap.ts index 5b570373..b4f64323 100644 --- a/packages/runtime-playground/src/php-bootstrap.ts +++ b/packages/runtime-playground/src/php-bootstrap.ts @@ -8,7 +8,12 @@ interface PhpBootstrapBridge { token: string } -export function bootstrapAbilityPhpCode(spec: RuntimeCreateSpec, code: string): string { +export interface PhpHostHttpTransportBridge { + url: string + token: string +} + +export function bootstrapAbilityPhpCode(spec: RuntimeCreateSpec, code: string, hostHttpTransport?: PhpHostHttpTransportBridge): string { assertRuntimeSecretEnvTargetsAvailable(spec.secretEnvTargets, spec.runtimeEnv ?? {}) return ` array('authorization' => ${JSON.stringify(`Bearer ${bridge.token}`)}, 'content-type' => 'application/json'), + 'body' => $payload, + 'timeout' => $timeout, + 'redirection' => 0, + )); + if (is_wp_error($response)) { + return wp_json_encode(array('schema' => 'wp-codebox/host-http-transport-response/v1', 'id' => is_array($message) ? (string) ($message['id'] ?? '') : '', 'success' => false, 'error' => array('code' => 'bridge_unavailable', 'message' => $response->get_error_message())), JSON_UNESCAPED_SLASHES); + } + return (string) wp_remote_retrieve_body($response); + } +} +` +} + function multisiteRequestBootstrapPhp(): string { return `$wp_codebox_wp_config = @file_get_contents('/wordpress/wp-config.php'); if (is_string($wp_codebox_wp_config)) { diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index 62b2b92a..cf6f0c38 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -28,6 +28,7 @@ import { createRuntimeWpCliBridge, type RuntimeWpCliBridge } from "./runtime-wp- import { writeReplayExportPackage } from "./replayable-wordpress-site-bundle.js" import { preflightPhpWasmRuntimeAssets } from "./php-wasm-preflight.js" import { previewReviewerAccess } from "./preview-reviewer-access.js" +import { installHostHttpTransportRoute } from "./host-http-transport.js" import { wordpressActionAuthNoncePhpCode, wordpressFixtureUserWithoutPassword, wordpressUserSessionFromCommandArgs, type WordPressUserSessionResolution } from "./wordpress-user-sessions.js" import type { ArtifactBundle, @@ -1868,10 +1869,12 @@ echo json_encode(array('command' => 'inspect-mounted-inputs', 'mounts' => $inspe } private async startPlayground(): Promise { - return startPlaygroundCliServer(this.spec, this.mounts, { + const server = await startPlaygroundCliServer(this.spec, this.mounts, { onProgress: (event) => this.recordBrowserStartupProgress(event), cliModule: this.backendOptions.cliModule, }) + server.hostHttpTransport = installHostHttpTransportRoute(server, this.spec.policy.network) + return server } private recordBrowserStartupProgress(event: BrowserStartupProgressEvent): void { diff --git a/packages/runtime-playground/src/preview-server.ts b/packages/runtime-playground/src/preview-server.ts index e34982dc..ade56a02 100644 --- a/packages/runtime-playground/src/preview-server.ts +++ b/packages/runtime-playground/src/preview-server.ts @@ -23,6 +23,7 @@ export interface PlaygroundCliServer { previewLease?: PreviewLease previewRoutes?: PlaygroundPreviewRouteRegistry previewProxyDiagnostics?: PlaygroundPreviewProxyDiagnostics + hostHttpTransport?: { url: string; token: string } [Symbol.asyncDispose](): Promise } diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index 82d2d3df..c03a7401 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -59,6 +59,7 @@ import { parsePhpunitOutput } from "./phpunit-test-results.js" import type { RuntimeWpCliBridge } from "./runtime-wp-cli-bridge.js" import { COMMAND_DIAGNOSTICS_ARTIFACT_SCHEMA, PERFORMANCE_OBSERVATION_SCHEMA, commandDiagnosticsCaptureArgs, commandDiagnosticsCaptureSpecFromArgs, createRuntimeCommandResultEnvelope, redactJsonValue, type ExecutionSpec, type MountSpec, type PerformanceObservation, type RuntimeCommandResultEnvelope, type RuntimeCreateSpec, type RuntimeEpisodeTraceRef } from "@automattic/wp-codebox-core" import { wordpressUserSessionFromCommandArgs } from "./wordpress-user-sessions.js" +import { executeHostHttpTransportRequest, parseHostHttpTransportMessage } from "./host-http-transport.js" type RunPlaygroundCommand = (command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }) => Promise type RunWpCliCommand = (server: PlaygroundCliServer, argv: string[]) => Promise @@ -94,9 +95,9 @@ export async function runPhpCommand({ const commandCode = diagnosticsCapture && marker ? runPhpCommandDiagnosticsPhp(code, marker, diagnosticsCapture.maxItems ?? 50, diagnosticsCapture.maxBytes ?? 64 * 1024) : code const bridge = argValue(spec.args ?? [], "wp-cli-bridge") === "1" ? await createRuntimeWpCliBridge(server) : undefined let response: PlaygroundRunResponse - const removeProviderProxy = installBrowserProviderProxy(server) + const removeProviderProxy = installPhpMessageBridges(server, runtimeSpec.policy.network) try { - response = await runPlaygroundCommand("wordpress.run-php", server, { code: bootstrapPhpCode(runtimeSpec, commandCode, bootstrapArgs, bridge) }) + response = await runPlaygroundCommand("wordpress.run-php", server, { code: bootstrapPhpCode(runtimeSpec, commandCode, bootstrapArgs, bridge, undefined, server.hostHttpTransport) }) assertPlaygroundResponseOk("wordpress.run-php", response) } finally { await removeProviderProxy?.() @@ -292,12 +293,23 @@ async function writeCommandDiagnosticsArtifact(artifactRoot: string, diagnostics } } -function installBrowserProviderProxy(server: PlaygroundCliServer): (() => Promise) | undefined { +function installPhpMessageBridges(server: PlaygroundCliServer, networkPolicy: RuntimeCreateSpec["policy"]["network"]): (() => Promise) | undefined { if (!server.playground.onMessage) { return undefined } + const activeHostRequests = new Set() const remove = server.playground.onMessage(async (data) => { + const hostHttpMessage = parseHostHttpTransportMessage(data) + if (hostHttpMessage) { + const controller = new AbortController() + activeHostRequests.add(controller) + try { + return JSON.stringify(await executeHostHttpTransportRequest(hostHttpMessage, networkPolicy, { signal: controller.signal })) + } finally { + activeHostRequests.delete(controller) + } + } const message = parseBrowserProviderProxyMessage(data) if (!message) { return undefined @@ -307,6 +319,7 @@ function installBrowserProviderProxy(server: PlaygroundCliServer): (() => Promis }) return async () => { + for (const controller of activeHostRequests) controller.abort(new Error("The runtime command ended before the host HTTP request completed.")) const cleanup = await remove if (typeof cleanup === "function") { await cleanup() @@ -657,7 +670,13 @@ export async function runAbilityCommand({ throw new Error("wordpress.ability accepts either user/session or principal, not both") } const expectedResultSchema = expectedAbilityResultSchemaFromArgs(spec.args ?? []) - const response = await runPlaygroundCommand("wordpress.ability", server, { code: bootstrapAbilityPhpCode(runtimeSpec, abilityPhpCode({ name, input, userSession, principal })) }) + const removeMessageBridges = installPhpMessageBridges(server, runtimeSpec.policy.network) + let response: PlaygroundRunResponse + try { + response = await runPlaygroundCommand("wordpress.ability", server, { code: bootstrapAbilityPhpCode(runtimeSpec, abilityPhpCode({ name, input, userSession, principal }), server.hostHttpTransport) }) + } finally { + await removeMessageBridges?.() + } assertPlaygroundResponseOk("wordpress.ability", response) return abilityResponseToCommandEnvelope(cleanWpCliOutput(response.text), name, input, expectedResultSchema) } diff --git a/scripts/smoke-manifest.ts b/scripts/smoke-manifest.ts index 8770b295..3bb60af4 100644 --- a/scripts/smoke-manifest.ts +++ b/scripts/smoke-manifest.ts @@ -48,6 +48,7 @@ export const smokeGroups = { npmScript("test:browser-runtime-file-ops"), npmScript("test:browser-prepared-runtime-filesystem-overlays"), npmScript("test:browser-provider-bridge-inheritance"), + npmScript("test:host-http-transport"), npmScript("test:browser-preview-routing"), npmScript("test:browser-routed-command-security"), tsxSmoke("runtime-backend-registry-smoke"), diff --git a/tests/host-http-transport.test.ts b/tests/host-http-transport.test.ts new file mode 100644 index 00000000..c7baa711 --- /dev/null +++ b/tests/host-http-transport.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { once } from "node:events" +import test from "node:test" +import { executeHostHttpTransportRequest, installHostHttpTransportRoute, parseHostHttpTransportMessage } from "../packages/runtime-playground/src/host-http-transport.js" +import type { PlaygroundCliServer, PlaygroundPreviewRouteHandler } from "../packages/runtime-playground/src/preview-server.js" + +test("host HTTP messages reject private and malformed targets", () => { + const base = { schema: "wp-codebox/host-http-transport-request/v1", id: "request-1", method: "GET", url: "https://example.com/", ips: ["93.184.216.34"], timeoutMs: 1000, maxBytes: 1024 } + assert.ok(parseHostHttpTransportMessage(JSON.stringify(base))) + for (const ip of ["127.0.0.1", "10.0.0.1", "::1", "0:0:0:0:0:0:0:1", "::127.0.0.1", "0:0:0:0:0:ffff:7f00:1", "0:0:0:0:0:0:0:0", "64:ff9b:1::a9fe:a9fe", "fc00::1", "fe80::1", "fec0::1", "fedf:ffff::1", "2001:db8::1"]) { + assert.equal(parseHostHttpTransportMessage(JSON.stringify({ ...base, ips: [ip] })), undefined, ip) + } + assert.equal(parseHostHttpTransportMessage(JSON.stringify({ ...base, method: "POST" })), undefined) + assert.equal(parseHostHttpTransportMessage(JSON.stringify({ ...base, timeoutMs: 60_001 })), undefined) +}) + +test("host HTTP requests enforce policy, pin public IPs, and preserve raw bytes", async () => { + const message = { schema: "wp-codebox/host-http-transport-request/v1" as const, id: "request-2", method: "GET" as const, url: "https://example.com/payload", ips: ["93.184.216.34"], timeoutMs: 1000, maxBytes: 1024 } + const denied = await executeHostHttpTransportRequest(message, "deny") + assert.equal(denied.error?.code, "network_denied") + const hostDenied = await executeHostHttpTransportRequest(message, { allowHosts: ["other.example"] }) + assert.equal(hostDenied.error?.code, "network_denied") + + const result = await executeHostHttpTransportRequest(message, { allowHosts: ["example.com"] }, { + resolveHost: async () => [message.ips[0]], + requester: async (url, ip, maxBytes, signal) => { + assert.equal(url.toString(), message.url) + assert.equal(ip, message.ips[0]) + assert.equal(maxBytes, message.maxBytes) + assert.equal(signal.aborted, false) + return { statusCode: 200, headers: { "x-test": ["pinned"] }, bodyBase64: Buffer.from([0, 1, 2, 255]).toString("base64") } + }, + }) + assert.equal(result.success, true) + assert.equal(result.response?.statusCode, 200) + assert.equal(result.response?.headers["x-test"]?.[0], "pinned") + assert.deepEqual(Buffer.from(result.response?.bodyBase64 ?? "", "base64"), Buffer.from([0, 1, 2, 255])) + + const privateResult = await executeHostHttpTransportRequest({ ...message, ips: ["127.0.0.1"] }, "allow") + assert.equal(privateResult.error?.code, "invalid_request") + const mismatch = await executeHostHttpTransportRequest(message, { allowHosts: ["example.com"] }, { resolveHost: async () => ["1.1.1.1"] }) + assert.equal(mismatch.error?.code, "target_ip_mismatch") +}) + +test("host HTTP requests abort at the absolute timeout", async () => { + const started = Date.now() + const result = await executeHostHttpTransportRequest( + { schema: "wp-codebox/host-http-transport-request/v1", id: "request-3", method: "GET", url: "https://example.com/stream", ips: ["93.184.216.34"], timeoutMs: 100, maxBytes: 1024 }, + "allow", + { requester: async (_url, _ip, _maxBytes, signal) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true })) }, + ) + assert.equal(result.success, false) + assert.equal(result.error?.code, "deadline_exhausted") + assert.ok(Date.now() - started < 500) +}) + +test("command cancellation aborts in-flight host HTTP work", async () => { + const controller = new AbortController() + const pending = executeHostHttpTransportRequest( + { schema: "wp-codebox/host-http-transport-request/v1", id: "request-4", method: "GET", url: "https://example.com/stream", ips: ["93.184.216.34"], timeoutMs: 10_000, maxBytes: 1024 }, + "allow", + { signal: controller.signal, requester: async (_url, _ip, _maxBytes, signal) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true })) }, + ) + controller.abort(new Error("command ended")) + const result = await pending + assert.equal(result.error?.code, "deadline_exhausted") +}) + +test("local host HTTP route authenticates worker-backed PHP requests and applies runtime policy", async () => { + const handlers = new Set() + const httpServer = createServer(async (incoming, outgoing) => { + for (const handler of handlers) { + if (await handler(incoming, outgoing)) return + } + outgoing.writeHead(404).end() + }) + httpServer.listen(0, "127.0.0.1") + await once(httpServer, "listening") + const address = httpServer.address() + assert.ok(address && typeof address === "object") + const server = { + serverUrl: `http://127.0.0.1:${address.port}`, + playground: { run: async () => ({ text: "" }) }, + previewRoutes: { add: (handler: PlaygroundPreviewRouteHandler) => { handlers.add(handler); return () => handlers.delete(handler) } }, + async [Symbol.asyncDispose]() {}, + } satisfies PlaygroundCliServer + const endpoint = installHostHttpTransportRoute(server, "deny") + assert.ok(endpoint) + const message = { schema: "wp-codebox/host-http-transport-request/v1", id: "route-1", method: "GET", url: "https://example.com/", ips: ["93.184.216.34"], timeoutMs: 1000, maxBytes: 1024 } + try { + const unauthenticated = await fetch(endpoint.url, { method: "POST", body: JSON.stringify(message) }) + assert.equal(unauthenticated.status, 404) + const authenticated = await fetch(endpoint.url, { method: "POST", headers: { authorization: `Bearer ${endpoint.token}` }, body: JSON.stringify(message) }) + assert.equal(authenticated.status, 200) + assert.equal((await authenticated.json() as { error?: { code?: string } }).error?.code, "network_denied") + } finally { + httpServer.close() + await once(httpServer, "close") + } +})