Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/packages/producer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<CardGroup cols={2}>
Expand Down
8 changes: 8 additions & 0 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): EngineConfig {
const env = (key: string): string | undefined => process.env[key];
const envNum = (key: string, fallback: number): number => {
Expand Down
7 changes: 5 additions & 2 deletions packages/engine/src/services/__fixtures__/loopback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ export async function listenOnLoopback(server: Server): Promise<string> {
return `http://127.0.0.1:${address.port}`;
}

export async function launchTestChrome(executablePath: string | undefined): Promise<Browser> {
export async function launchTestChrome(
executablePath: string | undefined,
extraArgs: readonly string[] = [],
): Promise<Browser> {
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();
Expand Down
73 changes: 73 additions & 0 deletions packages/engine/src/services/frameCapture-pageNetwork.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("./browserManager.js")>()),
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<void> {
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"]);
});
});
15 changes: 14 additions & 1 deletion packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
if (resolveRestrictPageNetwork()) await restrictPageNetwork(page, serverUrl);
}

async function constructCaptureSession(
input: CaptureSessionConstructionInput & { onPageCreated(page: Page): void },
): Promise<CaptureSession> {
Expand All @@ -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.
Expand Down Expand Up @@ -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?: <T>(fn: T, _name: string) => T };
if (typeof w.__name !== "function") {
Expand Down
183 changes: 183 additions & 0 deletions packages/engine/src/services/pageNetworkPolicy.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
"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: "<!doctype html><title>site</title>" },
);
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> {
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<void>((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<string>((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`]);
});
});
Loading
Loading