diff --git a/apps/browser-demos/pages/test-runner/main.ts b/apps/browser-demos/pages/test-runner/main.ts index 4babb8d7e3..6de5cf991e 100644 --- a/apps/browser-demos/pages/test-runner/main.ts +++ b/apps/browser-demos/pages/test-runner/main.ts @@ -50,6 +50,11 @@ let grepBytes: ArrayBuffer | null = null; let sedBytes: ArrayBuffer | null = null; let genCatBytes: ArrayBuffer | null = null; +const corsProxyUrl = new URL( + `${import.meta.env.BASE_URL}__kandelo_cors_proxy?url=`, + window.location.href, +).href; + const COREUTILS_NAMES = [ "arch", "b2sum", "base32", "base64", "basename", "basenc", "cat", "chcon", "chgrp", "chmod", "chown", "chroot", "cksum", "comm", "cp", @@ -182,6 +187,7 @@ async function init() { const kernel = new BrowserKernel({ kernelOwnedFs: true, + corsProxyUrl, onStdout: (data: Uint8Array) => { const text = new TextDecoder().decode(data); stdout += text; diff --git a/apps/browser-demos/test/browser-cors-proxy.spec.ts b/apps/browser-demos/test/browser-cors-proxy.spec.ts new file mode 100644 index 0000000000..1965e3634a --- /dev/null +++ b/apps/browser-demos/test/browser-cors-proxy.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { readFile } from "node:fs/promises"; +import { resolveBinary } from "../../../host/src/binary-resolver"; + +const wgetPath = resolveBinary("programs/wget.wasm"); + +type TestResult = { + exitCode: number; + stdout: string; + stderr: string; + combined: string; +}; + +type TestRunnerWindow = Window & { + __testRunnerReady: boolean; + __runTest( + wasmBytes: ArrayBuffer, + argv: string[], + timeoutMs: number, + ): Promise; +}; + +test("guest HTTP uses the test runner's same-origin CORS proxy", async ({ + page, +}) => { + const upstreamRequests: string[] = []; + const upstream = createServer((request, response) => { + upstreamRequests.push(request.url ?? ""); + response.writeHead(200, { "Content-Type": "text/plain" }); + response.end("Kandelo CORS proxy regression\n"); + }); + await new Promise((resolve, reject) => { + upstream.once("error", reject); + upstream.listen(0, "::1", () => { + upstream.off("error", reject); + resolve(); + }); + }); + + try { + const { port } = upstream.address() as AddressInfo; + // The trailing root dot avoids the guest's /etc/hosts localhost entry, so + // Kandelo delegates the connection to its browser backend. Node still + // resolves the proxy's upstream target to this test-only ::1 listener. + const targetUrl = `http://localhost.:${port}/probe`; + const wgetBytes = Array.from(await readFile(wgetPath)); + + await page.goto("/pages/test-runner/", { + waitUntil: "domcontentloaded", + }); + await page.waitForFunction( + () => (window as unknown as TestRunnerWindow).__testRunnerReady === true, + ); + expect( + await page.evaluate(() => navigator.serviceWorker.controller), + "the regression must exercise explicit BrowserKernel proxy configuration", + ).toBeNull(); + + const result = await page.evaluate( + async ({ bytes, url }) => + (window as unknown as TestRunnerWindow).__runTest( + new Uint8Array(bytes).buffer, + ["wget", "-qO-", url], + 60_000, + ), + { bytes: wgetBytes, url: targetUrl }, + ); + + expect( + result.exitCode, + JSON.stringify({ result, upstreamRequests }, null, 2), + ).toBe(0); + expect(result.stdout).toBe("Kandelo CORS proxy regression\n"); + expect(upstreamRequests).toEqual(["/probe"]); + } finally { + await new Promise((resolve, reject) => { + upstream.close((error) => (error ? reject(error) : resolve())); + }); + } +}); diff --git a/docs/architecture.md b/docs/architecture.md index 2441010bb0..1ca2326f56 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -733,7 +733,9 @@ Browsers cannot create external raw TCP or UDP sockets. Local loopback and `Loca 1. **FetchNetworkBackend**: Buffers an entire HTTP request from the Wasm process, sends it via `fetch()`, and returns the raw HTTP response bytes. Works for simple HTTP clients. -2. **Service Worker HTTP Bridge**: For server demos (nginx, WordPress), a service worker intercepts browser `fetch()` requests to a configurable URL prefix (e.g., `/app/`) and forwards them to the kernel via a MessagePort connection pump. The kernel injects the request as a TCP connection to nginx's listening socket, and nginx's response flows back through the pipe to the service worker. +2. **TlsNetworkBackend**: Terminates the guest's TLS connection with a generated, in-VFS CA and sends the decoded HTTP request through browser `fetch()`. Service-worker-controlled apps may proxy cross-origin fetches transparently. Other embedders set `BrowserKernelOptions.corsProxyUrl`; the option crosses the main-thread/worker protocol and routes backend fetches through the application's CORS proxy. + +3. **Service Worker HTTP Bridge**: For server demos (nginx, WordPress), a service worker intercepts browser `fetch()` requests to a configurable URL prefix (e.g., `/app/`) and forwards them to the kernel via a MessagePort connection pump. The kernel injects the request as a TCP connection to nginx's listening socket, and nginx's response flows back through the pipe to the service worker. `TcpNetworkBackend`, `FetchNetworkBackend`, `TlsNetworkBackend`, and `LocalVirtualNetwork` share one numeric-address and hostname validator. It accepts decimal one-, two-, three-, and four-component IPv4 forms within their component widths, rejects malformed or overflowing numeric forms, enforces ASCII host-label syntax and DNS length limits, and preserves one trailing root dot. The Node TCP backend resolves validated names through the host resolver. The browser HTTP fetch/TLS bridges synthesize IPv4 mappings for syntactically acceptable DNS names; `LocalVirtualNetwork` resolves only aliases registered by attached machines. None of the browser paths adds browser DNS resolution or AF_INET6 transport. diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index 439b0fa4d1..ab22d026df 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -107,7 +107,10 @@ describe("BrowserKernel", () => { it("boot() spawns a worker, sends init, and resolves on `ready`", async () => { const BrowserKernel = await loadBrowserKernel(); - const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + corsProxyUrl: "https://proxy.example/?url=", + }); const bootPromise = kernel.boot({ kernelWasm: new ArrayBuffer(8), @@ -123,6 +126,7 @@ describe("BrowserKernel", () => { expect(init).toBeDefined(); expect(init.argv).toBeUndefined(); // argv goes in the spawn message expect(init.kernelWasmBytes).toBeInstanceOf(ArrayBuffer); + expect(init.config.corsProxyUrl).toBe("https://proxy.example/?url="); // Simulate the worker becoming ready, then reply to the spawn request. w.simulateMessage({ type: "ready" });