mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(chrome): harden cssEscape for control chars + use [class~=]
Previous cssEscape only handled " and \, leaving NUL/newlines/control chars to break out of the CSS string literal. Port the CSSOM string serialization rules: NUL becomes U+FFFD, control chars become \HEX, quotes/backslashes get escaped. Class selector switched from `.x` (which would need separate identifier escaping) to `[class~="x"]`, which is also semantically correct for multi-class elements.
This commit is contained in:
1 parent
c55925d550
commit
d027bf304d
2 files changed
+47
-11
No files matched your search
@@ -2,7 +2,9 @@ package chrome
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TranslateStringSelector converts a legacy string selector ("id:foo",
|
||||
@@ -25,7 +27,7 @@ func TranslateStringSelector(selector string) (string, bool, error) {
|
||||
case "id", "resource-id":
|
||||
return `[id="` + cssEscape(value) + `"]`, false, nil
|
||||
case "class":
|
||||
return "." + cssEscape(value), false, nil
|
||||
return `[class~="` + cssEscape(value) + `"]`, false, nil
|
||||
case "tag":
|
||||
return cssEscape(value), false, nil
|
||||
case "text":
|
||||
@@ -43,20 +45,29 @@ func TranslateStringSelector(selector string) (string, bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// cssEscape escapes the subset of characters that break a CSS string literal
|
||||
// inside `[attr="..."]`. Quotes and backslashes are escaped per CSSOM's
|
||||
// CSS.escape rules; other printable bytes pass through.
|
||||
// cssEscape escapes a value for use inside a CSS double-quoted string
|
||||
// (`[attr="VALUE"]`). Per the CSSOM spec for serializing strings:
|
||||
// - U+0000 becomes U+FFFD (REPLACEMENT CHARACTER)
|
||||
// - control characters (U+0001-U+001F, U+007F) become \HEX escapes
|
||||
// - " and \ are escaped with a leading backslash
|
||||
// - everything else passes through, including non-ASCII
|
||||
//
|
||||
// Callers should not pass this output into identifier contexts (class names,
|
||||
// tag names) — use an attribute selector form (`[class~="..."]`) instead.
|
||||
func cssEscape(value string) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(value))
|
||||
for index := 0; index < len(value); index++ {
|
||||
c := value[index]
|
||||
switch c {
|
||||
case '\\', '"':
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == 0:
|
||||
builder.WriteRune(utf8.RuneError)
|
||||
case (r >= 0x01 && r <= 0x1F) || r == 0x7F:
|
||||
fmt.Fprintf(&builder, "\\%X ", r)
|
||||
case r == '\\' || r == '"':
|
||||
builder.WriteByte('\\')
|
||||
builder.WriteByte(c)
|
||||
builder.WriteRune(r)
|
||||
default:
|
||||
builder.WriteByte(c)
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
|
||||
@@ -10,7 +10,7 @@ func TestTranslateStringSelector_KnownKeys(t *testing.T) {
|
||||
}{
|
||||
{"id:email", `[id="email"]`, false},
|
||||
{"resource-id:account-name", `[id="account-name"]`, false},
|
||||
{"class:btn-primary", `.btn-primary`, false},
|
||||
{"class:btn-primary", `[class~="btn-primary"]`, false},
|
||||
{"tag:button", `button`, false},
|
||||
{"text:Sign in", `//*[normalize-space(text())="Sign in"]`, true},
|
||||
{"desc:logout", `[aria-label="logout"]`, false},
|
||||
@@ -63,3 +63,28 @@ func TestTranslateStringSelector_RejectsMissingPrefix(t *testing.T) {
|
||||
t.Error("expected error for empty selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSSEscape_ControlCharactersAndNUL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"plain ascii", "hello", "hello"},
|
||||
{"double quote", `say "hi"`, `say \"hi\"`},
|
||||
{"backslash", `a\b`, `a\\b`},
|
||||
{"newline", "a\nb", `a\A b`},
|
||||
{"carriage return", "a\rb", `a\D b`},
|
||||
{"form feed", "a\fb", `a\C b`},
|
||||
{"NUL replaced", "a\x00b", "a�b"},
|
||||
{"DEL", "a\x7Fb", `a\7F b`},
|
||||
{"non-ascii passes through", "café", "café"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
got := cssEscape(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Errorf("%s: cssEscape(%q) = %q, want %q",
|
||||
testCase.name, testCase.input, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user