diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index c186b36622..90c7d92088 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -756,6 +756,7 @@ cover every finding from the full check. | `--layout` | Layout knobs, currently `proseCoverageFloor=0.05` (0–1, default 0.15) | | `--browser-gpu` / `--no-browser-gpu` | Hardware GPU capture, or deterministic SwiftShader (default: auto-detect) | | `--proxy` / `--no-proxy` | Auto-transcode browser-hostile codecs before checking (default: on) | +| `--local-only` | Load only the project's own files, `data:`/`blob:` URLs and the template CDNs (GSAP on jsDelivr, Google Fonts). A request to any other host fails and is reported as a failed load. | `--caption-zone` and `--frame-check` refuse a ranged check when none of their configured seek samples falls inside `--range`; a requested gate never silently @@ -876,7 +877,7 @@ npx hyperframes snapshot my-project --at 1.5,4.3 --against ref.mp4 | `--angle` | Orthogonal 3D camera for depth and occlusion checks: `front`, `iso`, `top`, `side`, or `yaw,pitch` in degrees. Tilts the whole stage, so you get real pixels. | | `--describe` | Gemini vision analysis of each frame. Runs by default when `GEMINI_API_KEY` is set. Pass a question to override the prompt, or `--describe false` to opt out. | | `--timeout` | Ms to wait for the runtime (default 5000) | -| `--browser-gpu`, `--proxy` | Same meaning as on [`check`](#check) | +| `--browser-gpu`, `--proxy`, `--local-only` | Same meaning as on [`check`](#check) | Each snapshot is a 1920×1080 PNG taken by bundling the project, serving it, launching headless Chrome, and seeking. Useful for visual verification during diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index a15ab88eea..6b5effc7b5 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -6,6 +6,7 @@ import { import { spawn } from "node:child_process"; import type { Browser, Page } from "puppeteer-core"; import type { InProcessRequest, InProcessResponse } from "../utils/staticProjectServer.js"; +import { allowedLocalOnlyRequest, guardLocalOnlyRequests } from "./localOnlyRequests.js"; import { c } from "../ui/colors.js"; import { assertWebGpuAdapterAvailable, @@ -91,6 +92,7 @@ export interface OpenSettledCompositionPageOptions { // Runs after the page exists but before page.goto, so console/pageerror/ // request listeners can attach without missing load-time events. beforeNavigate?: (page: Page) => void | Promise; + localOnly?: boolean; } export interface FfmpegRunResult { @@ -262,7 +264,8 @@ export async function openSettledCompositionPage( const page = await chromeBrowser.newPage(); await installPageFunctionGuard(page); await page.setViewport(viewport); - if (answer) await answerPageInProcess(page, url, answer); + if (answer) await answerPageInProcess(page, url, answer, options.localOnly === true); + else if (options.localOnly) await guardLocalOnlyRequests(page, url); await options.beforeNavigate?.(page); await page.goto(url, { waitUntil: "domcontentloaded", @@ -604,18 +607,22 @@ export async function runFfmpegOnce( }); } -/** Requests to the in-process origin are answered here; any other request goes on as usual. */ +/** Requests to the in-process origin are answered here; any other request goes on as usual, or under --local-only only if allowed. */ async function answerPageInProcess( page: Page, url: string, answer: (request: InProcessRequest) => Promise, + localOnly: boolean, ): Promise { const origin = new URL(url).origin; await page.setRequestInterception(true); page.on("request", (request) => { if (request.isInterceptResolutionHandled()) return; const target = new URL(request.url()); - if (target.origin !== origin) return void request.continue().catch(() => {}); + if (target.origin !== origin) { + const blocked = localOnly && !allowedLocalOnlyRequest(request.url(), url); + return void (blocked ? request.abort("blockedbyclient") : request.continue()).catch(() => {}); + } answer({ method: request.method(), path: target.pathname + target.search, diff --git a/packages/cli/src/capture/localOnlyRequests.test.ts b/packages/cli/src/capture/localOnlyRequests.test.ts new file mode 100644 index 0000000000..1868d7ee9a --- /dev/null +++ b/packages/cli/src/capture/localOnlyRequests.test.ts @@ -0,0 +1,93 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeManagedBrowser } from "../browser/launch.js"; +import { openSettledCompositionPage } from "./captureCompositionFrame.js"; +import { allowedLocalOnlyRequest } from "./localOnlyRequests.js"; + +describe("allowedLocalOnlyRequest", () => { + const server = "http://127.0.0.1:4100/index.html"; + it.each([ + ["http://127.0.0.1:4100/assets/a.png", true], + ["data:image/png;base64,AAAA", true], + ["blob:http://127.0.0.1:4100/1234", true], + ["https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js", true], + ["https://fonts.googleapis.com/css2?family=Inter", true], + ["https://fonts.gstatic.com/s/inter/v13/a.woff2", true], + ["http://127.0.0.1:4101/secret", false], + ["https://example.com/", false], + ["https://cdn.jsdelivr.net/gh/someone/repo/payload.txt", false], + ["https://cdn.jsdelivr.net/npm/gsap-extra/index.js", false], + ["https://fonts.googleapis.com.example.com/x", false], + ])("%s -> %s", (url, allowed) => { + expect(allowedLocalOnlyRequest(url, server)).toBe(allowed); + }); +}); + +async function listen(handler: Parameters[1]): Promise<[Server, string]> { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return [server, `http://127.0.0.1:${(server.address() as AddressInfo).port}`]; +} + +describe("a --local-only page", () => { + let foreign: Server; + let foreignUrl: string; + let foreignHits = 0; + let project: Server; + let projectUrl: string; + let html = ""; + + beforeAll(async () => { + [foreign, foreignUrl] = await listen((_req, res) => { + foreignHits++; + res.writeHead(200, { "access-control-allow-origin": "*" }).end("FOREIGN TEXT"); + }); + html = ` +
+ +`; + [project, projectUrl] = await listen((_req, res) => { + res.writeHead(200, { "content-type": "text/html" }).end(html); + }); + }); + + afterAll(async () => { + await new Promise((resolve) => foreign.close(resolve)); + await new Promise((resolve) => project.close(resolve)); + }); + + async function pageTitle(localOnly: boolean): Promise<{ title: string; failed: string[] }> { + const failed: string[] = []; + const { browser, page } = await openSettledCompositionPage(html, `${projectUrl}/index.html`, { + renderReadyTimeoutMs: 2_000, + renderReadyWarningSuffix: "local-only test", + browserGpuMode: "software", + localOnly, + beforeNavigate: (p) => { + p.on("requestfailed", (r) => failed.push(`${r.url()} ${r.failure()?.errorText}`)); + }, + }); + try { + await page.waitForFunction(() => document.title !== "", { timeout: 10_000 }); + return { title: await page.title(), failed }; + } finally { + await closeManagedBrowser(browser); + } + } + + it("cannot read another host", async () => { + foreignHits = 0; + const { title, failed } = await pageTitle(true); + expect(title).toBe("blocked"); + expect(foreignHits).toBe(0); + expect(failed).toEqual([`${foreignUrl}/secret net::ERR_BLOCKED_BY_CLIENT.Inspector`]); + }, 60_000); + + it("reads it without the flag", async () => { + foreignHits = 0; + expect((await pageTitle(false)).title).toBe("FOREIGN TEXT"); + expect(foreignHits).toBe(1); + }, 60_000); +}); diff --git a/packages/cli/src/capture/localOnlyRequests.ts b/packages/cli/src/capture/localOnlyRequests.ts new file mode 100644 index 0000000000..5b07f42811 --- /dev/null +++ b/packages/cli/src/capture/localOnlyRequests.ts @@ -0,0 +1,32 @@ +import type { Page } from "puppeteer-core"; + +// The template CDNs a --local-only page still loads: GSAP and Google Fonts. +const TEMPLATE_CDN_PREFIXES = [ + "https://cdn.jsdelivr.net/npm/gsap@", + "https://cdn.jsdelivr.net/npm/gsap/", + "https://fonts.googleapis.com/", + "https://fonts.gstatic.com/", +]; + +/** True when a --local-only page may load `url`: the CLI's own server, data:/blob:, or a template CDN. */ +export function allowedLocalOnlyRequest(url: string, serverUrl: string): boolean { + if (url.startsWith("data:") || url.startsWith("blob:")) return true; + try { + if (new URL(url).origin === new URL(serverUrl).origin) return true; + } catch { + return false; + } + return TEMPLATE_CDN_PREFIXES.some((prefix) => url.startsWith(prefix)); +} + +/** Every other request, redirect hops included, fails as a load blocked by the client. */ +export async function guardLocalOnlyRequests(page: Page, serverUrl: string): Promise { + await page.setRequestInterception(true); + page.on("request", (request) => { + if (request.isInterceptResolutionHandled()) return; + const action = allowedLocalOnlyRequest(request.url(), serverUrl) + ? request.continue() + : request.abort("blockedbyclient"); + void action.catch(() => {}); + }); +} diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 2b32f9cae0..bcb566ef28 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -215,6 +215,12 @@ const CHECK_COMMAND_ARGS = { "Auto-transcode browser-hostile video codecs (default: hyperframes.json media.autoProxy, which defaults on)", default: undefined, }, + "local-only": { + type: "boolean", + description: + "Load only the project's own files, data:/blob: URLs and the template CDNs (GSAP, Google Fonts); any other host fails as a failed load", + default: false, + }, "browser-gpu": { type: "boolean", description: @@ -313,6 +319,7 @@ function parseCheckOptions(args: Record): CheckOptions { layout: parseLayout(args.layout), autoProxy: args.proxy as boolean | undefined, browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined), + localOnly: args["local-only"] === true, }; } diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 81ae52b0b7..22efae1d22 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -484,6 +484,7 @@ async function captureSnapshots( against?: string; /** Add to outputDir without clearing it; name each still by its time, not its place in the run. */ keep?: boolean; + localOnly?: boolean; }, ): Promise { const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js"); @@ -517,6 +518,7 @@ async function captureSnapshots( renderReadyTimeoutMs: opts.timeout ?? 5000, renderReadyWarningSuffix: "snapshots may be inaccurate", browserGpuMode: opts.browserGpuMode, + localOnly: opts.localOnly, }); try { @@ -1059,6 +1061,12 @@ export default defineCommand({ "Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)", default: undefined, }, + "local-only": { + type: "boolean", + description: + "Load only the project's own files, data:/blob: URLs and the template CDNs (GSAP, Google Fonts); any other host fails to load", + default: false, + }, }, async run({ args }) { if ([args.at, args.range, args.composition].filter((value) => value !== undefined).length > 1) @@ -1153,6 +1161,7 @@ export default defineCommand({ browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined), against, keep: args.keep === true, + localOnly: args["local-only"] === true, }); reportArtifacts( "snapshot", diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 28fb693eda..39616ce7ec 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -217,6 +217,7 @@ export async function runBrowserCheck( const launchSettleStart = Date.now(); const session = await openSettledCompositionPage(html, server.url, { navigationTimeoutMs: options.timeout, + localOnly: options.localOnly, renderReadyTimeoutMs: options.timeout, renderReadyWarningSuffix: "checking the current page state", browserGpuMode: options.browserGpuMode ?? resolveCliChromeGpuMode(), @@ -296,6 +297,7 @@ export async function captureFindingCrops( try { const session = await openSettledCompositionPage(html, server.url, { navigationTimeoutMs: options.timeout, + localOnly: options.localOnly, renderReadyTimeoutMs: options.timeout, renderReadyWarningSuffix: "capturing finding crops", signal: deadline?.signal, diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 23bc2fd063..1af396309c 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -28,6 +28,7 @@ export interface CheckOptions { /** Explicit --proxy/--no-proxy override; undefined preserves project config. */ autoProxy?: boolean; browserGpuMode?: BrowserGpuMode; + localOnly?: boolean; } export interface CaptionZoneOptions {