Align domainToAscii with current WhatWG spec

The WhatWG URL Standard changed its "domain to ASCII" algorithm when
beStrict is false and the domain is an ASCII string, it now returns
the domain lowercased regardless of Unicode ToASCII's outcome, for web
compatibility. Invalid or ambiguous "xn--" (ACE) labels are no longer
rejected or validated; they are lowercased and accepted, matching
browsers and the web-platform-tests URL cases.

This supersedes the earlier spec revision that only lowercased ASCII
domains whose labels did not start with "xn--". Drop the now-obsolete
"xn--" label detection (which was dead code anyway due to a typo) and
unconditionally lowercase ASCII domains.

Closes gh-37018
This commit is contained in:
Sébastien Deleuze
2026-07-08 15:11:42 +02:00
parent ed13afa0d4
commit 7e0818f4e9
2 changed files with 26 additions and 24 deletions
@@ -55,6 +55,7 @@ import org.springframework.util.Assert;
* with {@code EXTRA}.
*
* @author Arjen Poutsma
* @author Sebastien Deleuze
* @since 6.2
*/
@SuppressWarnings({"SameParameterValue", "BooleanMethodIsAlwaysInverted"})
@@ -277,31 +278,12 @@ final class WhatWgUrlParser {
}
private static String domainToAscii(String domain, boolean beStrict) {
// If beStrict is false, domain is an ASCII string, and strictly splitting domain on U+002E (.)
// does not produce any item that starts with an ASCII case-insensitive match for "xn--",
// this step is equivalent to ASCII lowercasing domain.
// If beStrict is false and domain is an ASCII string, the algorithm returns domain lowercased
// regardless of Unicode ToASCII's outcome, due to web compatibility. In particular, the WhatWG
// spec deliberately does not reject invalid or ambiguous "xn--" (ACE) labels here. See the note in
// https://url.spec.whatwg.org/#concept-domain-to-ascii and web-platform-tests url cases
if (!beStrict && containsOnlyAscii(domain)) {
int dotIdx = domain.indexOf('.');
boolean onlyLowerCase = true;
while (dotIdx != -1) {
if (domain.length() - dotIdx > 4) {
// ASCII case-insensitive match for "xn--"
int ch0 = domain.codePointAt(dotIdx + 1);
int ch1 = domain.codePointAt(dotIdx + 2);
int ch2 = domain.codePointAt(dotIdx + 3);
int ch3 = domain.codePointAt(dotIdx + 4);
if ((ch0 == 'x' || ch0 == 'X') &&
(ch1 == 'n' || ch1 == 'N') &&
ch2 == '-' && ch3 == '_') {
onlyLowerCase = false;
break;
}
}
dotIdx = domain.indexOf('.', dotIdx + 1);
}
if (onlyLowerCase) {
return domain.toLowerCase(Locale.ENGLISH);
}
return domain.toLowerCase(Locale.ENGLISH);
}
// Let result be the result of running Unicode ToASCII (https://www.unicode.org/reports/tr46/#ToASCII)
// with domain_name set to domain, UseSTD3ASCIIRules set to beStrict, CheckHyphens set to false,
@@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Arjen Poutsma
* @author Sebastien Deleuze
*/
class WhatWgUrlParserTests {
@@ -43,6 +44,25 @@ class WhatWgUrlParserTests {
testParse("//other.info/parent/../foo/bar", "", "other.info", null, "/foo/bar", null, null);
}
@Test
void parseAsciiHost() {
// Pure ASCII host is lowercased
testParse("https://EXAMPLE.com/foo", "https", "example.com", null, "/foo", null, null);
// ASCII "xn--" (ACE) labels are accepted and lowercased (in any case), not validated or rejected.
// See https://url.spec.whatwg.org/#concept-domain-to-ascii and web-platform-tests url cases.
testParse("https://a.b.c.xn--pokxncvks", "https", "a.b.c.xn--pokxncvks", null, "", null, null);
testParse("https://a.b.c.XN--pokxncvks", "https", "a.b.c.xn--pokxncvks", null, "", null, null);
testParse("https://a.b.c.Xn--pokxncvks", "https", "a.b.c.xn--pokxncvks", null, "", null, null);
// A trailing non-numeric "xn--" label keeps a numeric-looking host as a domain, not an IPv4 address.
testParse("https://10.0.0.xn--pokxncvks", "https", "10.0.0.xn--pokxncvks", null, "", null, null);
testParse("https://10.0.0.XN--pokxncvks", "https", "10.0.0.xn--pokxncvks", null, "", null, null);
testParse("https://10.0.0.xN--pokxncvks", "https", "10.0.0.xn--pokxncvks", null, "", null, null);
// Leading ACE label is handled too, and an empty "xn--" label is accepted (not a failure)
testParse("https://XN--pokxncvks.example", "https", "xn--pokxncvks.example", null, "", null, null);
testParse("https://xn--/", "https", "xn--", null, "/", null, null);
testParse("file://xn--/p", "file", "xn--", null, "/p", null, null);
}
private void testParse(String input, String scheme, @Nullable String host, @Nullable String port, String path, @Nullable String query, @Nullable String fragment) {
WhatWgUrlParser.UrlRecord result = WhatWgUrlParser.parse(input, EMPTY_URL_RECORD, null, null);
assertThat(result.scheme()).as("Invalid scheme").isEqualTo(scheme);