fix(web-runtime): use CSS.escape and validate tag-name selectors

The previous cssEscape only handled " and \, leaving newlines/control
chars to break out of attribute string literals. Delegate to the
platform CSS.escape per CSSOM spec.

The `tag` selector branch returned the bare value through cssEscape,
which doesn't prevent pseudo-classes (`*:hover`) from injecting into
the surrounding selector. Add a positive whitelist; values that don't
match a tag-name pattern collapse to a never-matching `:not(*)`.

Also switch class selectors to `[class~="..."]` to remove the only
identifier-context use of cssEscape.
This commit is contained in:
pj committed 2026-05-03 10:59:06 +07:00
1 parent d027bf304d
commit 8c7470a1a6
1 file changed
+18 -5
+18 -5
View File
@@ -48,10 +48,9 @@ const KNOWN_KEY_TO_CSS: Record<string, (value: string) => string> = {
testTag: (v) => `[data-testid="${cssEscape(v)}"]`,
testID: (v) => `[data-testid="${cssEscape(v)}"]`,
"data-testid": (v) => `[data-testid="${cssEscape(v)}"]`,
className: (v) => `.${cssEscape(v)}`,
class: (v) => `.${cssEscape(v)}`,
tag: (v) => cssEscape(v),
text: (v) => `:scope :is(*[contains-text="${cssEscape(v)}"])`, // unused; text uses XPath
className: (v) => `[class~="${cssEscape(v)}"]`,
class: (v) => `[class~="${cssEscape(v)}"]`,
tag: tagSelector,
"aria-label": (v) => `[aria-label="${cssEscape(v)}"]`,
ariaLabel: (v) => `[aria-label="${cssEscape(v)}"]`,
accessibilityLabel: (v) => `[aria-label="${cssEscape(v)}"]`,
@@ -63,8 +62,22 @@ const KNOWN_KEY_TO_CSS: Record<string, (value: string) => string> = {
hintText: (v) => `[placeholder="${cssEscape(v)}"]`,
};
// cssEscape delegates to the platform CSS.escape (per CSSOM spec). It produces
// output safe for both identifier and string contexts, since CSS string
// literals accept the same `\HEX ` and `\X` escape sequences as identifiers.
function cssEscape(value: string): string {
return value.replace(/(["\\])/g, "\\$1");
return CSS.escape(value);
}
const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/;
// tagSelector accepts only valid HTML tag-name characters. Anything else (a
// pseudo-class like `*:hover`, a comma, whitespace) would inject CSS into the
// surrounding selector. Returning a never-matching selector rather than
// throwing keeps the spec running while making the typo visible in logs.
function tagSelector(value: string): string {
if (!TAG_NAME.test(value)) return ":not(*)";
return value;
}
function selectorFromObject(selector: Record<string, string | boolean | undefined>): {