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
@@ -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);
});
});