diff --git a/docs/packages/producer.mdx b/docs/packages/producer.mdx index 6e6d9e1eeb..8a45247174 100644 --- a/docs/packages/producer.mdx +++ b/docs/packages/producer.mdx @@ -192,6 +192,13 @@ For larger workloads, the `@hyperframes/producer/distributed` entry exposes the three rendering activities—`plan`, `renderChunk`, and `assemble`. Your orchestrator remains responsible for dispatch, retries, storage, and networking. +A service that renders projects from other people should set +`PRODUCER_RESTRICT_PAGE_NETWORK=true`. The render page can then reach only its own +project server and public addresses, checked by host and by every address a name +resolves to: the same rule asset downloads follow. A refused request fails like +any other failed asset and shows in the render's browser diagnostics. Leave it +unset for local renders that load assets from your own machine or network. + ## Related topics diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index 078f3dab4b..c4c1b782b7 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -819,6 +819,14 @@ export function resolvePageNavigationTimeout(): number { return Number.isFinite(parsed) ? parsed : DEFAULT_CONFIG.pageNavigationTimeout; } +/** + * `PRODUCER_RESTRICT_PAGE_NETWORK=true`: the render page may reach only its own server and public addresses. + * A property of the host (a shared render service), not of one render, so it stays out of EngineConfig snapshots. + */ +export function resolveRestrictPageNetwork(): boolean { + return process.env.PRODUCER_RESTRICT_PAGE_NETWORK === "true"; +} + export function resolveConfig(overrides?: Partial): EngineConfig { const env = (key: string): string | undefined => process.env[key]; const envNum = (key: string, fallback: number): number => { diff --git a/packages/engine/src/services/__fixtures__/loopback.ts b/packages/engine/src/services/__fixtures__/loopback.ts index 88e99d83de..acdd2a65f0 100644 --- a/packages/engine/src/services/__fixtures__/loopback.ts +++ b/packages/engine/src/services/__fixtures__/loopback.ts @@ -9,12 +9,15 @@ export async function listenOnLoopback(server: Server): Promise { return `http://127.0.0.1:${address.port}`; } -export async function launchTestChrome(executablePath: string | undefined): Promise { +export async function launchTestChrome( + executablePath: string | undefined, + extraArgs: readonly string[] = [], +): Promise { const startedAt = Date.now(); const browser = await puppeteer.launch({ executablePath, headless: true, - args: ["--no-sandbox", "--disable-dev-shm-usage"], + args: ["--no-sandbox", "--disable-dev-shm-usage", ...extraArgs], }); const launchedAt = Date.now(); await (await browser.newPage()).close(); diff --git a/packages/engine/src/services/frameCapture-pageNetwork.test.ts b/packages/engine/src/services/frameCapture-pageNetwork.test.ts new file mode 100644 index 0000000000..8c5034e912 --- /dev/null +++ b/packages/engine/src/services/frameCapture-pageNetwork.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createCaptureSession, createStaticVerificationPage } from "./frameCapture.js"; + +// Each page records what it was asked to do; `goto` stops the setup there. +const calls: string[] = []; +const fakePage = () => ({ + mainFrame: () => ({}), + on: () => {}, + close: async () => {}, + goto: async () => { + calls.push("goto"); + throw new Error("stop at navigation"); + }, + createCDPSession: async () => ({ + on: () => {}, + send: async (method: string, params?: { patterns?: { urlPattern: string }[] }) => { + if (method === "Fetch.enable" && params?.patterns?.[0]?.urlPattern === "*") { + calls.push("filter"); + } + return {}; + }, + }), + evaluateOnNewDocument: async () => {}, + setViewport: async () => {}, + evaluate: async () => undefined, +}); + +vi.mock("./browserManager.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveBrowserGpuMode: async () => "software", + acquireBrowser: async () => ({ + captureMode: "screenshot", + browser: { version: async () => "Chrome/150.0.0.0", newPage: async () => fakePage() }, + }), +})); + +const dirs: string[] = []; +afterEach(() => { + calls.length = 0; + vi.unstubAllEnvs(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +async function openPages(): Promise { + const dir = mkdtempSync(join(tmpdir(), "hf-page-network-")); + dirs.push(dir); + const session = await createCaptureSession( + "http://localhost:4321", + dir, + { width: 320, height: 180, fps: { num: 30, den: 1 }, format: "jpeg" }, + null, + { forceScreenshot: true }, + ); + calls.push("session page ready"); + await expect(createStaticVerificationPage(session)).rejects.toThrow("stop at navigation"); +} + +describe("render page address rules", () => { + it("guard every composition page before it navigates on a restricted host", async () => { + vi.stubEnv("PRODUCER_RESTRICT_PAGE_NETWORK", "true"); + await openPages(); + expect(calls).toEqual(["filter", "session page ready", "filter", "goto"]); + }); + + it("leave local renders alone", async () => { + vi.stubEnv("PRODUCER_RESTRICT_PAGE_NETWORK", ""); + await openPages(); + expect(calls).toEqual(["session page ready", "goto"]); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 9029cd3f14..970bdd6bb5 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -107,7 +107,13 @@ import { } from "./riskFrames.js"; import { getAvailableMemoryMb } from "./systemMemory.js"; import { isPsnrFilterAvailable } from "../utils/psnrFilterAvailability.js"; -import { DEFAULT_CONFIG, applyConcreteGpuScreenshotClamp, type EngineConfig } from "../config.js"; +import { + DEFAULT_CONFIG, + applyConcreteGpuScreenshotClamp, + resolveRestrictPageNetwork, + type EngineConfig, +} from "../config.js"; +import { restrictPageNetwork } from "./pageNetworkPolicy.js"; import type { CaptureOptions, CaptureVideoMetadataHint, @@ -1649,6 +1655,11 @@ async function constructCaptureSessionWithRollback( } } +/** Every page that loads the composition goes through here, before it navigates. */ +async function guardPageNetwork(page: Page, serverUrl: string): Promise { + if (resolveRestrictPageNetwork()) await restrictPageNetwork(page, serverUrl); +} + async function constructCaptureSession( input: CaptureSessionConstructionInput & { onPageCreated(page: Page): void }, ): Promise { @@ -1668,6 +1679,7 @@ async function constructCaptureSession( const page = await browser.newPage(); onPageCreated(page); + await guardPageNetwork(page, serverUrl); // Cap the existing session's inspection buffer; new sessions have separate buffers. const frame = page.mainFrame(); // Frame.client is runtime-only, so keep capture working when it is absent. @@ -4128,6 +4140,7 @@ export async function createStaticVerificationPage(session: CaptureSession): Pro const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout; try { + await guardPageNetwork(page, session.serverUrl); await page.evaluateOnNewDocument(() => { const w = window as unknown as { __name?: (fn: T, _name: string) => T }; if (typeof w.__name !== "function") { diff --git a/packages/engine/src/services/pageNetworkPolicy.browser.test.ts b/packages/engine/src/services/pageNetworkPolicy.browser.test.ts new file mode 100644 index 0000000000..c996e6e8b6 --- /dev/null +++ b/packages/engine/src/services/pageNetworkPolicy.browser.test.ts @@ -0,0 +1,183 @@ +import { spawnSync } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import type { Browser, Page } from "puppeteer-core"; +import { resolveHeadlessShellPath } from "./browserManager.js"; +import { restrictPageNetwork } from "./pageNetworkPolicy.js"; +import { closeTestChrome, launchTestChrome, listenOnLoopback } from "./__fixtures__/loopback.js"; + +const chromePath = resolveHeadlessShellPath(); +const hasBrowser = chromePath !== undefined && spawnSync(chromePath, ["--version"]).status === 0; + +// Chrome dials these names where the rules say; the policy decides from `answers`, its own resolver. +const answers: Record = { + "private.hf-test": ["127.0.0.1"], + "meta.hf-test": ["169.254.169.254"], + "public.hf-test": ["8.8.8.8"], +}; +const resolveHost = async (host: string) => { + const found = answers[host]; + if (!found) throw new Error(`ENOTFOUND ${host}`); + return found; +}; + +function serve(handler: (path: string) => { type: string; body: string }): Server { + return createServer((req, res) => { + const path = new URL(req.url ?? "/", "http://site").pathname; + if (path.startsWith("/redirect/")) { + res.writeHead(302, { location: decodeURIComponent(path.slice("/redirect/".length)) }); + return void res.end(); + } + const { type, body } = handler(path); + res.writeHead(200, { "content-type": type, "access-control-allow-origin": "*" }); + res.end(body); + }); +} + +describe.skipIf(!hasBrowser)("restrictPageNetwork in Chrome", () => { + let browser: Browser; + let site: Server; + let other: Server; + let siteOrigin: string; + let otherPort: string; + const otherHits: string[] = []; + let page: Page | undefined; + + beforeAll(async () => { + site = serve((path) => + path === "/worker.js" + ? { + type: "text/javascript", + body: "fetch(self.location.search.slice(1)).then(() => postMessage('ok'), () => postMessage('refused'));", + } + : path === "/asset.txt" + ? { type: "text/plain", body: "asset" } + : { type: "text/html", body: "site" }, + ); + other = createServer((req, res) => { + otherHits.push(req.url ?? "/"); + res.writeHead(200, { "content-type": "text/plain", "access-control-allow-origin": "*" }); + res.end("secret"); + }); + siteOrigin = await listenOnLoopback(site); + otherPort = new URL(await listenOnLoopback(other)).port; + // The render browser runs frames in-process (no site isolation); mirror that. + browser = await launchTestChrome(chromePath, [ + "--disable-features=IsolateOrigins,site-per-process", + "--host-resolver-rules=MAP private.hf-test 127.0.0.1, MAP public.hf-test 127.0.0.1, MAP meta.hf-test 169.254.169.254", + ]); + }); + afterEach(async () => { + await page?.close(); + page = undefined; + otherHits.length = 0; + }); + afterAll(async () => { + await closeTestChrome(browser); + site?.close(); + other?.close(); + }); + + async function openRestricted(refused: string[]): Promise { + page = await browser.newPage(); + page.on("requestfailed", (request) => { + if (request.failure()?.errorText.startsWith("net::ERR_BLOCKED_BY_CLIENT")) + refused.push(request.url()); + }); + await restrictPageNetwork(page, siteOrigin, resolveHost); + await page.goto(`${siteOrigin}/`, { waitUntil: "load" }); + return page; + } + + const fetchText = (target: Page, url: string) => + target.evaluate( + (u) => + fetch(u).then( + (r) => r.text(), + () => "refused", + ), + url, + ); + + it("lets the page load from its own server", async () => { + const restricted = await openRestricted([]); + expect(await fetchText(restricted, `${siteOrigin}/asset.txt`)).toBe("asset"); + }); + + it("refuses another local port, by address and by a name that resolves to it", async () => { + const refused: string[] = []; + const restricted = await openRestricted(refused); + expect(await fetchText(restricted, `http://127.0.0.1:${otherPort}/by-address`)).toBe("refused"); + expect(await fetchText(restricted, `http://private.hf-test:${otherPort}/by-name`)).toBe( + "refused", + ); + expect(otherHits).toEqual([]); + expect(refused).toEqual([ + `http://127.0.0.1:${otherPort}/by-address`, + `http://private.hf-test:${otherPort}/by-name`, + ]); + }); + + it("refuses link-local addresses, by address and by name, without waiting on them", async () => { + const refused: string[] = []; + const restricted = await openRestricted(refused); + const started = Date.now(); + expect(await fetchText(restricted, "http://169.254.169.254/")).toBe("refused"); + expect(await fetchText(restricted, "http://meta.hf-test/")).toBe("refused"); + expect(await fetchText(restricted, "http://2852039166/")).toBe("refused"); + expect(Date.now() - started).toBeLessThan(5_000); + expect(refused).toEqual([ + "http://169.254.169.254/", + "http://meta.hf-test/", + "http://169.254.169.254/", + ]); + }); + + it("refuses frames and workers too", async () => { + const restricted = await openRestricted([]); + await restricted.evaluate( + (src) => + new Promise((resolve) => { + const frame = document.createElement("iframe"); + frame.onload = frame.onerror = () => resolve(); + frame.src = src; + document.body.append(frame); + setTimeout(resolve, 2_000); + }), + `http://127.0.0.1:${otherPort}/frame`, + ); + const fromWorker = await restricted.evaluate( + (src) => + new Promise((resolve) => { + const worker = new Worker(src); + worker.onmessage = (event) => resolve(String(event.data)); + worker.onerror = () => resolve("worker failed to start"); + }), + `${siteOrigin}/worker.js?http://127.0.0.1:${otherPort}/worker`, + ); + expect(fromWorker).toBe("refused"); + expect(otherHits).toEqual([]); + }); + + it("refuses a redirect from its own server to another local port", async () => { + const restricted = await openRestricted([]); + const target = `http://127.0.0.1:${otherPort}/redirected`; + expect( + await fetchText(restricted, `${siteOrigin}/redirect/${encodeURIComponent(target)}`), + ).toBe("refused"); + expect(otherHits).toEqual([]); + }); + + it("allows a name that resolves to a public address", async () => { + const restricted = await openRestricted([]); + expect(await fetchText(restricted, `http://public.hf-test:${otherPort}/public`)).toBe("secret"); + expect(otherHits).toEqual(["/public"]); + }); + + it("refuses a name it cannot resolve", async () => { + const refused: string[] = []; + const restricted = await openRestricted(refused); + expect(await fetchText(restricted, `http://unknown.hf-test:${otherPort}/x`)).toBe("refused"); + expect(refused).toEqual([`http://unknown.hf-test:${otherPort}/x`]); + }); +}); diff --git a/packages/engine/src/services/pageNetworkPolicy.ts b/packages/engine/src/services/pageNetworkPolicy.ts new file mode 100644 index 0000000000..687030f4cf --- /dev/null +++ b/packages/engine/src/services/pageNetworkPolicy.ts @@ -0,0 +1,65 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import type { CDPSession, Page, Protocol } from "puppeteer-core"; +import { isBlockedNetworkHost } from "../utils/networkPolicy.js"; + +type ResolveHost = (hostname: string) => Promise; + +const systemResolveHost: ResolveHost = async (hostname) => + (await lookup(hostname, { all: true, verbatim: true })).map(({ address }) => address); + +async function isPublicHost(hostname: string, resolveHost: ResolveHost): Promise { + const host = hostname.replace(/^\[|\]$/g, ""); + if (isBlockedNetworkHost(host)) return false; + if (isIP(host)) return true; + try { + const addresses = await resolveHost(host); + return addresses.length > 0 && !addresses.some((a) => isBlockedNetworkHost(a.split("%")[0]!)); + } catch { + return false; + } +} + +/** + * The page reaches only its own server and public addresses (the asset downloader's rule, by host and by + * resolved address). Install before navigation; a refused request fails with net::ERR_BLOCKED_BY_CLIENT. + */ +export async function restrictPageNetwork( + page: Page, + allowedOrigin: string, + resolveHost: ResolveHost = systemResolveHost, +): Promise { + const own = new URL(allowedOrigin).origin; + const verdicts = new Map>(); + const allows = (url: string): Promise => { + let target: URL; + try { + target = new URL(url); + } catch { + return Promise.resolve(false); + } + if (target.origin === own || !/^(https?|wss?):$/.test(target.protocol)) { + return Promise.resolve(true); + } + let verdict = verdicts.get(target.hostname); + if (!verdict) { + verdict = isPublicHost(target.hostname, resolveHost); + verdicts.set(target.hostname, verdict); + } + return verdict; + }; + const client: CDPSession = await page.createCDPSession(); + client.on("Fetch.requestPaused", (event: Protocol.Fetch.RequestPausedEvent) => { + void allows(event.request.url).then((allowed) => { + const settled = allowed + ? client.send("Fetch.continueRequest", { requestId: event.requestId }) + : client.send("Fetch.failRequest", { + requestId: event.requestId, + errorReason: "BlockedByClient", + }); + // A page closed mid-request rejects the reply; there is nothing left to settle. + return settled.catch(() => undefined); + }); + }); + await client.send("Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }] }); +}