Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions packages/cli/src/capture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export async function captureWebsite(
onPhase,
} = opts;

const downloadByteBudget = createCaptureDownloadBudget();
const warnings: string[] = [];
const progress = (stage: string, detail?: string) => {
onProgress?.(stage, detail);
Expand Down Expand Up @@ -385,7 +386,7 @@ export async function captureWebsite(
if (discoveredLotties.length > 0 && remainingMs() > 0) {
const lottieDir = join(outputDir, "assets", "lottie");
mkdirSync(lottieDir, { recursive: true });
const lottieBudget = { remainingMs };
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget);
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
if (savedCount > 0 && remainingMs() > 0) {
Expand Down Expand Up @@ -609,7 +610,6 @@ export async function captureWebsite(
// `budget-exhausted` for every one of them replaces a warning string that could only ever
// say "some". A zero budget means it breaks on the first url, so this costs no network.
phase("fonts", "started");
const downloadByteBudget = createCaptureDownloadBudget();
const fontPass = await downloadAndRewriteFonts(extracted.headHtml, outputDir, {
remainingMs,
byteBudget: downloadByteBudget,
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/capture/lottiePreviewRequests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { allowedLottieRequest, LOTTIE_RUNTIME_URL } from "./lottiePreviewRequests.js";

describe("Lottie preview request policy", () => {
it("allows the pinned runtime and public image/font assets", () => {
expect(allowedLottieRequest(LOTTIE_RUNTIME_URL, "script")).toBe(true);
expect(allowedLottieRequest("https://cdn.example/image.png", "image")).toBe(true);
expect(allowedLottieRequest("https://cdn.example/font.woff2", "font")).toBe(true);
expect(allowedLottieRequest("data:image/png;base64,AA==", "image")).toBe(true);
});
it.each([
"http://127.0.0.1/a",
"http://169.254.169.254/a",
"http://[::1]/a",
"file:///etc/passwd",
])("blocks private and local targets %s", (url) => {
expect(allowedLottieRequest(url, "image")).toBe(false);
});
it("blocks arbitrary scripts and navigations", () => {
expect(allowedLottieRequest("https://cdn.example/script.js", "script")).toBe(false);
expect(allowedLottieRequest("https://cdn.example/page", "document")).toBe(false);
});
});
24 changes: 24 additions & 0 deletions packages/cli/src/capture/lottiePreviewRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Page } from "puppeteer-core";
import { isPrivateUrl } from "./assetDownloader.js";

export const LOTTIE_RUNTIME_URL =
"https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js";

/** Apply capture's URL policy to each preview request, including redirect targets. */
export function allowedLottieRequest(url: string, resourceType: string): boolean {
if (url === LOTTIE_RUNTIME_URL) return true;
if (!["image", "font", "stylesheet"].includes(resourceType)) return false;
if (url.startsWith("data:") || url.startsWith("blob:")) return true;
return !isPrivateUrl(url);
}

export async function guardLottiePreviewRequests(page: Page): Promise<void> {
await page.setRequestInterception(true);
page.on("request", (request) => {
if (request.isInterceptResolutionHandled()) return;
const action = allowedLottieRequest(request.url(), request.resourceType())
? request.continue()
: request.abort("blockedbyclient");
void action.catch(() => {});
});
}
42 changes: 42 additions & 0 deletions packages/cli/src/capture/lottieValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import AdmZip from "adm-zip";
import { describe, expect, it } from "vitest";
import { MAX_LOTTIE_BYTES, readLottieArchive, validLottieJson } from "./lottieValidation.js";

const animation = JSON.stringify({ w: 100, h: 100, fr: 30, ip: 0, op: 30, layers: [] });

function archive(path: string, content = animation): Buffer {
const zip = new AdmZip();
zip.addFile(path, Buffer.from(content));
return zip.toBuffer();
}

describe("Lottie archive validation", () => {
it.each(["a/demo.json", "animations/demo.json"])("preserves valid %s animation JSON", (path) => {
expect(readLottieArchive(archive(path))).toBe(animation);
});
it("rejects corrupt archives and traversal names", () => {
expect(readLottieArchive(Buffer.from("not a zip"))).toBeNull();
expect(readLottieArchive(archive("animations/../demo.json"))).toBeNull();
});
it.each([0, MAX_LOTTIE_BYTES + 1])("rejects an unsafe declared expansion size %s", (size) => {
const bytes = archive("a/demo.json");
const central = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
expect(central).toBeGreaterThan(0);
bytes.writeUInt32LE(size, central + 24);
expect(readLottieArchive(bytes)).toBeNull();
});
});

describe("Lottie JSON validation", () => {
it("accepts the supported animation shape", () => {
expect(validLottieJson(animation)).toBe(true);
});
it.each([
'{"w":100,"h":100,"layers":true}',
'{"w":100000,"h":100000,"layers":[]}',
'{"w":100,"h":100,"layers":[],"__proto__":{}}',
'{"w":100,"h":100,"layers":[],"fr":1e999}',
])("rejects invalid animation data %s", (source) => {
expect(validLottieJson(source)).toBe(false);
});
});
95 changes: 95 additions & 0 deletions packages/cli/src/capture/lottieValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import AdmZip from "adm-zip";

export const MAX_LOTTIE_BYTES = 10 * 1024 * 1024;

/** Read animation JSON without extracting remote archive paths onto the filesystem. */
export function readLottieArchive(bytes: Buffer): string | null {
if (bytes.length > MAX_LOTTIE_BYTES) return null;
try {
const entries = new AdmZip(bytes).getEntries();
if (entries.length > 256) return null;
let total = 0;
for (const entry of entries) {
if (!safeArchivePath(entry.entryName)) return null;
const size = entry.header.size;
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_LOTTIE_BYTES) return null;
total += size;
if (total > 50 * 1024 * 1024) return null;
}
const animation = entries.find((entry) =>
/^(?:a|animations)\/[^/]+\.json$/.test(entry.entryName),
);
// adm-zip uses a positive expected size as zlib's maxOutputLength.
if (!animation || animation.header.size <= 0) return null;
const data = animation.getData();
if (data.length !== animation.header.size || data.length > MAX_LOTTIE_BYTES) return null;
return data.toString("utf8");
} catch {
return null;
}
}

function safeArchivePath(path: string): boolean {
if (
path.startsWith("/") ||
/[\\:]/.test(path) ||
Array.from(path).some((char) => char.charCodeAt(0) < 32)
)
return false;
return path.split("/").every((segment) => segment !== ".." && segment !== ".");
}

function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function dimension(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 16384;
}

/** Keep original valid animation JSON while bounding the data consumed by lottie-web. */
export function validLottieJson(source: string): boolean {
if (Buffer.byteLength(source) > MAX_LOTTIE_BYTES) return false;
try {
const value: unknown = JSON.parse(source);
if (!record(value) || !dimension(value.w) || !dimension(value.h)) return false;
if (value.w * value.h > 40_000_000 || !Array.isArray(value.layers)) return false;
return validLottieTiming(value) && boundedLottieTree(value);
} catch {
return false;
}
}

function validLottieTiming(value: Record<string, unknown>): boolean {
if (value.fr !== undefined && (typeof value.fr !== "number" || value.fr <= 0 || value.fr > 240))
return false;
if (![value.ip, value.op].every(validOptionalFrame)) return false;
return !(typeof value.ip === "number" && typeof value.op === "number" && value.op < value.ip);
}

function validOptionalFrame(frame: unknown): boolean {
return (
frame === undefined ||
(typeof frame === "number" && Number.isFinite(frame) && Math.abs(frame) <= 10_000_000)
);
}

function jsonChildren(value: unknown): [string, unknown][] {
return value !== null && typeof value === "object" ? Object.entries(value) : [];
}

function boundedLottieTree(root: unknown): boolean {
const pending = [{ value: root, depth: 0 }];
let count = 0;
while (pending.length) {
const item = pending.pop()!;
if (++count > 100000 || item.depth > 128) return false;
if (typeof item.value === "number" && !Number.isFinite(item.value)) return false;
for (const [key, value] of jsonChildren(item.value)) {
if (["__proto__", "constructor", "prototype"].includes(key)) return false;
if (count + pending.length >= 100000) return false;
pending.push({ value, depth: item.depth + 1 });
}
}
return true;
}
35 changes: 34 additions & 1 deletion packages/cli/src/capture/mediaCapture.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Browser, Page } from "puppeteer-core";
Expand Down Expand Up @@ -56,6 +64,8 @@ describe("Lottie capture budget", () => {

let remainingMs = 10_000;
const previewPage = {
setRequestInterception: vi.fn(async () => undefined),
on: vi.fn(),
setViewport: vi.fn(async () => undefined),
setContent: vi.fn(async () => undefined),
evaluate: vi.fn(async () => undefined),
Expand Down Expand Up @@ -96,6 +106,8 @@ describe("Lottie capture budget", () => {

const screenshot = vi.fn(async () => undefined);
const previewPage = {
setRequestInterception: vi.fn(async () => undefined),
on: vi.fn(),
setViewport: vi.fn(async () => undefined),
setContent: vi.fn(async () => undefined),
evaluate: vi.fn(async () => undefined),
Expand Down Expand Up @@ -185,3 +197,24 @@ describe("remainingVideoDownloadTimeoutMs", () => {
expect(remainingVideoDownloadTimeoutMs(1_000, 300_000, 2_000)).toBe(120_000);
});
});

describe("Lottie capture rejects unsafe persistence", () => {
it("does not write corrupt archives as raw Lottie files", async () => {
const dir = tempDir();
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("not a zip")),
);
expect(await saveLottieAnimations([{ url: "https://public.example/bad.lottie" }], dir)).toBe(0);
expect(readdirSync(dir)).toEqual([]);
});
it("does not publish truthy non-array layers", async () => {
const dir = tempDir();
vi.stubGlobal(
"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(readdirSync(dir)).toEqual([]);
});
});
58 changes: 20 additions & 38 deletions packages/cli/src/capture/mediaCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync } from "n
import { join, extname } from "node:path";
import { isPrivateUrl, safeFetch } from "./assetDownloader.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
import { MAX_LOTTIE_BYTES, readLottieArchive, validLottieJson } from "./lottieValidation.js";
import { guardLottiePreviewRequests, LOTTIE_RUNTIME_URL } from "./lottiePreviewRequests.js";
import {
readBoundedResponse,
createCaptureDownloadBudget,
type DownloadByteBudget,
} from "./readBoundedResponse.js";

/** Discovered Lottie item from network interception or DOM scan. */
export interface DiscoveredLottie {
Expand All @@ -22,6 +29,7 @@ export interface DiscoveredLottie {
}

interface RemainingBudget {
byteBudget?: DownloadByteBudget;
remainingMs?: () => number;
}

Expand All @@ -41,6 +49,7 @@ export async function saveLottieAnimations(
lottieDir: string,
budget: RemainingBudget = {},
): Promise<number> {
const byteBudget = budget.byteBudget ?? createCaptureDownloadBudget();
let savedCount = 0;
const savedHashes = new Set<string>(); // Deduplicate by content

Expand All @@ -53,6 +62,9 @@ export async function saveLottieAnimations(
if (lottieItem.data) {
// Already have the JSON data from network interception
jsonData = JSON.stringify(lottieItem.data);
const size = Buffer.byteLength(jsonData);
if (size > MAX_LOTTIE_BYTES || size > byteBudget.remainingBytes) continue;
byteBudget.remainingBytes -= size;
} else if (lottieItem.url) {
const requestTimeoutMs = Math.min(10_000, liveRemainingMs(budget, 10_000));
if (requestTimeoutMs <= 0) break;
Expand All @@ -62,36 +74,11 @@ export async function saveLottieAnimations(
headers: { "User-Agent": CAPTURE_USER_AGENT },
});
if (!res || !res.ok) continue;
const buf = Buffer.from(await res.arrayBuffer());

if (lottieItem.url.endsWith(".lottie")) {
// dotLottie is a ZIP — extract the animation JSON
try {
const AdmZip = (await import("adm-zip")).default;
const zip = new AdmZip(buf);
const entries = zip.getEntries();
// Look for animation JSON in both v1 (animations/) and v2 (a/) paths
const animEntry = entries.find(
(e) =>
(e.entryName.startsWith("a/") || e.entryName.startsWith("animations/")) &&
e.entryName.endsWith(".json"),
);
if (animEntry) {
jsonData = animEntry.getData().toString("utf-8");
}
} catch {
// adm-zip not available or extraction failed — save raw .lottie
const hash = buf.toString("base64").slice(0, 100);
if (savedHashes.has(hash)) continue;
savedHashes.add(hash);
writeFileSync(join(lottieDir, `animation-${savedCount}.lottie`), buf);
savedCount++;
continue;
}
} else {
// Plain JSON file
jsonData = buf.toString("utf-8");
}
const buf = await readBoundedResponse(res, MAX_LOTTIE_BYTES, byteBudget);
if (!buf) continue;
jsonData = new URL(lottieItem.url).pathname.endsWith(".lottie")
? (readLottieArchive(buf) ?? undefined)
: buf.toString("utf8");
}

if (jsonData) {
Expand All @@ -100,13 +87,7 @@ export async function saveLottieAnimations(
if (savedHashes.has(hash)) continue;
savedHashes.add(hash);

// Validate it's actually Lottie
try {
const parsed = JSON.parse(jsonData);
if (!parsed.layers || !parsed.w) continue;
} catch {
continue;
}
if (!validLottieJson(jsonData)) continue;

writeFileSync(join(lottieDir, `animation-${savedCount}.json`), jsonData, "utf-8");
savedCount++;
Expand Down Expand Up @@ -165,14 +146,15 @@ export async function renderLottiePreviews(
if (liveRemainingMs(budget, 1) <= 0) break;
previewPage = await chromeBrowser.newPage();
if (liveRemainingMs(budget, 1) <= 0) break;
await guardLottiePreviewRequests(previewPage);
await previewPage.setViewport({ width: 400, height: 400 });
const animData = JSON.parse(readFileSync(join(lottieDir, file), "utf-8"));
const midFrame = Math.floor(((raw.op || 0) - (raw.ip || 0)) * 0.3);
// Load the shell page first (no untrusted data in the HTML)
await previewPage.setContent(
`<!DOCTYPE html>
<html><head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
<script src="${LOTTIE_RUNTIME_URL}"></script>
<style>*{margin:0;padding:0;background:transparent}#c{width:400px;height:400px}</style>
</head><body><div id="c"></div></body></html>`,
{ waitUntil: "load", timeout: 10000 },
Expand Down
Loading