Title: [🐛 Bug]: By.className()/By.id() escape a leading non-ASCII Unicode digit to the wrong ASCII code point, colliding with a different class name
Description
PreW3CLocator.cssEscape (used by By.className and By.id) special-cases a leading digit with Character.isDigit(...), which is true for every Unicode Nd (decimal digit) code point, not only ASCII 0–9. It then rewrites that first character as "\\" + (30 + Integer.parseInt(first)) + " ": the decimal number 30 + value, printed as text, read back as a hex escape by CSS. That round-trips for ASCII U+0030–U+0039 only. A non-ASCII digit of numeric value d gets rewritten to the escape for the ASCII digit U+003d, changing the class/id to a different string.
Result: a leading Arabic-Indic ٥ (U+0665, value 5) escapes to \35 , the escape for ASCII 5, so By.className("٥foo") produces the exact same CSS selector as By.className("5foo"). In the browser this matches a different element, or nothing. No error is raised at any point.
Observed against selenium-api 4.46.0, present on trunk:
| Input |
Selenium fallback CSS |
Should escape to |
By.className("5foo") (ASCII 5) |
.\35 foo |
.\35 foo ✓ |
By.className("٥foo") (U+0665, Arabic-Indic 5) |
.\35 foo ❌ |
.\665 foo (the U+0665 code point) |
By.className("9bar") (U+FF19, fullwidth 9) |
.\39 bar ❌ |
.\ff19 bar |
By.className("৭x") (U+09ED, Bengali 7) |
.\37 x ❌ |
.\9ed x |
Collision proof: By.className("٥foo") and By.className("5foo") produce the identical selector .\35 foo; the non-ASCII digit is indistinguishable from ASCII 5 after escaping.
By.id (format string #%s) goes through the same cssEscape, so it has the same defect. A byte-identical copy of the routine also lives in W3CHttpCommandCodec.java (see below).
JDK behavior (confirmed on JDK 24): Character.isDigit is true for ٥ 9 ৭ ٠; Integer.parseInt("٥") returns 5 without throwing. The guard fires and emits the wrong escape with no exception.
Reproducible Code
import org.openqa.selenium.By;
import java.lang.reflect.Field;
public class RealByClassTest {
static String fallbackSelector(By by) throws Exception {
Field f = null; Class<?> cur = by.getClass();
while (cur != null) {
try { f = cur.getDeclaredField("fallback"); break; }
catch (NoSuchFieldException e) { cur = cur.getSuperclass(); }
}
f.setAccessible(true);
Object fb = f.get(by);
Field cf = fb.getClass().getDeclaredField("cssSelector");
cf.setAccessible(true);
return (String) cf.get(fb);
}
static String show(String s) { // print non-ASCII as \uXXXX
StringBuilder b = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
b.append(c >= 0x20 && c < 0x7f ? String.valueOf(c)
: String.format("\\u%04X", (int) c));
}
return b.toString();
}
public static void main(String[] args) throws Exception {
for (String cls : new String[]{"5foo", "٥foo", "9bar", "৭x"}) {
By by = By.className(cls);
System.out.printf("By.className(\"%s\") -> %s%n", show(cls), show(fallbackSelector(by)));
}
String a = fallbackSelector(By.className("٥foo"));
String b = fallbackSelector(By.className("5foo"));
System.out.println("COLLISION '٥foo' vs '5foo' same selector? " + a.equals(b));
}
}
Run against the real jar; no browser needed to observe the wrong escape and the collision:
javac -encoding UTF-8 -cp selenium-api-4.46.0.jar -d . RealByClassTest.java
java -Dfile.encoding=UTF-8 -cp selenium-api-4.46.0.jar:. RealByClassTest
Output:
By.className("5foo") -> .\35 foo
By.className("٥foo") -> .\35 foo
By.className("9bar") -> .\39 bar
By.className("৭x") -> .\37 x
COLLISION '٥foo' vs '5foo' same selector? true
Cross-check with an independent CSS engine (jsdom): .\35 foo matches the ASCII-5 element (wrong target); the intended .٥foo / .\665 foo match the Arabic-Indic element. With only the ٥foo element present, .\35 foo matches nothing, a false NoSuchElementException.
Expected vs Actual
- Expected:
By.className("٥foo") escapes the leading ٥ (U+0665) to its own code point (.\665 foo) and locates the element whose class starts with ٥.
- Actual: it escapes to
.\35 foo (ASCII 5), colliding with 5foo and matching the wrong element, or nothing. No error surfaces.
Root cause (file:line, trunk)
java/src/org/openqa/selenium/By.java:451 (PreW3CLocator.cssEscape):
if (!using.isEmpty() && Character.isDigit(using.charAt(0))) {
using = "\\" + (30 + Integer.parseInt(using.substring(0, 1))) + " " + using.substring(1);
}
Character.isDigit accepts all Unicode Nd. 30 + Integer.parseInt(...) computes a decimal number (30 + 5 = 35) whose digits CSS then reads as hex text (\35 = U+0035); that coincidence holds for ASCII 0–9 only.
- A byte-identical copy exists in
java/src/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodec.java (cssEscape, line 390); both copies need the fix.
Suggested fix
Gate the escape on ASCII digits and emit the character's own code point. This matches the CSS serialize-an-identifier algorithm, which escapes a leading digit as \ + hex code point + space.
char first = using.charAt(0);
if (first >= '0' && first <= '9') {
using = "\\3" + first + " " + using.substring(1); // ASCII digit -> \3X<space>
}
// non-ASCII leading digits need no special digit-escape here; if a broader
// CSS.escape is desired, escape by actual code point, never by numeric value.
The complete route is to implement CSS serialize-an-identifier (or reuse a vetted CSS.escape port) so a leading digit c becomes \ + Integer.toHexString(c) + " ", correct for ASCII and non-ASCII digits alike. Apply the fix to both copies (By.java and W3CHttpCommandCodec.java) with a shared regression test over leading ٥ 9 ৭ ٠.
Other template fields (for the submitter to fill on the form):
- Selenium version:
4.46.0
- Regression: Not sure / long-standing (present in current
trunk)
- Operating System: platform-independent (pure Java string handling)
- Language Binding: Java
- Browsers: N/A (reproduces at selector-construction time; wrong-target is confirmed with an independent CSS engine, no browser needed)
- Selenium Grid: No
Found via property-based & differential bug-hunting, part of an effort to scale PBT (DepTyCheck-based) testing across the OSS ecosystem.
If this is intended / by-design: I'm really sorry, please just close it — no need to flag or ban me. I'm trying to scale property-based testing across the whole ecosystem and my publishing agents may have gotten this one wrong. I read every issue and follow up on each.
Title:
[🐛 Bug]: By.className()/By.id() escape a leading non-ASCII Unicode digit to the wrong ASCII code point, colliding with a different class nameDescription
PreW3CLocator.cssEscape(used byBy.classNameandBy.id) special-cases a leading digit withCharacter.isDigit(...), which istruefor every UnicodeNd(decimal digit) code point, not only ASCII0–9. It then rewrites that first character as"\\" + (30 + Integer.parseInt(first)) + " ": the decimal number30 + value, printed as text, read back as a hex escape by CSS. That round-trips for ASCIIU+0030–U+0039only. A non-ASCII digit of numeric valuedgets rewritten to the escape for the ASCII digitU+003d, changing the class/id to a different string.Result: a leading Arabic-Indic
٥(U+0665, value 5) escapes to\35, the escape for ASCII5, soBy.className("٥foo")produces the exact same CSS selector asBy.className("5foo"). In the browser this matches a different element, or nothing. No error is raised at any point.Observed against selenium-api 4.46.0, present on
trunk:By.className("5foo")(ASCII 5).\35 foo.\35 foo✓By.className("٥foo")(U+0665, Arabic-Indic 5).\35 foo❌.\665 foo(the U+0665 code point)By.className("9bar")(U+FF19, fullwidth 9).\39 bar❌.\ff19 barBy.className("৭x")(U+09ED, Bengali 7).\37 x❌.\9ed xCollision proof:
By.className("٥foo")andBy.className("5foo")produce the identical selector.\35 foo; the non-ASCII digit is indistinguishable from ASCII5after escaping.By.id(format string#%s) goes through the samecssEscape, so it has the same defect. A byte-identical copy of the routine also lives inW3CHttpCommandCodec.java(see below).JDK behavior (confirmed on JDK 24):
Character.isDigitistruefor٥ 9 ৭ ٠;Integer.parseInt("٥")returns5without throwing. The guard fires and emits the wrong escape with no exception.Reproducible Code
Run against the real jar; no browser needed to observe the wrong escape and the collision:
Output:
Cross-check with an independent CSS engine (jsdom):
.\35 foomatches the ASCII-5element (wrong target); the intended.٥foo/.\665 foomatch the Arabic-Indic element. With only the٥fooelement present,.\35 foomatches nothing, a falseNoSuchElementException.Expected vs Actual
By.className("٥foo")escapes the leading٥(U+0665) to its own code point (.\665 foo) and locates the element whose class starts with٥..\35 foo(ASCII5), colliding with5fooand matching the wrong element, or nothing. No error surfaces.Root cause (file:line,
trunk)java/src/org/openqa/selenium/By.java:451(PreW3CLocator.cssEscape):Character.isDigitaccepts all UnicodeNd.30 + Integer.parseInt(...)computes a decimal number (30 + 5 = 35) whose digits CSS then reads as hex text (\35= U+0035); that coincidence holds for ASCII0–9only.java/src/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodec.java(cssEscape, line 390); both copies need the fix.Suggested fix
Gate the escape on ASCII digits and emit the character's own code point. This matches the CSS
serialize-an-identifieralgorithm, which escapes a leading digit as\+ hex code point + space.The complete route is to implement CSS
serialize-an-identifier(or reuse a vettedCSS.escapeport) so a leading digitcbecomes\+Integer.toHexString(c)+" ", correct for ASCII and non-ASCII digits alike. Apply the fix to both copies (By.javaandW3CHttpCommandCodec.java) with a shared regression test over leading٥ 9 ৭ ٠.Other template fields (for the submitter to fill on the form):
4.46.0trunk)Found via property-based & differential bug-hunting, part of an effort to scale PBT (DepTyCheck-based) testing across the OSS ecosystem.
If this is intended / by-design: I'm really sorry, please just close it — no need to flag or ban me. I'm trying to scale property-based testing across the whole ecosystem and my publishing agents may have gotten this one wrong. I read every issue and follow up on each.