mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio-server): bound entity-quoted preview references (#5100)
This commit is contained in:
@@ -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 [""", "'", """, """, "'", "'"]) {
|
||||
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, `"${data}"`)).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks entity-quoted local image paths after decoding their HTML entities", () => {
|
||||
const projectDir = createProjectDir();
|
||||
recordPreviewReferences(
|
||||
projectDir,
|
||||
'<i style="background-image:url("assets/a&b.png")"></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&b.png') }</style>",
|
||||
);
|
||||
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,
|
||||
`<i title='style="'></i><style>/* style=" */ i { background:url('assets/a&b.png') } /* " */</style><i title='"'></i>`,
|
||||
);
|
||||
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,
|
||||
'<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 () => {
|
||||
|
||||
Reference in New Issue
Block a user