mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): refuse capture folders that resolve outside the output root (#4789)
* fix(cli): refuse capture directories that resolve outside the output root writeCaptureFileSync guards the final path entry, but a directory in the path that is itself a planted symlink (assets -> ~/somewhere) still carried the staging directory and the rename into wherever it pointed, with a filename the captured page chose. Every capture directory is now created through ensureCaptureDirSync(root, dir), which walks from the output root one level at a time and refuses any directory whose real path is not the one it has under the root, before creating anything inside it. The root itself stays trusted as given, so a symlinked root like /tmp on macOS keeps working. Fixes #4304 * fix(cli): vet assets/svgs before the SVG contact sheet writes into it Under --skip-assets, downloadAssets never runs, so nothing checked assets/svgs before createSvgContactSheet wrote its thumbnails and contact-sheet.jpg into it; a planted assets/svgs link received them. The sheet now runs ensureCaptureDirSync on the directory first, and a refusal just skips the sheet. A dangling or looping directory link is now refused with the same message instead of a raw ENOENT/ELOOP, and a comment records why the check uses the JS realpathSync rather than realpathSync.native. Refs #4304 * fix(cli): vet capture --video's download folder Video mode created assets/videos (or capture/assets/videos in a W2H project) with a plain mkdirSync, so a planted assets or capture link carried the downloaded clip outside the project. It now goes through ensureCaptureDirSync with the project folder as the trusted root, and a refused folder is reported before anything is downloaded. * fix(cli): warn when a best-effort capture phase refuses a folder Folder refusals now throw CaptureDirRefusedError. The contact sheets and the video manifest still skip on any error, but a refusal adds a warning so a planted link doesn't go unnoticed. saveLottieAnimations now vets assets/lottie itself, taking the output root like renderLottiePreviews, and every folder check in the capture pipeline is pinned by a test. * style(cli): trim the ensureCaptureDirSync doc comment to two lines --------- Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
This commit is contained in:
co-authored by
Miguel Angel Simon Sierra
parent
f1b79d8e7b
commit
d2cdad418a
@@ -1,5 +1,13 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -9,6 +17,7 @@ import {
|
||||
safeFetch,
|
||||
toStandaloneSvg,
|
||||
} from "./assetDownloader.js";
|
||||
import { CaptureDirRefusedError } from "./captureErrors.js";
|
||||
import type { DesignTokens } from "./types.js";
|
||||
import type { IconCandidate } from "./faviconRanker.js";
|
||||
import { CAPTURE_USER_AGENT } from "./userAgent.js";
|
||||
@@ -741,3 +750,65 @@ describe("capture download security boundaries", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a planted directory link inside the capture directory (#4304)", () => {
|
||||
// Creating symlinks needs elevated rights on Windows.
|
||||
const posixOnly = it.skipIf(process.platform === "win32");
|
||||
|
||||
function plantedLink(root: string, link: string): { outputDir: string; outside: string } {
|
||||
const outputDir = join(root, "capture");
|
||||
const outside = join(root, "outside");
|
||||
mkdirSync(join(outputDir, link, ".."), { recursive: true });
|
||||
mkdirSync(outside);
|
||||
symlinkSync(outside, join(outputDir, link));
|
||||
return { outputDir, outside };
|
||||
}
|
||||
|
||||
posixOnly.each(["assets", "assets/svgs"])(
|
||||
"refuses to write page-derived assets through a planted %s/ symlink",
|
||||
async (link) => {
|
||||
await withTempDir(async (root) => {
|
||||
const { outputDir, outside } = plantedLink(root, link);
|
||||
const svgs = [
|
||||
{
|
||||
outerHTML: `<svg viewBox="0 0 10 10"><rect width="10" height="10" fill="#abc"/></svg>`,
|
||||
isLogo: false,
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
downloadAssets({ svgs, sections: [], ogImage: "" } as unknown as DesignTokens, outputDir),
|
||||
).rejects.toThrow(/outside the capture directory/);
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
posixOnly("refuses a dangling assets/ symlink before its svgs folder", async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const outputDir = join(root, "capture");
|
||||
const target = join(root, "missing");
|
||||
mkdirSync(outputDir);
|
||||
symlinkSync(target, join(outputDir, "assets"));
|
||||
|
||||
await expect(
|
||||
downloadAssets(
|
||||
{ svgs: [], sections: [], ogImage: "" } as unknown as DesignTokens,
|
||||
outputDir,
|
||||
),
|
||||
).rejects.toThrow(CaptureDirRefusedError);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
posixOnly("refuses to create the fonts directory through a planted assets/ symlink", async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const { outputDir, outside } = plantedLink(root, "assets");
|
||||
|
||||
await expect(downloadAndRewriteFonts("", outputDir)).rejects.toThrow(
|
||||
/outside the capture directory/,
|
||||
);
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
*/
|
||||
|
||||
import { isBlockedNetworkHost } from "@hyperframes/engine";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join, extname } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DesignTokens, DownloadedAsset } from "./types.js";
|
||||
@@ -317,14 +316,14 @@ export async function downloadAssets(
|
||||
): Promise<{ assets: DownloadedAsset[]; drops: AssetDropCounts; icons: IconManifest }> {
|
||||
options = { ...options, byteBudget: options.byteBudget ?? createCaptureDownloadBudget() };
|
||||
const assetsDir = join(outputDir, "assets");
|
||||
mkdirSync(assetsDir, { recursive: true });
|
||||
ensureCaptureDirSync(outputDir, assetsDir);
|
||||
|
||||
const assets: DownloadedAsset[] = [];
|
||||
const drops = noDrops();
|
||||
const downloadedUrls = new Set<string>();
|
||||
let icons: IconManifest = emptyIconManifest();
|
||||
|
||||
mkdirSync(join(outputDir, "assets", "svgs"), { recursive: true });
|
||||
ensureCaptureDirSync(outputDir, join(outputDir, "assets", "svgs"));
|
||||
const usedSvgNames = new Set<string>();
|
||||
const MAX_INLINE_SVGS = 30;
|
||||
drops["cap-reached"] += Math.max(0, tokens.svgs.length - MAX_INLINE_SVGS);
|
||||
@@ -542,7 +541,7 @@ export async function downloadAndRewriteFonts(
|
||||
): Promise<{ css: string; drops: AssetDropCounts }> {
|
||||
options = { ...options, byteBudget: options.byteBudget ?? createCaptureDownloadBudget() };
|
||||
const assetsDir = join(outputDir, "assets", "fonts");
|
||||
mkdirSync(assetsDir, { recursive: true });
|
||||
ensureCaptureDirSync(outputDir, assetsDir);
|
||||
const drops = noDrops();
|
||||
|
||||
const fontUrlRegex = /url\(['"]?(https?:\/\/[^'")\s]+\.(?:woff2?|ttf|otf)[^'")\s]*?)['"]?\)/g;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { captureWebsiteAttempt } from "./captureAttempt.js";
|
||||
import { CaptureDirRefusedError } from "./captureErrors.js";
|
||||
import type { CaptureWatchdog } from "./captureWatchdog.js";
|
||||
import { createPartialCaptureState } from "./partialCapture.js";
|
||||
|
||||
const { ensureBrowserMock } = vi.hoisted(() => ({
|
||||
ensureBrowserMock: vi.fn(async () => {
|
||||
throw new Error("the browser must not start");
|
||||
}),
|
||||
}));
|
||||
vi.mock("../browser/manager.js", () => ({ ensureBrowser: ensureBrowserMock }));
|
||||
|
||||
const watchdog: CaptureWatchdog = {
|
||||
expired: () => false,
|
||||
promise: new Promise(() => {}),
|
||||
registerBrowser: () => {},
|
||||
unregisterBrowser: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
|
||||
describe("captureWebsiteAttempt output folders", () => {
|
||||
// Creating symlinks needs elevated rights on Windows.
|
||||
it.skipIf(process.platform === "win32").each(["extracted", "screenshots", "assets"])(
|
||||
"refuses a planted %s/ symlink before the browser starts (#4304)",
|
||||
async (link) => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-capture-attempt-"));
|
||||
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
|
||||
const outputDir = join(root, "capture");
|
||||
const outside = join(root, "outside");
|
||||
mkdirSync(outputDir);
|
||||
mkdirSync(outside);
|
||||
symlinkSync(outside, join(outputDir, link));
|
||||
const opts = { url: "https://example.com", outputDir };
|
||||
|
||||
await expect(
|
||||
captureWebsiteAttempt(opts, undefined, false, watchdog, createPartialCaptureState(opts)),
|
||||
).rejects.toThrow(CaptureDirRefusedError);
|
||||
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
expect(ensureBrowserMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -5,9 +5,10 @@ import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
|
||||
* - Rich animation metadata for Claude Code to recreate
|
||||
*/
|
||||
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { noDrops } from "./assetDownloader.js";
|
||||
import { ensureCaptureDirSync } from "./captureFile.js";
|
||||
import type { IconCandidate } from "./faviconRanker.js";
|
||||
import { CAPTURE_USER_AGENT } from "./userAgent.js";
|
||||
import { setupAnimationCapture, startCdpAnimationCapture } from "./animationCataloger.js";
|
||||
@@ -101,9 +102,9 @@ export async function captureWebsiteAttempt(
|
||||
loadEnvFile(outputDir);
|
||||
|
||||
// Create output directories
|
||||
mkdirSync(join(outputDir, "extracted"), { recursive: true });
|
||||
mkdirSync(join(outputDir, "screenshots"), { recursive: true });
|
||||
mkdirSync(join(outputDir, "assets"), { recursive: true });
|
||||
ensureCaptureDirSync(outputDir, join(outputDir, "extracted"));
|
||||
ensureCaptureDirSync(outputDir, join(outputDir, "screenshots"));
|
||||
ensureCaptureDirSync(outputDir, join(outputDir, "assets"));
|
||||
|
||||
// Launch browser
|
||||
progress("browser", "Launching headless Chrome...");
|
||||
|
||||
@@ -7,3 +7,10 @@ export class NavigationDeadlineError extends Error {
|
||||
this.name = "NavigationDeadlineError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CaptureDirRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CaptureDirRefusedError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
@@ -122,3 +122,76 @@ describe("writeCaptureFileSync", () => {
|
||||
expect(readdirSync(outputDir).sort()).toEqual(["a.txt", "occupied"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureCaptureDirSync", () => {
|
||||
it("creates nested directories under the root, and accepts them again on a re-run", () => {
|
||||
const { outputDir } = scratch();
|
||||
const fonts = join(outputDir, "assets", "fonts");
|
||||
|
||||
expect(ensureCaptureDirSync(outputDir, fonts)).toBe(fonts);
|
||||
expect(ensureCaptureDirSync(outputDir, fonts)).toBe(fonts);
|
||||
|
||||
expect(lstatSync(fonts).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
posixOnly("refuses a planted directory symlink and creates nothing in its target", () => {
|
||||
const { outputDir } = scratch();
|
||||
const outside = join(outputDir, "..", "outside");
|
||||
mkdirSync(outside);
|
||||
symlinkSync(outside, join(outputDir, "assets"));
|
||||
|
||||
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, "assets", "fonts"))).toThrow(
|
||||
/outside the capture directory/,
|
||||
);
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
});
|
||||
|
||||
posixOnly("refuses a directory symlink even when it points elsewhere inside the root", () => {
|
||||
const { outputDir } = scratch();
|
||||
mkdirSync(join(outputDir, "extracted"));
|
||||
symlinkSync(join(outputDir, "extracted"), join(outputDir, "assets"));
|
||||
|
||||
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, "assets"))).toThrow(
|
||||
/outside the capture directory/,
|
||||
);
|
||||
});
|
||||
|
||||
posixOnly("refuses a dangling or looping directory symlink with the same message", () => {
|
||||
const { outputDir } = scratch();
|
||||
symlinkSync(join(outputDir, "..", "missing"), join(outputDir, "assets"));
|
||||
symlinkSync(join(outputDir, "screenshots"), join(outputDir, "screenshots"));
|
||||
|
||||
for (const name of ["assets", "screenshots"]) {
|
||||
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, name))).toThrow(
|
||||
/outside the capture directory/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
posixOnly("trusts a root that is itself a symlink, as /tmp is on macOS", () => {
|
||||
const { outputDir } = scratch();
|
||||
const linkedRoot = join(outputDir, "..", "linked-capture");
|
||||
symlinkSync(outputDir, linkedRoot);
|
||||
|
||||
ensureCaptureDirSync(linkedRoot, join(linkedRoot, "assets", "svgs"));
|
||||
|
||||
expect(lstatSync(join(outputDir, "assets", "svgs")).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a file sitting where a directory belongs", () => {
|
||||
const { outputDir } = scratch();
|
||||
writeFileSync(join(outputDir, "assets"), "not a directory");
|
||||
|
||||
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, "assets"))).toThrow(
|
||||
/not a directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a directory that is not under the root", () => {
|
||||
const { outputDir } = scratch();
|
||||
|
||||
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, "..", "elsewhere"))).toThrow(
|
||||
/outside the capture directory/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { CaptureDirRefusedError } from "./captureErrors.js";
|
||||
|
||||
type WriteOptions = Parameters<typeof writeFileSync>[2];
|
||||
|
||||
@@ -40,3 +49,59 @@ export function writeCaptureFileSync(
|
||||
}
|
||||
writeFileSync(path, data, { ...normalized, mode: 0o600 });
|
||||
}
|
||||
|
||||
function hasCode(error: unknown, ...codes: string[]): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
typeof error.code === "string" &&
|
||||
codes.includes(error.code)
|
||||
);
|
||||
}
|
||||
|
||||
// The JS `realpathSync`, deliberately not `realpathSync.native`: native returns the on-disk case
|
||||
// on macOS/Windows, so an existing `Assets/` asked for as `assets` would be falsely refused. The
|
||||
// JS walk only rewrites the components that are links, which is exactly what is being checked.
|
||||
function resolvedPath(path: string): string | undefined {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch (error) {
|
||||
// A dangling or looping link resolves nowhere: refuse it like any other escaping link.
|
||||
if (hasCode(error, "ENOENT", "ELOOP")) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Creates `dir` under the trusted capture root, refusing any directory whose real path leaves it (#4304).
|
||||
// Checked on creation, not on every write: a link swapped in later by a racing process is out of scope.
|
||||
export function ensureCaptureDirSync(root: string, dir: string): string {
|
||||
const rootPath = resolve(root);
|
||||
const target = resolve(dir);
|
||||
const rel = relative(rootPath, target);
|
||||
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new CaptureDirRefusedError(
|
||||
`Refusing to create ${target}: it is outside the capture directory ${rootPath}`,
|
||||
);
|
||||
}
|
||||
mkdirSync(rootPath, { recursive: true });
|
||||
let current = rootPath;
|
||||
let expected = realpathSync(rootPath);
|
||||
for (const segment of rel.split(sep).filter(Boolean)) {
|
||||
current = join(current, segment);
|
||||
expected = join(expected, segment);
|
||||
try {
|
||||
mkdirSync(current);
|
||||
} catch (error) {
|
||||
if (!hasCode(error, "EEXIST")) throw error;
|
||||
}
|
||||
if (resolvedPath(current) !== expected) {
|
||||
throw new CaptureDirRefusedError(
|
||||
`Refusing to write into ${current}: it resolves outside the capture directory ${rootPath}`,
|
||||
);
|
||||
}
|
||||
if (!statSync(current).isDirectory()) {
|
||||
throw new CaptureDirRefusedError(`Refusing to write into ${current}: it is not a directory`);
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { IconCandidate } from "./faviconRanker.js";
|
||||
import { startCdpAnimationCapture } from "./animationCataloger.js";
|
||||
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
|
||||
import { detectLibraries } from "./contentExtractor.js";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { CaptureDirRefusedError } from "./captureErrors.js";
|
||||
import { join } from "node:path";
|
||||
import { extractHtml } from "./htmlExtractor.js";
|
||||
import { extractTokens } from "./tokenExtractor.js";
|
||||
@@ -176,9 +176,13 @@ export async function runCoreExtraction(input: CoreExtractionInput): Promise<Cor
|
||||
if (discoveredLotties.length > 0 && remainingMs() > 0) {
|
||||
const lottieDir = join(outputDir, "assets", "lottie");
|
||||
if (!canWrite()) return;
|
||||
mkdirSync(lottieDir, { recursive: true });
|
||||
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
|
||||
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget);
|
||||
const savedCount = await saveLottieAnimations(
|
||||
discoveredLotties,
|
||||
lottieDir,
|
||||
outputDir,
|
||||
lottieBudget,
|
||||
);
|
||||
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
|
||||
if (savedCount > 0 && remainingMs() > 0 && canWrite()) {
|
||||
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget);
|
||||
@@ -348,8 +352,10 @@ export async function runCoreExtraction(input: CoreExtractionInput): Promise<Cor
|
||||
remainingMs,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* non-blocking — video manifest is best-effort */
|
||||
} catch (err) {
|
||||
if (err instanceof CaptureDirRefusedError)
|
||||
warnings.push(`Video manifest skipped: ${err.message}`);
|
||||
/* otherwise non-blocking — video manifest is best-effort */
|
||||
}
|
||||
|
||||
// Detect JS libraries via globals, DOM fingerprints, script URLs, and shaders
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
renderLottiePreviews,
|
||||
saveLottieAnimations,
|
||||
} from "./mediaCapture.js";
|
||||
import { CaptureDirRefusedError } from "./captureErrors.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -48,6 +49,7 @@ describe("Lottie capture budget", () => {
|
||||
const saved = await saveLottieAnimations(
|
||||
[{ url: "https://one.example/a.json" }, { url: "https://two.example/b.json" }],
|
||||
dir,
|
||||
dir,
|
||||
{ remainingMs: () => remainingMs },
|
||||
);
|
||||
|
||||
@@ -207,7 +209,9 @@ describe("Lottie capture rejects unsafe persistence", () => {
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("not a zip")),
|
||||
);
|
||||
expect(await saveLottieAnimations([{ url: "https://public.example/bad.lottie" }], dir)).toBe(0);
|
||||
expect(
|
||||
await saveLottieAnimations([{ url: "https://public.example/bad.lottie" }], dir, dir),
|
||||
).toBe(0);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
it("does not publish truthy non-array layers", async () => {
|
||||
@@ -216,7 +220,9 @@ describe("Lottie capture rejects unsafe persistence", () => {
|
||||
"fetch",
|
||||
vi.fn(async () => new Response('{"w":100,"h":100,"layers":true}')),
|
||||
);
|
||||
expect(await saveLottieAnimations([{ url: "https://public.example/bad.json" }], dir)).toBe(0);
|
||||
expect(await saveLottieAnimations([{ url: "https://public.example/bad.json" }], dir, dir)).toBe(
|
||||
0,
|
||||
);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -299,3 +305,90 @@ describe("media previews replace pre-planted links", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("media folders refuse a planted directory link (#4304)", () => {
|
||||
// captureVideoManifest keeps any http(s) video it finds; only the URL matters here.
|
||||
const descriptor = { src: "https://video.example/planted.mp4", filename: "planted.mp4" };
|
||||
|
||||
function plantedCapture(link: string): { dir: string; outside: string } {
|
||||
const dir = tempDir();
|
||||
const outside = join(dir, "outside");
|
||||
mkdirSync(outside);
|
||||
mkdirSync(join(dir, "extracted"));
|
||||
mkdirSync(join(dir, link, ".."), { recursive: true });
|
||||
symlinkSync(outside, join(dir, link));
|
||||
return { dir, outside };
|
||||
}
|
||||
|
||||
// Creating symlinks needs elevated rights on Windows.
|
||||
it.skipIf(process.platform === "win32").each(["assets/videos", "assets/videos/previews"])(
|
||||
"does not write the video manifest through a planted %s link",
|
||||
async (link) => {
|
||||
const { dir, outside } = plantedCapture(link);
|
||||
const screenshot = vi.fn();
|
||||
const page = { evaluate: vi.fn(async () => [descriptor]), screenshot } as unknown as Page;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(null, { status: 404 })),
|
||||
);
|
||||
|
||||
await expect(captureVideoManifest(page, dir, () => {})).rejects.toThrow(
|
||||
CaptureDirRefusedError,
|
||||
);
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
expect(screenshot).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses a dangling assets/videos link before its previews folder",
|
||||
async () => {
|
||||
const dir = tempDir();
|
||||
const target = join(dir, "missing");
|
||||
mkdirSync(join(dir, "assets"), { recursive: true });
|
||||
symlinkSync(target, join(dir, "assets", "videos"));
|
||||
const page = {
|
||||
evaluate: vi.fn(async () => [descriptor]),
|
||||
screenshot: vi.fn(),
|
||||
} as unknown as Page;
|
||||
|
||||
await expect(captureVideoManifest(page, dir, () => {})).rejects.toThrow(
|
||||
CaptureDirRefusedError,
|
||||
);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not save Lottie files through a planted assets/lottie link",
|
||||
async () => {
|
||||
const { dir, outside } = plantedCapture("assets/lottie");
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
saveLottieAnimations(
|
||||
[{ url: "https://public.example/a.json" }],
|
||||
join(dir, "assets", "lottie"),
|
||||
dir,
|
||||
),
|
||||
).rejects.toThrow(CaptureDirRefusedError);
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not render Lottie previews through a planted previews link",
|
||||
async () => {
|
||||
const { dir, outside } = plantedCapture("assets/lottie/previews");
|
||||
const browser = { newPage: vi.fn() } as unknown as Browser;
|
||||
|
||||
await expect(
|
||||
renderLottiePreviews(browser, join(dir, "assets", "lottie"), dir),
|
||||
).rejects.toThrow(CaptureDirRefusedError);
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
expect(browser.newPage).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
*/
|
||||
|
||||
import type { Browser, Page } from "puppeteer-core";
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join, extname } from "node:path";
|
||||
import { isPrivateUrl, safeFetch } from "./assetDownloader.js";
|
||||
import { CAPTURE_USER_AGENT } from "./userAgent.js";
|
||||
@@ -50,8 +50,10 @@ function liveRemainingMs(budget: RemainingBudget, fallbackMs: number): number {
|
||||
export async function saveLottieAnimations(
|
||||
discoveredLotties: DiscoveredLottie[],
|
||||
lottieDir: string,
|
||||
outputDir: string,
|
||||
budget: RemainingBudget = {},
|
||||
): Promise<number> {
|
||||
ensureCaptureDirSync(outputDir, lottieDir);
|
||||
const byteBudget = budget.byteBudget ?? createCaptureDownloadBudget();
|
||||
let savedCount = 0;
|
||||
const savedHashes = new Set<string>(); // Deduplicate by content
|
||||
@@ -130,7 +132,7 @@ export async function renderLottiePreviews(
|
||||
layers: number;
|
||||
}> = [];
|
||||
const previewDir = join(lottieDir, "previews");
|
||||
mkdirSync(previewDir, { recursive: true });
|
||||
ensureCaptureDirSync(outputDir, previewDir);
|
||||
|
||||
for (const file of readdirSync(lottieDir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
@@ -470,9 +472,9 @@ export async function captureVideoManifest(
|
||||
if (merged.length === 0) return;
|
||||
|
||||
const videoManifestDir = join(outputDir, "assets", "videos");
|
||||
mkdirSync(videoManifestDir, { recursive: true });
|
||||
ensureCaptureDirSync(outputDir, videoManifestDir);
|
||||
const previewDir = join(videoManifestDir, "previews");
|
||||
mkdirSync(previewDir, { recursive: true });
|
||||
ensureCaptureDirSync(outputDir, previewDir);
|
||||
|
||||
const videoManifest: Array<{
|
||||
index: number;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -45,4 +53,30 @@ describe("writePartialCaptureBundle", () => {
|
||||
partial: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Creating symlinks needs elevated rights on Windows.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses to write through a planted extracted/ symlink (#4304)",
|
||||
() => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hyperframes-partial-capture-"));
|
||||
temporaryDirectories.push(root);
|
||||
const outputDir = join(root, "capture");
|
||||
const outside = join(root, "outside");
|
||||
mkdirSync(outputDir);
|
||||
mkdirSync(outside);
|
||||
symlinkSync(outside, join(outputDir, "extracted"));
|
||||
const opts = { url: "https://example.com", outputDir };
|
||||
|
||||
expect(() =>
|
||||
writePartialCaptureBundle(opts, createPartialCaptureState(opts), {
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: "complete",
|
||||
status: "degraded",
|
||||
remainingMs: null,
|
||||
reason: "deadline",
|
||||
}),
|
||||
).toThrow(/outside the capture directory/);
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join } from "node:path";
|
||||
import { noDrops } from "./assetDownloader.js";
|
||||
import type {
|
||||
@@ -74,9 +73,7 @@ export function writePartialCaptureBundle(
|
||||
lastPhase: CaptureResult["lastPhase"],
|
||||
): CaptureResult {
|
||||
const hostname = new URL(opts.url).hostname.replace(/^www\./, "");
|
||||
mkdirSync(opts.outputDir, { recursive: true });
|
||||
const extractedDir = join(opts.outputDir, "extracted");
|
||||
mkdirSync(extractedDir, { recursive: true });
|
||||
const extractedDir = ensureCaptureDirSync(opts.outputDir, join(opts.outputDir, "extracted"));
|
||||
writeCaptureFileSync(join(extractedDir, "tokens.json"), serializeTokensForCapture(state.tokens));
|
||||
writeCaptureFileSync(
|
||||
join(extractedDir, "design-styles.json"),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { noDrops } from "./assetDownloader.js";
|
||||
import { createPartialCaptureState } from "./partialCapture.js";
|
||||
import { runPostExtraction } from "./postExtractionPhase.js";
|
||||
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
|
||||
|
||||
const PLANTED_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="#abc"/></svg>`;
|
||||
|
||||
describe("runPostExtraction", () => {
|
||||
// Creating symlinks needs elevated rights on Windows.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not write the SVG contact sheet through a planted assets/svgs symlink under --skip-assets (#4304)",
|
||||
async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hyperframes-post-extraction-"));
|
||||
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
|
||||
const outputDir = join(root, "capture");
|
||||
const outside = join(root, "outside");
|
||||
for (const dir of ["extracted", "screenshots", "assets"]) {
|
||||
mkdirSync(join(outputDir, dir), { recursive: true });
|
||||
}
|
||||
mkdirSync(outside);
|
||||
writeFileSync(join(outside, "planted.svg"), PLANTED_SVG);
|
||||
symlinkSync(outside, join(outputDir, "assets", "svgs"));
|
||||
|
||||
const state = createPartialCaptureState({ url: "https://example.com", outputDir });
|
||||
const warnings: string[] = [];
|
||||
await runPostExtraction({
|
||||
state,
|
||||
outputDir,
|
||||
warnings,
|
||||
progress: () => {},
|
||||
remainingMs: () => 60_000,
|
||||
phase: () => {},
|
||||
animationCatalog: undefined,
|
||||
catalogedAssets: [],
|
||||
visibleTextContent: "",
|
||||
faviconLinks: [],
|
||||
tokens: state.tokens,
|
||||
extracted: state.extracted,
|
||||
skipAssets: true,
|
||||
skipVision: true,
|
||||
downloadByteBudget: createCaptureDownloadBudget(),
|
||||
assets: [],
|
||||
dropped: noDrops(),
|
||||
fontDrops: noDrops(),
|
||||
canWrite: () => true,
|
||||
});
|
||||
|
||||
expect(readdirSync(outside)).toEqual(["planted.svg"]);
|
||||
expect(warnings).toEqual([
|
||||
expect.stringMatching(/^SVG contact sheet skipped: Refusing to write/),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
|
||||
import { CaptureDirRefusedError } from "./captureErrors.js";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
downloadAssets,
|
||||
@@ -404,7 +405,9 @@ export async function runPostExtraction(input: PostExtractionInput): Promise<Pos
|
||||
// so sites like huly.io that only use external SVGs still get a grid
|
||||
const svgsDir = join(outputDir, "assets", "svgs");
|
||||
const assetsRootDir = join(outputDir, "assets");
|
||||
const svgOutputPath = existsSync(svgsDir)
|
||||
const hasSvgsDir = existsSync(svgsDir);
|
||||
if (hasSvgsDir) ensureCaptureDirSync(outputDir, svgsDir);
|
||||
const svgOutputPath = hasSvgsDir
|
||||
? join(outputDir, "assets", "svgs", "contact-sheet.jpg")
|
||||
: join(outputDir, "assets", "contact-sheet-svgs.jpg");
|
||||
const svgSheets = await createSvgContactSheet(
|
||||
@@ -420,8 +423,10 @@ export async function runPostExtraction(input: PostExtractionInput): Promise<Pos
|
||||
);
|
||||
};
|
||||
await runSvgContactSheet();
|
||||
} catch {
|
||||
/* contact sheets are non-critical — agent can still read images individually */
|
||||
} catch (err) {
|
||||
if (err instanceof CaptureDirRefusedError)
|
||||
warnings.push(`SVG contact sheet skipped: ${err.message}`);
|
||||
/* otherwise non-critical — agent can still read images individually */
|
||||
}
|
||||
phase(
|
||||
"contact-sheets",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Page } from "puppeteer-core";
|
||||
@@ -9,6 +17,7 @@ import {
|
||||
MAX_PLATE_HEIGHT_PX,
|
||||
pngHeight,
|
||||
} from "./screenshotCapture.js";
|
||||
import { CaptureDirRefusedError } from "./captureErrors.js";
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
@@ -263,3 +272,23 @@ describe("captureFullPagePlate — the guard sees the post-neutralisation page (
|
||||
expect(String(evaluate.mock.calls.at(-1)?.[0])).toContain("removeAttribute");
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureScrollScreenshots — planted screenshots link (#4304)", () => {
|
||||
// Creating symlinks needs elevated rights on Windows.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses a planted screenshots/ symlink before touching the page",
|
||||
async () => {
|
||||
const dir = tempDir("hf-scroll-planted-");
|
||||
const outside = join(dir, "outside");
|
||||
mkdirSync(outside);
|
||||
symlinkSync(outside, join(dir, "screenshots"));
|
||||
const { page, evaluate, screenshot } = fakePage();
|
||||
|
||||
await expect(captureScrollScreenshots(page, dir)).rejects.toThrow(CaptureDirRefusedError);
|
||||
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
expect(evaluate).not.toHaveBeenCalled();
|
||||
expect(screenshot).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
|
||||
import type { Page } from "puppeteer-core";
|
||||
import { isDegradableEvaluateTimeoutError } from "./captureTimeout.js";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
|
||||
|
||||
/**
|
||||
* Capture viewport screenshots covering the entire page height.
|
||||
@@ -118,7 +117,7 @@ export async function captureScrollScreenshots(
|
||||
budget: { remainingMs?: () => number } = {},
|
||||
): Promise<string[]> {
|
||||
const screenshotsDir = join(outputDir, "screenshots");
|
||||
mkdirSync(screenshotsDir, { recursive: true });
|
||||
ensureCaptureDirSync(outputDir, screenshotsDir);
|
||||
|
||||
const MAX_SCREENSHOTS = 20;
|
||||
const filePaths: string[] = [];
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { consumeCommandResult } from "../../utils/commandResult.js";
|
||||
import {
|
||||
MAX_VIDEO_BYTES,
|
||||
VIDEO_CONTENT_TYPE_RE,
|
||||
findFilenameCollision,
|
||||
pickManifestEntry,
|
||||
runVideoMode,
|
||||
safeFilename,
|
||||
type ManifestEntry,
|
||||
} from "./video.js";
|
||||
@@ -160,3 +165,44 @@ describe("findFilenameCollision", () => {
|
||||
expect(collisions.every((c) => c.index !== 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runVideoMode", () => {
|
||||
// Creating symlinks needs elevated rights on Windows.
|
||||
it.skipIf(process.platform === "win32").each([
|
||||
{ layout: "capture output", manifestDir: "extracted", planted: "assets" },
|
||||
{ layout: "W2H project", manifestDir: "capture/extracted", planted: "capture" },
|
||||
])(
|
||||
"refuses to download through a planted $planted symlink in a $layout (#4304)",
|
||||
async ({ manifestDir, planted }) => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-video-mode-"));
|
||||
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
|
||||
const projectDir = join(root, "project");
|
||||
const outside = join(root, "outside");
|
||||
mkdirSync(projectDir);
|
||||
mkdirSync(outside);
|
||||
symlinkSync(outside, join(projectDir, planted));
|
||||
mkdirSync(join(projectDir, manifestDir), { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectDir, manifestDir, "video-manifest.json"),
|
||||
JSON.stringify([ENTRY(0)]),
|
||||
);
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("no network in tests");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
onTestFinished(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
await runVideoMode({ project: projectDir, index: "0" });
|
||||
|
||||
expect(consumeCommandResult().exitCode).toBe(1);
|
||||
expect(String(error.mock.calls[0]?.[0])).toContain("resolves outside the capture directory");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(existsSync(join(outside, planted === "assets" ? "videos" : "assets"))).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { setCommandExitCode } from "../../utils/commandResult.js";
|
||||
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { createWriteStream, existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { resolve, join, basename } from "node:path";
|
||||
import { c } from "../../ui/colors.js";
|
||||
import { safeFetch } from "../../capture/assetDownloader.js";
|
||||
import { ensureCaptureDirSync } from "../../capture/captureFile.js";
|
||||
import { CAPTURE_USER_AGENT } from "../../capture/userAgent.js";
|
||||
|
||||
const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
|
||||
@@ -254,7 +255,13 @@ export async function runVideoMode(args: VideoModeArgs): Promise<void> {
|
||||
const outDir = isW2hLayout
|
||||
? join(projectDir, "capture", "assets", "videos")
|
||||
: join(projectDir, "assets", "videos");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
try {
|
||||
ensureCaptureDirSync(projectDir, outDir);
|
||||
} catch (e) {
|
||||
console.error(`${c.error("✗")} ${(e as Error).message}`);
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
}
|
||||
const fname = safeFilename(entry.filename || basename(entry.url));
|
||||
const outPath = join(outDir, fname);
|
||||
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;
|
||||
|
||||
Reference in New Issue
Block a user