fix(core,studio): escape user values in querySelector attribute selectors

Extract cssAttrSelector to packages/core/src/utils/cssSelector.ts and
use it (or CSS.escape for browser-side code) at all 12 sites that
previously interpolated raw user-authored values into querySelector
attribute selectors. A " in a composition ID, script src, or
data-start value would produce a malformed selector that throws.

Node-side (core compiler/parser): uses the shared cssAttrSelector.
Browser-side (runtime, studio): uses native CSS.escape().

Supersedes #1568 which fixed only the 3 bundler sites.
This commit is contained in:
Miguel Ángel
2026-06-19 15:47:28 -04:00
committed by GitHub
parent 758eda995c
commit c0ffdc0fb0
15 changed files with 126 additions and 21 deletions
+6 -4
View File
@@ -18,6 +18,7 @@ import { validateHyperframeHtmlContract } from "./staticGuard";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
import { readDeclaredDefaults } from "../runtime/getVariables";
import { inlineSubCompositions } from "./inlineSubCompositions";
import { queryByAttr } from "../utils/cssSelector";
import { isSafePath, resolveWithinProject } from "../safePath.js";
import { HF_COLOR_GRADING_ATTR } from "../colorGrading";
@@ -277,7 +278,8 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
}
function cssAttributeSelector(attr: string, value: string): string {
return `[${attr}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `[${attr}="${escaped}"]`;
}
function uniqueCompositionId(baseId: string, index: number): string {
@@ -624,7 +626,7 @@ export interface BundleOptions {
*/
function ensureExternalScriptTag(doc: Document, src: string): void {
if (doc.querySelector(`script[src="${src}"]`)) return;
if (queryByAttr(doc, "src", src, "script")) return;
const el = doc.createElement("script");
el.setAttribute("src", src);
doc.body.appendChild(el);
@@ -825,7 +827,7 @@ export async function bundleToSingleHtml(
continue;
}
}
if (!document.querySelector(`script[src="${extSrc}"]`)) {
if (!queryByAttr(document, "src", extSrc, "script")) {
const extScript = document.createElement("script");
extScript.setAttribute("src", extSrc);
document.body.appendChild(extScript);
@@ -857,7 +859,7 @@ export async function bundleToSingleHtml(
const hostIdentity = hostIdentityByElement.get(host);
const runtimeCompId = hostIdentity?.runtimeCompositionId || compId;
const innerDoc = parseHTMLContent(templateHtml);
const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`);
const innerRoot = queryByAttr(innerDoc, "data-composition-id", compId);
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
const runtimeScope = runtimeCompId
? cssAttributeSelector("data-composition-id", runtimeCompId)
@@ -13,6 +13,7 @@ import {
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths";
import { queryByAttr } from "../utils/cssSelector";
import {
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
@@ -225,7 +226,7 @@ export function inlineSubCompositions(
// Find the inner composition root
const innerRoot = compId
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
? queryByAttr(contentDoc, "data-composition-id", compId)
: contentDoc.querySelector("[data-composition-id]");
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
+1
View File
@@ -151,6 +151,7 @@ export {
rewriteCssAssetUrls,
} from "./compiler/rewriteSubCompPaths";
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
export { queryByAttr } from "./utils/cssSelector";
export { decodeUrlPathVariants } from "./utils/urlPath";
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";
export {
+2 -1
View File
@@ -13,6 +13,7 @@ import type {
import { validateCompositionGsap } from "./gsapSerialize";
import { ensureHfIds } from "./hfIds.js";
import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js";
import { queryByAttr } from "../utils/cssSelector";
import { removeAnimationFromScript } from "./gsapWriterAcorn.js";
import type { ValidationResult } from "../core.types";
@@ -519,7 +520,7 @@ export function updateElementInHtml(
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const el = doc.getElementById(elementId) || doc.querySelector(`[data-name="${elementId}"]`);
const el = doc.getElementById(elementId) || queryByAttr(doc, "data-name", elementId);
if (!el) return html;
if (updates.startTime !== undefined) {
+3 -3
View File
@@ -97,11 +97,11 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
const htmlEl = el as HTMLElement;
if (htmlEl.id) return `#${htmlEl.id}`;
const compositionId = el.getAttribute("data-composition-id");
if (compositionId) return `[data-composition-id="${compositionId}"]`;
if (compositionId) return `[data-composition-id="${CSS.escape(compositionId)}"]`;
const compositionSrc = el.getAttribute("data-composition-src");
if (compositionSrc) return `[data-composition-src="${compositionSrc}"]`;
if (compositionSrc) return `[data-composition-src="${CSS.escape(compositionSrc)}"]`;
const track = el.getAttribute("data-track-index");
if (track) return `[data-track-index="${track}"]`;
if (track) return `[data-track-index="${CSS.escape(track)}"]`;
const tag = el.tagName.toLowerCase();
const parent = el.parentElement;
if (!parent) return tag;
@@ -0,0 +1,77 @@
import { describe, it, expect } from "bun:test";
import { queryByAttr } from "./cssSelector";
function makeDoc(html: string) {
const { parseHTML } = require("linkedom");
const { document } = parseHTML(`<html><body>${html}</body></html>`);
return document;
}
describe("queryByAttr", () => {
it("finds element by exact attribute match", () => {
const doc = makeDoc('<div data-id="abc"></div>');
const el = queryByAttr(doc, "data-id", "abc");
expect(el).not.toBeNull();
expect(el!.getAttribute("data-id")).toBe("abc");
});
it("returns null when no match", () => {
const doc = makeDoc('<div data-id="abc"></div>');
expect(queryByAttr(doc, "data-id", "xyz")).toBeNull();
});
it("handles values with double quotes", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", 'has"quote');
expect(queryByAttr(doc, "data-id", 'has"quote')).toBe(el);
});
it("handles values with backslashes", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", "has\\backslash");
expect(queryByAttr(doc, "data-id", "has\\backslash")).toBe(el);
});
it("handles values with closing bracket", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", "has]bracket");
expect(queryByAttr(doc, "data-id", "has]bracket")).toBe(el);
});
it("handles injection attempt", () => {
const doc = makeDoc('<div data-id="safe"></div>');
const el = doc.querySelector("div")!;
el.setAttribute("data-id", '"][data-evil]');
expect(queryByAttr(doc, "data-id", '"][data-evil]')).toBe(el);
expect(queryByAttr(doc, "data-id", "safe")).toBeNull();
});
it("filters by tag when provided", () => {
const doc = makeDoc('<div data-src="a.js"></div><script data-src="a.js"></script>');
const el = queryByAttr(doc, "data-src", "a.js", "script");
expect(el).not.toBeNull();
expect(el!.tagName.toLowerCase()).toBe("script");
});
it("returns null when tag filter excludes match", () => {
const doc = makeDoc('<div data-src="a.js"></div>');
expect(queryByAttr(doc, "data-src", "a.js", "script")).toBeNull();
});
it("handles values with newlines", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", "line1\nline2");
expect(queryByAttr(doc, "data-id", "line1\nline2")).toBe(el);
});
it("handles values with leading digits", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", "123abc");
expect(queryByAttr(doc, "data-id", "123abc")).toBe(el);
});
});
+14
View File
@@ -0,0 +1,14 @@
// ponytail: queries DOM by exact attribute match without interpolating
// the value into a selector string — zero injection surface.
export function queryByAttr(
root: ParentNode,
attr: string,
value: string,
tag?: string,
): Element | null {
const selector = tag ? `${tag}[${attr}]` : `[${attr}]`;
for (const el of root.querySelectorAll(selector)) {
if (el.getAttribute(attr) === value) return el;
}
return null;
}