diff --git a/packages/studio-server/src/helpers/previewReads.ts b/packages/studio-server/src/helpers/previewReads.ts index 0a032e794e4..cd7ea00e8a6 100644 --- a/packages/studio-server/src/helpers/previewReads.ts +++ b/packages/studio-server/src/helpers/previewReads.ts @@ -1,11 +1,17 @@ import { readlinkSync } from "node:fs"; import { dirname, join, parse, relative, resolve } from "node:path"; +import { parseHTML } from "linkedom"; import { STUDIO_SIGNATURE_MANIFEST_PATHS } from "./projectSignature.js"; import { realFilePath } from "./safePath.js"; const ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS]; +// A malformed inline URL must not turn ancestor tracking into an image-sized path walk. +const MAX_PREVIEW_PATH_LENGTH = 4096; +const SHOW_ELEMENT = 1; const REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi; +const REFERENCE_ATTRIBUTE = /\b(?:src|href|poster|data-composition-src)$/i; +const isElement = (node: Node): node is Element => node.nodeType === 1; // macOS and Windows volumes ignore letter case by default, so it is not part of a path's identity there. const pathKey = process.platform === "darwin" || process.platform === "win32" @@ -39,6 +45,7 @@ function linksOnTheWay(path: string): string[] { continue; } const next = join(at, part); + if (next.length > MAX_PREVIEW_PATH_LENGTH) return passed; const target = hops < 40 ? readLink(next) : null; if (target === null) { at = next; @@ -56,10 +63,12 @@ function linksOnTheWay(path: string): string[] { // A link's target can be created or retargeted later, so every read resolves it again. export function recordPreviewRead(projectDir: string, filePath: string): void { + if (filePath.length > MAX_PREVIEW_PATH_LENGTH) return; const key = projectKey(projectDir); let reads = readsByProject.get(key); if (!reads) readsByProject.set(key, (reads = new Set())); const read = inRealProject(projectDir, filePath); + if (read.length > MAX_PREVIEW_PATH_LENGTH) return; for (const found of [read, ...linksOnTheWay(read)]) { for (let path = pathKey(found); !reads.has(path); path = dirname(path)) reads.add(path); } @@ -67,9 +76,9 @@ export function recordPreviewRead(projectDir: string, filePath: string): void { export function recordPreviewReferences(projectDir: string, html: string): void { const named = new Set(); - for (const match of html.matchAll(REFERENCE)) { - const url = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "").trim(); - if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) continue; + const add = (reference: string) => { + const url = reference.trim(); + if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) return; const path = url.split(/[?#]/)[0] ?? ""; let decoded = path; try { @@ -77,7 +86,31 @@ export function recordPreviewReferences(projectDir: string, html: string): void } catch { // A malformed escape names the file literally. } - if (decoded) named.add(decoded); + if (decoded && decoded.length <= MAX_PREVIEW_PATH_LENGTH) named.add(decoded); + }; + const scan = (text: string) => { + for (const match of text.matchAll(REFERENCE)) { + add(match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? ""); + } + }; + const scanElement = (element: Element) => { + for (const attribute of element.attributes) { + if (REFERENCE_ATTRIBUTE.test(attribute.name)) add(attribute.value); + } + const style = element.getAttribute("style"); + if (style) scan(style); + if (element.localName === "style" || element.localName === "script") { + scan(element.textContent ?? ""); + } + }; + // The parser decodes attributes; walking also visits inert template descendants without cloning them. + const { document } = parseHTML(html); + for (const root of document.children) { + scanElement(root); + const elements = document.createTreeWalker(root, SHOW_ELEMENT); + for (let node = elements.nextNode(); node; node = elements.nextNode()) { + if (isElement(node)) scanElement(node); + } } for (const path of named) recordPreviewRead(projectDir, path); } diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 187b1ea6c17..e8abd0afd9f 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -1898,6 +1898,86 @@ describe("what the preview loaded", () => { expect(affectsPreview(projectDir, "im")).toBe(false); }); + it("does not walk entity-quoted inline image data as a filesystem path", () => { + const projectDir = createProjectDir(); + const data = `data:image/png;base64,${"A/".repeat(128)}`; + for (const quote of [""", "'", """, """, "'", "'"]) { + recordPreviewReferences( + projectDir, + ``, + ); + recordPreviewBuilt(projectDir); + expect(affectsPreview(projectDir, `${quote}${data}${quote}`)).toBe(false); + } + recordPreviewBuilt(projectDir); + expect(affectsPreview(projectDir, `"${data}"`)).toBe(false); + }); + + it("tracks entity-quoted local image paths after decoding their HTML entities", () => { + const projectDir = createProjectDir(); + recordPreviewReferences( + projectDir, + '', + ); + recordPreviewBuilt(projectDir); + expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(true); + }); + + it("keeps entities literal in stylesheet URLs, as CSS raw text does", () => { + const projectDir = createProjectDir(); + recordPreviewReferences( + projectDir, + "", + ); + recordPreviewBuilt(projectDir); + expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(true); + expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(false); + }); + + it("does not mistake style-looking text for an HTML style attribute", () => { + const projectDir = createProjectDir(); + recordPreviewReferences( + projectDir, + ``, + ); + recordPreviewBuilt(projectDir); + expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(true); + expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(false); + }); + + it("tracks references in nested templates and script-created CSS", () => { + const projectDir = createProjectDir(); + recordPreviewReferences( + projectDir, + '', + ); + recordPreviewBuilt(projectDir); + expect(affectsPreview(projectDir, "assets/nested.png")).toBe(true); + expect(affectsPreview(projectDir, "assets/script.png")).toBe(true); + }); + + it("bounds filesystem paths after stripping long queries and decoding escapes", () => { + const projectDir = createProjectDir(); + const path = `assets/${"x/".repeat(1200)}image.png`; + const encoded = path.replace(/x/g, "%78"); + recordPreviewReferences( + projectDir, + ``, + ); + recordPreviewBuilt(projectDir); + expect(affectsPreview(projectDir, "assets/image.png")).toBe(true); + expect(affectsPreview(projectDir, path)).toBe(true); + }); + + it("does not walk oversized preview references or direct read paths", () => { + const projectDir = createProjectDir(); + const path = `assets/${"long/".repeat(850)}image.png`; + recordPreviewReferences(projectDir, ``); + recordPreviewRead(projectDir, path); + recordPreviewBuilt(projectDir); + expect(affectsPreview(projectDir, path)).toBe(false); + }); + it.skipIf(process.platform === "win32")( "counts an edit to the file a symlinked asset points at", async () => {