fix(studio-server): bound entity-quoted preview references (#5100)

This commit is contained in:
Miguel Ángel
2026-10-05 18:47:04 -07:00
committed by GitHub
parent 568dc2ae5b
commit 0221dc1701
2 changed files with 117 additions and 4 deletions
@@ -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<string>();
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);
}
@@ -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 ["&quot;", "&apos;", "&#34;", "&#x22;", "&#39;", "&#x27;"]) {
recordPreviewReferences(
projectDir,
`<i style="background-image:url(${quote}${data}${quote})"></i>`,
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, `${quote}${data}${quote}`)).toBe(false);
}
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, `&quot;${data}&quot;`)).toBe(false);
});
it("tracks entity-quoted local image paths after decoding their HTML entities", () => {
const projectDir = createProjectDir();
recordPreviewReferences(
projectDir,
'<i style="background-image:url(&quot;assets/a&amp;b.png&quot;)"></i>',
);
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,
"<style>i { background:url('assets/a&amp;b.png') }</style>",
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, "assets/a&amp;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,
`<i title='style="'></i><style>/* style=" */ i { background:url('assets/a&amp;b.png') } /* " */</style><i title='"'></i>`,
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, "assets/a&amp;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,
'<template><template><img src="assets/nested.png"></template></template><script>el.style.background="url(assets/script.png)"</script>',
);
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,
`<img src="assets/image.png?${"x".repeat(5000)}"><img src="${encoded}">`,
);
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, `<img src="${path}">`);
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 () => {