From 13453a1308ac7ba8bc7669f34d79490b69d81bf1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 14 Nov 2026 11:17:13 -0500 Subject: [PATCH 1/2] fix(engine): hold the render page to the asset download address rules With PRODUCER_RESTRICT_PAGE_NETWORK=true the render page reaches only its own project server and public addresses, checked by host and by resolved address, the rule asset downloads already follow. Unset, nothing changes for local renders. --- docs/packages/producer.mdx | 7 + packages/engine/src/config.ts | 8 + .../src/services/__fixtures__/loopback.ts | 7 +- .../services/frameCapture-pageNetwork.test.ts | 73 +++++++ packages/engine/src/services/frameCapture.ts | 15 +- .../pageNetworkPolicy.browser.test.ts | 183 ++++++++++++++++++ .../engine/src/services/pageNetworkPolicy.ts | 66 +++++++ 7 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 packages/engine/src/services/frameCapture-pageNetwork.test.ts create mode 100644 packages/engine/src/services/pageNetworkPolicy.browser.test.ts create mode 100644 packages/engine/src/services/pageNetworkPolicy.ts diff --git a/docs/packages/producer.mdx b/docs/packages/producer.mdx index 6e6d9e1eebe..8a452471742 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 078f3dab4bc..c4c1b782b77 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 88e99d83dec..acdd2a65f00 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 00000000000..8c5034e9129 --- /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 9029cd3f148..970bdd6bb51 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 00000000000..c996e6e8b6e --- /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 00000000000..0102b8e5b88 --- /dev/null +++ b/packages/engine/src/services/pageNetworkPolicy.ts @@ -0,0 +1,66 @@ +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 may reach its own server and public addresses only: the asset downloader's rule, applied to + * the literal host and to every address a name resolves to. Install before the page navigates; 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" }] }); +} From a6037e1489e30c68708d09921d5c0e7be2362608 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 14 Nov 2026 11:19:01 -0500 Subject: [PATCH 2/2] fix(engine): keep the page network filter comment within four lines --- packages/engine/src/services/pageNetworkPolicy.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/services/pageNetworkPolicy.ts b/packages/engine/src/services/pageNetworkPolicy.ts index 0102b8e5b88..687030f4cf4 100644 --- a/packages/engine/src/services/pageNetworkPolicy.ts +++ b/packages/engine/src/services/pageNetworkPolicy.ts @@ -21,9 +21,8 @@ async function isPublicHost(hostname: string, resolveHost: ResolveHost): Promise } /** - * The page may reach its own server and public addresses only: the asset downloader's rule, applied to - * the literal host and to every address a name resolves to. Install before the page navigates; a refused - * request fails with net::ERR_BLOCKED_BY_CLIENT. + * 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,