Skip to content

Commit 59dc037

Browse files
committed
fix(studio-server): bound entity-quoted preview references
1 parent 568dc2a commit 59dc037

2 files changed

Lines changed: 117 additions & 4 deletions

File tree

‎packages/studio-server/src/helpers/previewReads.ts‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { readlinkSync } from "node:fs";
22
import { dirname, join, parse, relative, resolve } from "node:path";
3+
import { parseHTML } from "linkedom";
34
import { STUDIO_SIGNATURE_MANIFEST_PATHS } from "./projectSignature.js";
45
import { realFilePath } from "./safePath.js";
56

67
const ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
8+
// A malformed inline URL must not turn ancestor tracking into an image-sized path walk.
9+
const MAX_PREVIEW_PATH_LENGTH = 4096;
10+
const SHOW_ELEMENT = 1;
711
const REFERENCE =
812
/\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
13+
const REFERENCE_ATTRIBUTE = /\b(?:src|href|poster|data-composition-src)$/i;
14+
const isElement = (node: Node): node is Element => node.nodeType === 1;
915
// macOS and Windows volumes ignore letter case by default, so it is not part of a path's identity there.
1016
const pathKey =
1117
process.platform === "darwin" || process.platform === "win32"
@@ -39,6 +45,7 @@ function linksOnTheWay(path: string): string[] {
3945
continue;
4046
}
4147
const next = join(at, part);
48+
if (next.length > MAX_PREVIEW_PATH_LENGTH) return passed;
4249
const target = hops < 40 ? readLink(next) : null;
4350
if (target === null) {
4451
at = next;
@@ -56,28 +63,54 @@ function linksOnTheWay(path: string): string[] {
5663

5764
// A link's target can be created or retargeted later, so every read resolves it again.
5865
export function recordPreviewRead(projectDir: string, filePath: string): void {
66+
if (filePath.length > MAX_PREVIEW_PATH_LENGTH) return;
5967
const key = projectKey(projectDir);
6068
let reads = readsByProject.get(key);
6169
if (!reads) readsByProject.set(key, (reads = new Set()));
6270
const read = inRealProject(projectDir, filePath);
71+
if (read.length > MAX_PREVIEW_PATH_LENGTH) return;
6372
for (const found of [read, ...linksOnTheWay(read)]) {
6473
for (let path = pathKey(found); !reads.has(path); path = dirname(path)) reads.add(path);
6574
}
6675
}
6776

6877
export function recordPreviewReferences(projectDir: string, html: string): void {
6978
const named = new Set<string>();
70-
for (const match of html.matchAll(REFERENCE)) {
71-
const url = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "").trim();
72-
if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) continue;
79+
const add = (reference: string) => {
80+
const url = reference.trim();
81+
if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) return;
7382
const path = url.split(/[?#]/)[0] ?? "";
7483
let decoded = path;
7584
try {
7685
decoded = decodeURIComponent(path);
7786
} catch {
7887
// A malformed escape names the file literally.
7988
}
80-
if (decoded) named.add(decoded);
89+
if (decoded && decoded.length <= MAX_PREVIEW_PATH_LENGTH) named.add(decoded);
90+
};
91+
const scan = (text: string) => {
92+
for (const match of text.matchAll(REFERENCE)) {
93+
add(match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "");
94+
}
95+
};
96+
const scanElement = (element: Element) => {
97+
for (const attribute of element.attributes) {
98+
if (REFERENCE_ATTRIBUTE.test(attribute.name)) add(attribute.value);
99+
}
100+
const style = element.getAttribute("style");
101+
if (style) scan(style);
102+
if (element.localName === "style" || element.localName === "script") {
103+
scan(element.textContent ?? "");
104+
}
105+
};
106+
// The parser decodes attributes; walking also visits inert template descendants without cloning them.
107+
const { document } = parseHTML(html);
108+
for (const root of document.children) {
109+
scanElement(root);
110+
const elements = document.createTreeWalker(root, SHOW_ELEMENT);
111+
for (let node = elements.nextNode(); node; node = elements.nextNode()) {
112+
if (isElement(node)) scanElement(node);
113+
}
81114
}
82115
for (const path of named) recordPreviewRead(projectDir, path);
83116
}

‎packages/studio-server/src/routes/preview.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1898,6 +1898,86 @@ describe("what the preview loaded", () => {
18981898
expect(affectsPreview(projectDir, "im")).toBe(false);
18991899
});
19001900

1901+
it("does not walk entity-quoted inline image data as a filesystem path", () => {
1902+
const projectDir = createProjectDir();
1903+
const data = `data:image/png;base64,${"A/".repeat(128)}`;
1904+
for (const quote of ["&quot;", "&apos;", "&#34;", "&#x22;", "&#39;", "&#x27;"]) {
1905+
recordPreviewReferences(
1906+
projectDir,
1907+
`<i style="background-image:url(${quote}${data}${quote})"></i>`,
1908+
);
1909+
recordPreviewBuilt(projectDir);
1910+
expect(affectsPreview(projectDir, `${quote}${data}${quote}`)).toBe(false);
1911+
}
1912+
recordPreviewBuilt(projectDir);
1913+
expect(affectsPreview(projectDir, `&quot;${data}&quot;`)).toBe(false);
1914+
});
1915+
1916+
it("tracks entity-quoted local image paths after decoding their HTML entities", () => {
1917+
const projectDir = createProjectDir();
1918+
recordPreviewReferences(
1919+
projectDir,
1920+
'<i style="background-image:url(&quot;assets/a&amp;b.png&quot;)"></i>',
1921+
);
1922+
recordPreviewBuilt(projectDir);
1923+
expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(true);
1924+
});
1925+
1926+
it("keeps entities literal in stylesheet URLs, as CSS raw text does", () => {
1927+
const projectDir = createProjectDir();
1928+
recordPreviewReferences(
1929+
projectDir,
1930+
"<style>i { background:url('assets/a&amp;b.png') }</style>",
1931+
);
1932+
recordPreviewBuilt(projectDir);
1933+
expect(affectsPreview(projectDir, "assets/a&amp;b.png")).toBe(true);
1934+
expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(false);
1935+
});
1936+
1937+
it("does not mistake style-looking text for an HTML style attribute", () => {
1938+
const projectDir = createProjectDir();
1939+
recordPreviewReferences(
1940+
projectDir,
1941+
`<i title='style="'></i><style>/* style=" */ i { background:url('assets/a&amp;b.png') } /* " */</style><i title='"'></i>`,
1942+
);
1943+
recordPreviewBuilt(projectDir);
1944+
expect(affectsPreview(projectDir, "assets/a&amp;b.png")).toBe(true);
1945+
expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(false);
1946+
});
1947+
1948+
it("tracks references in nested templates and script-created CSS", () => {
1949+
const projectDir = createProjectDir();
1950+
recordPreviewReferences(
1951+
projectDir,
1952+
'<template><template><img src="assets/nested.png"></template></template><script>el.style.background="url(assets/script.png)"</script>',
1953+
);
1954+
recordPreviewBuilt(projectDir);
1955+
expect(affectsPreview(projectDir, "assets/nested.png")).toBe(true);
1956+
expect(affectsPreview(projectDir, "assets/script.png")).toBe(true);
1957+
});
1958+
1959+
it("bounds filesystem paths after stripping long queries and decoding escapes", () => {
1960+
const projectDir = createProjectDir();
1961+
const path = `assets/${"x/".repeat(1200)}image.png`;
1962+
const encoded = path.replace(/x/g, "%78");
1963+
recordPreviewReferences(
1964+
projectDir,
1965+
`<img src="assets/image.png?${"x".repeat(5000)}"><img src="${encoded}">`,
1966+
);
1967+
recordPreviewBuilt(projectDir);
1968+
expect(affectsPreview(projectDir, "assets/image.png")).toBe(true);
1969+
expect(affectsPreview(projectDir, path)).toBe(true);
1970+
});
1971+
1972+
it("does not walk oversized preview references or direct read paths", () => {
1973+
const projectDir = createProjectDir();
1974+
const path = `assets/${"long/".repeat(850)}image.png`;
1975+
recordPreviewReferences(projectDir, `<img src="${path}">`);
1976+
recordPreviewRead(projectDir, path);
1977+
recordPreviewBuilt(projectDir);
1978+
expect(affectsPreview(projectDir, path)).toBe(false);
1979+
});
1980+
19011981
it.skipIf(process.platform === "win32")(
19021982
"counts an edit to the file a symlinked asset points at",
19031983
async () => {

0 commit comments

Comments
 (0)