From 8c7470a1a6976b195e40d06d3771374135f2c2dd Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 3 May 2026 10:59:06 +0700 Subject: [PATCH] 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. --- pkg/spec/src/web-runtime.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 8130c6b..4469819 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -48,10 +48,9 @@ const KNOWN_KEY_TO_CSS: Record 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> = { 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): {