make URL / URLSearchParams parsing a little more correct (char sets, percent coding, etc)

This commit is contained in:
Denis Pushkarev
2026-07-26 16:52:13 +03:00
parent 711025a6e7
commit 1382bb3cd7
12 changed files with 449 additions and 158 deletions
+1
View File
@@ -27,6 +27,7 @@
- Use `PromiseResolve` semantics in `Promise.try`, following [tc39/ecma262/#3883](https://github.com/tc39/ecma262/pull/3883)
- Added detection of missed [Webkit ~ Safari < 26.2 `Iterator.prototype.flatMap` bug](https://bugs.webkit.org/show_bug.cgi?id=297532) case, [#1538](https://github.com/zloirock/core-js/issues/1538)
- Deno 2.9+ [*replaces* `Object.prototype.__proto__`](https://github.com/denoland/deno/pull/35192) instead of removing it, so the feature detection updated
- Make `URL` / `URLSearchParams` parsing a little more correct (char sets, percent coding, etc)
- Ensure opaque paths always roundtrip in URL polyfill (still without adding to feature detection), [whatwg/url#844](https://github.com/whatwg/url/pull/844)
- Fixed possible crash on some keys in `Symbol.for`
- Some `get-iterator` / `get-iterator-method` fixes
@@ -0,0 +1,171 @@
'use strict';
// TODO: in core-js@4, move /modules/ dependencies to public entries for better optimization by tools like `preset-env`
require('../modules/es.string.from-code-point');
var getBuiltIn = require('../internals/get-built-in');
var uncurryThis = require('../internals/function-uncurry-this');
var fromCharCode = String.fromCharCode;
var fromCodePoint = getBuiltIn('String', 'fromCodePoint');
var $encodeURIComponent = encodeURIComponent;
var $parseInt = parseInt;
var charAt = uncurryThis(''.charAt);
var push = uncurryThis([].push);
var replace = uncurryThis(''.replace);
var stringSlice = uncurryThis(''.slice);
var exec = uncurryThis(/./.exec);
var FALLBACK_REPLACER = '\uFFFD';
var VALID_HEX = /^[0-9a-f]+$/i;
// a surrogate pair is matched first, so a one-unit match is always a lone surrogate
var SURROGATE = /[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g;
var parseHexOctet = function (string, start) {
var substr = stringSlice(string, start, start + 2);
if (!exec(VALID_HEX, substr)) return NaN;
return $parseInt(substr, 16);
};
var getLeadingOnes = function (octet) {
var count = 0;
for (var mask = 0x80; mask > 0 && (octet & mask) !== 0; mask >>= 1) {
count++;
}
return count;
};
var utf8Decode = function (octets) {
var codePoint = null;
var length = octets.length;
switch (length) {
case 1:
codePoint = octets[0];
break;
case 2:
codePoint = (octets[0] & 0x1F) << 6 | (octets[1] & 0x3F);
break;
case 3:
codePoint = (octets[0] & 0x0F) << 12 | (octets[1] & 0x3F) << 6 | (octets[2] & 0x3F);
break;
case 4:
codePoint = (octets[0] & 0x07) << 18 | (octets[1] & 0x3F) << 12 | (octets[2] & 0x3F) << 6 | (octets[3] & 0x3F);
break;
}
// reject surrogates, overlong encodings, and out-of-range codepoints
if (codePoint === null
|| codePoint > 0x10FFFF
|| (codePoint >= 0xD800 && codePoint <= 0xDFFF)
|| codePoint < (length > 3 ? 0x10000 : length > 2 ? 0x800 : length > 1 ? 0x80 : 0)
) return null;
return codePoint;
};
var replaceLoneSurrogate = function (chunk) {
return chunk.length === 2 ? chunk : FALLBACK_REPLACER;
};
// https://url.spec.whatwg.org/#percent-decode
/* eslint-disable max-depth -- ok */
var decode = function (input) {
var length = input.length;
var result = '';
var i = 0;
while (i < length) {
var decodedChar = charAt(input, i);
if (decodedChar === '%') {
if (charAt(input, i + 1) === '%' || i + 3 > length) {
result += '%';
i++;
continue;
}
var octet = parseHexOctet(input, i + 1);
// eslint-disable-next-line no-self-compare -- NaN check
if (octet !== octet) {
result += decodedChar;
i++;
continue;
}
i += 2;
var byteSequenceLength = getLeadingOnes(octet);
if (byteSequenceLength === 0) {
decodedChar = fromCharCode(octet);
} else {
if (byteSequenceLength === 1 || byteSequenceLength > 4) {
result += FALLBACK_REPLACER;
i++;
continue;
}
var octets = [octet];
var sequenceIndex = 1;
while (sequenceIndex < byteSequenceLength) {
i++;
if (i + 3 > length || charAt(input, i) !== '%') break;
var nextByte = parseHexOctet(input, i + 1);
// eslint-disable-next-line no-self-compare -- NaN check
if (nextByte !== nextByte || nextByte > 191 || nextByte < 128) break;
// https://encoding.spec.whatwg.org/#utf-8-decoder - position-specific byte ranges
if (sequenceIndex === 1) {
if (octet === 0xE0 && nextByte < 0xA0) break;
if (octet === 0xED && nextByte > 0x9F) break;
if (octet === 0xF0 && nextByte < 0x90) break;
if (octet === 0xF4 && nextByte > 0x8F) break;
}
push(octets, nextByte);
i += 2;
sequenceIndex++;
}
if (octets.length !== byteSequenceLength) {
result += FALLBACK_REPLACER;
continue;
}
var codePoint = utf8Decode(octets);
if (codePoint === null) {
for (var replacement = 0; replacement < byteSequenceLength; replacement++) result += FALLBACK_REPLACER;
i++;
continue;
} else {
decodedChar = fromCodePoint(codePoint);
}
}
}
result += decodedChar;
i++;
}
return result;
};
/* eslint-enable max-depth -- ok */
// https://url.spec.whatwg.org/#string-percent-encode-after-encoding
// a lone surrogate is the only input `encodeURIComponent` throws on, and the UTF-8
// encoder replaces it - so the throw selects the slow path instead of a per-call scan
var encode = function (input) {
try {
return $encodeURIComponent(input);
} catch (error) {
return $encodeURIComponent(replace(input, SURROGATE, replaceLoneSurrogate));
}
};
module.exports = {
decode: decode,
encode: encode
};
@@ -1,15 +1,14 @@
'use strict';
// TODO: in core-js@4, move /modules/ dependencies to public entries for better optimization by tools like `preset-env`
require('../modules/es.array.iterator');
require('../modules/es.string.from-code-point');
var $ = require('../internals/export');
var globalThis = require('../internals/global-this');
var safeGetBuiltIn = require('../internals/safe-get-built-in');
var getBuiltIn = require('../internals/get-built-in');
var call = require('../internals/function-call');
var uncurryThis = require('../internals/function-uncurry-this');
var DESCRIPTORS = require('../internals/descriptors');
var USE_NATIVE_URL = require('../internals/url-constructor-detection');
var percentCoding = require('../internals/url-percent-coding');
var defineBuiltIn = require('../internals/define-built-in');
var defineBuiltInAccessor = require('../internals/define-built-in-accessor');
var defineBuiltIns = require('../internals/define-built-ins');
@@ -39,6 +38,8 @@ var URL_SEARCH_PARAMS_ITERATOR = URL_SEARCH_PARAMS + 'Iterator';
var setInternalState = InternalStateModule.set;
var getInternalParamsState = InternalStateModule.getterFor(URL_SEARCH_PARAMS);
var getInternalIteratorState = InternalStateModule.getterFor(URL_SEARCH_PARAMS_ITERATOR);
var percentDecode = percentCoding.decode;
var percentEncode = percentCoding.encode;
var nativeFetch = safeGetBuiltIn('fetch');
var NativeRequest = safeGetBuiltIn('Request');
@@ -46,10 +47,6 @@ var Headers = safeGetBuiltIn('Headers');
var RequestPrototype = NativeRequest && NativeRequest.prototype;
var HeadersPrototype = Headers && Headers.prototype;
var TypeError = globalThis.TypeError;
var encodeURIComponent = globalThis.encodeURIComponent;
var fromCharCode = String.fromCharCode;
var fromCodePoint = getBuiltIn('String', 'fromCodePoint');
var $parseInt = parseInt;
var charAt = uncurryThis(''.charAt);
var join = uncurryThis([].join);
var push = uncurryThis([].push);
@@ -58,143 +55,14 @@ var shift = uncurryThis([].shift);
var splice = uncurryThis([].splice);
var split = uncurryThis(''.split);
var stringSlice = uncurryThis(''.slice);
var exec = uncurryThis(/./.exec);
var plus = /\+/g;
var FALLBACK_REPLACER = '\uFFFD';
var VALID_HEX = /^[0-9a-f]+$/i;
var parseHexOctet = function (string, start) {
var substr = stringSlice(string, start, start + 2);
if (!exec(VALID_HEX, substr)) return NaN;
return $parseInt(substr, 16);
// https://url.spec.whatwg.org/#urlencoded-parsing - `+` decodes to a space
var decodeQueryComponent = function (input) {
return percentDecode(replace(input, plus, ' '));
};
var getLeadingOnes = function (octet) {
var count = 0;
for (var mask = 0x80; mask > 0 && (octet & mask) !== 0; mask >>= 1) {
count++;
}
return count;
};
var utf8Decode = function (octets) {
var codePoint = null;
var length = octets.length;
switch (length) {
case 1:
codePoint = octets[0];
break;
case 2:
codePoint = (octets[0] & 0x1F) << 6 | (octets[1] & 0x3F);
break;
case 3:
codePoint = (octets[0] & 0x0F) << 12 | (octets[1] & 0x3F) << 6 | (octets[2] & 0x3F);
break;
case 4:
codePoint = (octets[0] & 0x07) << 18 | (octets[1] & 0x3F) << 12 | (octets[2] & 0x3F) << 6 | (octets[3] & 0x3F);
break;
}
// reject surrogates, overlong encodings, and out-of-range codepoints
if (codePoint === null
|| codePoint > 0x10FFFF
|| (codePoint >= 0xD800 && codePoint <= 0xDFFF)
|| codePoint < (length > 3 ? 0x10000 : length > 2 ? 0x800 : length > 1 ? 0x80 : 0)
) return null;
return codePoint;
};
/* eslint-disable max-statements, max-depth -- ok */
var decode = function (input) {
input = replace(input, plus, ' ');
var length = input.length;
var result = '';
var i = 0;
while (i < length) {
var decodedChar = charAt(input, i);
if (decodedChar === '%') {
if (charAt(input, i + 1) === '%' || i + 3 > length) {
result += '%';
i++;
continue;
}
var octet = parseHexOctet(input, i + 1);
// eslint-disable-next-line no-self-compare -- NaN check
if (octet !== octet) {
result += decodedChar;
i++;
continue;
}
i += 2;
var byteSequenceLength = getLeadingOnes(octet);
if (byteSequenceLength === 0) {
decodedChar = fromCharCode(octet);
} else {
if (byteSequenceLength === 1 || byteSequenceLength > 4) {
result += FALLBACK_REPLACER;
i++;
continue;
}
var octets = [octet];
var sequenceIndex = 1;
while (sequenceIndex < byteSequenceLength) {
i++;
if (i + 3 > length || charAt(input, i) !== '%') break;
var nextByte = parseHexOctet(input, i + 1);
// eslint-disable-next-line no-self-compare -- NaN check
if (nextByte !== nextByte || nextByte > 191 || nextByte < 128) break;
// https://encoding.spec.whatwg.org/#utf-8-decoder - position-specific byte ranges
if (sequenceIndex === 1) {
if (octet === 0xE0 && nextByte < 0xA0) break;
if (octet === 0xED && nextByte > 0x9F) break;
if (octet === 0xF0 && nextByte < 0x90) break;
if (octet === 0xF4 && nextByte > 0x8F) break;
}
push(octets, nextByte);
i += 2;
sequenceIndex++;
}
if (octets.length !== byteSequenceLength) {
result += FALLBACK_REPLACER;
continue;
}
var codePoint = utf8Decode(octets);
if (codePoint === null) {
for (var replacement = 0; replacement < byteSequenceLength; replacement++) result += FALLBACK_REPLACER;
i++;
continue;
} else {
decodedChar = fromCodePoint(codePoint);
}
}
}
result += decodedChar;
i++;
}
return result;
};
/* eslint-enable max-statements, max-depth -- ok */
var find = /[!'()~]|%20/g;
var replacements = {
@@ -211,7 +79,7 @@ var replacer = function (match) {
};
var serialize = function (it) {
return replace(encodeURIComponent(it), find, replacer);
return replace(percentEncode(it), find, replacer);
};
var URLSearchParamsIterator = createIteratorConstructor(function Iterator(params, kind) {
@@ -285,8 +153,8 @@ URLSearchParamsState.prototype = {
if (attribute.length) {
entry = split(attribute, '=');
push(entries, {
key: decode(shift(entry)),
value: decode(join(entry, '='))
key: decodeQueryComponent(shift(entry)),
value: decodeQueryComponent(join(entry, '='))
});
}
}
+98 -17
View File
@@ -16,6 +16,7 @@ var arrayFrom = require('../internals/array-from');
var arraySlice = require('../internals/array-slice');
var codeAt = require('../internals/string-multibyte').codeAt;
var toASCII = require('../internals/string-punycode-to-ascii');
var percentCoding = require('../internals/url-percent-coding');
var $toString = require('../internals/to-string');
var setToStringTag = require('../internals/set-to-string-tag');
var validateArgumentsLength = require('../internals/validate-arguments-length');
@@ -26,13 +27,15 @@ var setInternalState = InternalStateModule.set;
var getInternalURLState = InternalStateModule.getterFor('URL');
var URLSearchParams = URLSearchParamsModule.URLSearchParams;
var getInternalSearchParamsState = URLSearchParamsModule.getState;
var percentDecode = percentCoding.decode;
var percentEncode = percentCoding.encode;
var NativeURL = globalThis.URL;
var TypeError = globalThis.TypeError;
var encodeURIComponent = globalThis.encodeURIComponent;
var parseInt = globalThis.parseInt;
var floor = Math.floor;
var pow = Math.pow;
var fromCharCode = String.fromCharCode;
var charAt = uncurryThis(''.charAt);
var exec = uncurryThis(/./.exec);
var join = uncurryThis([].join);
@@ -42,6 +45,7 @@ var push = uncurryThis([].push);
var replace = uncurryThis(''.replace);
var shift = uncurryThis([].shift);
var split = uncurryThis(''.split);
var stringIndexOf = uncurryThis(''.indexOf);
var stringSlice = uncurryThis(''.slice);
var toLowerCase = uncurryThis(''.toLowerCase);
var unshift = uncurryThis([].unshift);
@@ -59,15 +63,88 @@ var OCT = /^[0-7]+$/;
var DEC = /^\d+$/;
var HEX = /^[\da-f]+$/i;
/* eslint-disable regexp/no-control-character -- safe */
var FORBIDDEN_HOST_CODE_POINT = /[\0\t\n\r #%/:<>?@[\\\]^|]/;
var FORBIDDEN_HOST_CODE_POINT_EXCLUDING_PERCENT = /[\0\t\n\r #/:<>?@[\\\]^|]/;
// https://url.spec.whatwg.org/#forbidden-domain-code-point
var FORBIDDEN_DOMAIN_CODE_POINT = /[\u0000-\u0020#%/:<>?@[\\\]^|\u007F]/;
// https://url.spec.whatwg.org/#forbidden-host-code-point
var FORBIDDEN_HOST_CODE_POINT = /[\0\t\n\r #/:<>?@[\\\]^|]/;
var LEADING_C0_CONTROL_OR_SPACE = /^[\u0000-\u0020]+/;
var TRAILING_C0_CONTROL_OR_SPACE = /(^|[^\u0000-\u0020])[\u0000-\u0020]+$/;
var TAB_AND_NEW_LINE = /[\t\n\r]/g;
var NON_ASCII = /[^\u0000-\u007F]/;
/* eslint-enable regexp/no-control-character -- safe */
// UTS#46 maps these onto a string containing a forbidden domain code point, so a domain
// holding one is rejected - listing them is far smaller than shipping the mapping table
var MAPPED_ONTO_FORBIDDEN = '\u00A8\u00AF\u00B4\u00B8\u02D8\u02D9\u02DA\u02DB\u02DC\u02DD\u037A\u0384\u0385\u1FBD\u1FBF\u1FC0'
+ '\u1FC1\u1FCD\u1FCE\u1FCF\u1FDD\u1FDE\u1FDF\u1FED\u1FEE\u1FFD\u1FFE\u2017\u203E\u2047\u2048\u2049'
+ '\u2100\u2101\u2105\u2106\u2A74\u309B\u309C\uFC5E\uFC5F\uFC60\uFC61\uFC62\uFC63\uFDFA\uFDFB\uFE13'
+ '\uFE16\uFE47\uFE48\uFE49\uFE4A\uFE4B\uFE4C\uFE55\uFE56\uFE5F\uFE64\uFE65\uFE68\uFE6A\uFE6B\uFE70'
+ '\uFE72\uFE74\uFE76\uFE78\uFE7A\uFE7C\uFE7E\uFFE3';
// eslint-disable-next-line no-unassigned-vars -- expected `undefined` value
var EOF;
// https://www.unicode.org/reports/tr46/#IDNA_Mapping_Table
var isIgnoredCodePoint = function (code) {
return code === 0xAD || code === 0x34F || code === 0x200B || code === 0x3164
|| code === 0xFEFF || code === 0xFFA0
|| (code >= 0x115F && code <= 0x1160)
|| (code >= 0x17B4 && code <= 0x17B5)
|| (code >= 0x180B && code <= 0x180F)
|| (code >= 0x2060 && code <= 0x2064) || (code >= 0x206A && code <= 0x206F)
|| (code >= 0xFE00 && code <= 0xFE0F)
|| (code >= 0xE0100 && code <= 0xE01EF);
};
var isDisallowedCodePoint = function (code) {
return code === 0xFFFD
// lone surrogate - a matched pair is a single code point and never lands here
|| (code >= 0xD800 && code <= 0xDFFF)
// C1 controls
|| (code >= 0x80 && code <= 0x9F)
// line and paragraph separators, bidirectional formatting characters
|| (code >= 0x200E && code <= 0x200F) || (code >= 0x2028 && code <= 0x2029)
|| (code >= 0x202A && code <= 0x202E) || (code >= 0x2065 && code <= 0x2069)
// noncharacters
|| (code >= 0xFDD0 && code <= 0xFDEF) || (code & 0xFFFE) === 0xFFFE
// private use
|| (code >= 0xE000 && code <= 0xF8FF) || (code >= 0xF0000 && code <= 0x10FFFD);
};
var mapCodePoint = function (code) {
// full-width forms
if (code >= 0xFF01 && code <= 0xFF5E) return code - 0xFEE0;
// spaces and the listed code points collapse onto a forbidden domain code point; a space
// stands in for the real mapping since either way the domain is rejected
if (code === 0xA0 || code === 0x1680 || code === 0x202F || code === 0x205F || code === 0x3000) return 0x20;
if (code >= 0x2000 && code <= 0x200A) return 0x20;
// the list holds no supplementary code point, and `fromCharCode` would truncate one
if (code <= 0xFFFF && stringIndexOf(MAPPED_ONTO_FORBIDDEN, fromCharCode(code)) > -1) return 0x20;
return code;
};
// https://url.spec.whatwg.org/#concept-domain-to-ascii
// A subset of UTS#46 processing - the full mapping table is too large to ship. Covered are the
// entries cheap to express: the ignored and disallowed code points, full-width forms, and the
// code points mapping onto a forbidden domain code point. Not covered, so such domains are
// punycoded rather than mapped or rejected: unassigned code points, half-width forms,
// compatibility decompositions, NFC, CheckJoiners, CheckBidi, and labels that already arrive
// punycoded - those are not decoded back for validation.
var domainToASCII = function (domain) {
// every mapped, ignored and disallowed code point is non-ASCII, so an ASCII domain
// needs the punycode step only - this keeps the common case off the slow path
if (!exec(NON_ASCII, domain)) return domain === '' ? null : toASCII(domain);
var codePoints = arrayFrom(domain);
var result = '';
var index, code, mapped;
for (index = 0; index < codePoints.length; index++) {
code = codeAt(codePoints[index], 0);
if (isDisallowedCodePoint(code)) return null;
if (isIgnoredCodePoint(code)) continue;
mapped = mapCodePoint(code);
result += mapped === code ? codePoints[index] : fromCharCode(mapped);
}
return result === '' ? null : toASCII(result);
};
// https://url.spec.whatwg.org/#ends-in-a-number-checker
var endsInNumber = function (input) {
var parts = split(input, '.');
@@ -259,6 +336,9 @@ var serializeHost = function (host) {
return host;
};
// https://url.spec.whatwg.org/#c0-control-percent-encode-set
// empty because the set is the code points outside U+0020..U+007E, which the range
// check below already covers; the sets extending it only add code points inside it
var C0ControlPercentEncodeSet = {};
var queryPercentEncodeSet = assign({}, C0ControlPercentEncodeSet, {
' ': 1, '"': 1, '#': 1, '<': 1, '>': 1
@@ -276,10 +356,11 @@ var userinfoPercentEncodeSet = assign({}, pathPercentEncodeSet, {
'/': 1, ':': 1, ';': 1, '=': 1, '@': 1, '[': 1, '\\': 1, ']': 1, '^': 1, '|': 1
});
var percentEncode = function (chr, set) {
// https://url.spec.whatwg.org/#string-utf-8-percent-encode
var utf8PercentEncode = function (chr, set) {
var code = codeAt(chr, 0);
// encodeURIComponent does not encode ', which is in the special-query percent-encode set
return code >= 0x20 && code < 0x7F && !hasOwn(set, chr) ? chr : chr === "'" && hasOwn(set, chr) ? '%27' : encodeURIComponent(chr);
// percent-encoding leaves ' alone, but it belongs to the special-query percent-encode set
return code >= 0x20 && code < 0x7F && !hasOwn(set, chr) ? chr : chr === "'" && hasOwn(set, chr) ? '%27' : percentEncode(chr);
};
// https://url.spec.whatwg.org/#special-scheme
@@ -551,7 +632,7 @@ URLState.prototype = {
seenPasswordToken = true;
continue;
}
var encodedCodePoints = percentEncode(codePoint, userinfoPercentEncodeSet);
var encodedCodePoints = utf8PercentEncode(codePoint, userinfoPercentEncodeSet);
if (seenPasswordToken) url.password += encodedCodePoints;
else url.username += encodedCodePoints;
}
@@ -739,7 +820,7 @@ URLState.prototype = {
state = FRAGMENT;
}
} else {
buffer += percentEncode(chr, pathPercentEncodeSet);
buffer += utf8PercentEncode(chr, pathPercentEncodeSet);
} break;
case CANNOT_BE_A_BASE_URL_PATH:
@@ -753,7 +834,7 @@ URLState.prototype = {
if (chr === ' ') {
url.path[0] += codePoints[pointer + 1] === '?' || codePoints[pointer + 1] === '#' ? '%20' : ' ';
} else {
url.path[0] += percentEncode(chr, C0ControlPercentEncodeSet);
url.path[0] += utf8PercentEncode(chr, C0ControlPercentEncodeSet);
}
} break;
@@ -762,11 +843,11 @@ URLState.prototype = {
url.fragment = '';
state = FRAGMENT;
} else if (chr !== EOF) {
url.query += percentEncode(chr, url.isSpecial() ? specialQueryPercentEncodeSet : queryPercentEncodeSet);
url.query += utf8PercentEncode(chr, url.isSpecial() ? specialQueryPercentEncodeSet : queryPercentEncodeSet);
} break;
case FRAGMENT:
if (chr !== EOF) url.fragment += percentEncode(chr, fragmentPercentEncodeSet);
if (chr !== EOF) url.fragment += utf8PercentEncode(chr, fragmentPercentEncodeSet);
break;
}
@@ -783,16 +864,16 @@ URLState.prototype = {
this.host = result;
// opaque host
} else if (!this.isSpecial()) {
if (exec(FORBIDDEN_HOST_CODE_POINT_EXCLUDING_PERCENT, input)) return INVALID_HOST;
if (exec(FORBIDDEN_HOST_CODE_POINT, input)) return INVALID_HOST;
result = '';
codePoints = arrayFrom(input);
for (index = 0; index < codePoints.length; index++) {
result += percentEncode(codePoints[index], C0ControlPercentEncodeSet);
result += utf8PercentEncode(codePoints[index], C0ControlPercentEncodeSet);
}
this.host = result;
} else {
input = toASCII(input);
if (exec(FORBIDDEN_HOST_CODE_POINT, input)) return INVALID_HOST;
input = domainToASCII(percentDecode(input));
if (input === null || exec(FORBIDDEN_DOMAIN_CODE_POINT, input)) return INVALID_HOST;
if (endsInNumber(input)) {
result = parseIPv4(input);
if (result === null) return INVALID_HOST;
@@ -882,7 +963,7 @@ URLState.prototype = {
if (this.cannotHaveUsernamePasswordPort()) return;
this.username = '';
for (var i = 0; i < codePoints.length; i++) {
this.username += percentEncode(codePoints[i], userinfoPercentEncodeSet);
this.username += utf8PercentEncode(codePoints[i], userinfoPercentEncodeSet);
}
},
// https://url.spec.whatwg.org/#dom-url-password
@@ -894,7 +975,7 @@ URLState.prototype = {
if (this.cannotHaveUsernamePasswordPort()) return;
this.password = '';
for (var i = 0; i < codePoints.length; i++) {
this.password += percentEncode(codePoints[i], userinfoPercentEncodeSet);
this.password += utf8PercentEncode(codePoints[i], userinfoPercentEncodeSet);
}
},
// https://url.spec.whatwg.org/#dom-url-host
@@ -21,6 +21,13 @@ QUnit.test('URLSearchParams', assert => {
assert.same(String(new URLSearchParams('?a=b')), 'a=b', 'leading ? should be ignored');
assert.same(String(new URLSearchParams('??a=b')), '%3Fa=b');
// a lone surrogate is percent-encoded as U+FFFD, https://encoding.spec.whatwg.org/#utf-8-encoder
assert.same(String(new URLSearchParams([['k', 'a\uD800b']])), 'k=a%EF%BF%BDb', 'lone surrogate in a value');
assert.same(String(new URLSearchParams([['a\uD800b', 'v']])), 'a%EF%BF%BDb=v', 'lone surrogate in a key');
assert.same(String(new URLSearchParams([['k', '\uDC00\uD800']])), 'k=%EF%BF%BD%EF%BF%BD', 'reversed surrogates');
assert.same(String(new URLSearchParams([['k', '\uD83D\uDE00']])), 'k=%F0%9F%98%80', 'surrogate pair is kept');
assert.same(new URLSearchParams('a=%ED%A0%80').get('a'), '\uFFFD\uFFFD\uFFFD', 'percent-encoded surrogate decodes to U+FFFD');
assert.same(String(new URLSearchParams('?')), '');
assert.same(String(new URLSearchParams('??')), '%3F=');
+4
View File
@@ -22,6 +22,10 @@ QUnit.test('URL.canParse', assert => {
assert.true(canParse('x', 'https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment'), 'x, https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment');
assert.throws(() => canParse(), 'no args');
assert.false(canParse('http://a\u0001b.example.com/'), 'forbidden domain code point');
assert.false(canParse('http://a\uFE6Ab.example.com/'), 'code point mapped onto a forbidden domain code point');
assert.true(canParse('http://a\u180Eb.example.com/'), 'ignored code point in a domain');
assert.throws(() => canParse({ toString() { throw new Error('thrower'); } }), 'conversion thrower #1');
assert.throws(() => canParse('q:w', { toString() { throw new Error('thrower'); } }), 'conversion thrower #2');
});
+71
View File
@@ -45,6 +45,72 @@ QUnit.test('URL constructor', assert => {
assert.throws(() => new URL('http://1.2.3.4.5/'), 'IPv4 with > 4 parts');
assert.throws(() => new URL('http://a.b.c.d.5/'), 'host ending in number with non-numeric parts');
assert.throws(() => new URL('http://foo.1/'), 'host ending in number with non-IPv4');
// https://url.spec.whatwg.org/#forbidden-domain-code-point
assert.throws(() => new URL('http://a\u0001b.example.com/'), 'C0 control in a domain');
assert.throws(() => new URL('https://a\u001Fb/'), 'C0 control in a domain');
assert.throws(() => new URL('ws://a\u000Bb/'), 'C0 control in a domain');
// assert.throws(() => new URL('file://a\u000Cb/c'), 'C0 control in a file: domain'); // 'ab' in Chromium and WebKit, '' in FF
assert.throws(() => new URL('http://a\u007Fb.example.com/'), 'DELETE in a domain');
assert.throws(() => new URL('http://a\u0085b.example.com/'), 'C1 control in a domain');
assert.throws(() => new URL('http://a\u202Eb.example.com/'), 'right-to-left override in a domain');
assert.throws(() => new URL('http://a\uFFFDb.example.com/'), 'replacement character in a domain');
assert.throws(() => new URL('http://a\uFDD0b.example.com/'), 'noncharacter in a domain');
assert.throws(() => new URL('http://a\uFFFFb.example.com/'), 'noncharacter in a domain');
assert.throws(() => new URL('http://a\uE000b.example.com/'), 'private use character in a domain');
assert.throws(() => new URL('http://a\uD800b.example.com/'), 'lone surrogate in a domain');
// assert.throws(() => new URL('http://a\u00A0b.example.com/'), 'no-break space in a domain'); // 'a%20b.example.com' in Chromium
// assert.throws(() => new URL('http://a\u3000b.example.com/'), 'ideographic space in a domain'); // 'a%20b.example.com' in Chromium
assert.throws(() => new URL('http://a\uFF03b.example.com/'), 'full-width form of a forbidden domain code point');
assert.throws(() => new URL('http://\u00AD/'), 'domain mapped to the empty string');
// the forbidden host code point set is smaller - an opaque host keeps them percent-encoded
assert.same(new URL('nonspecial://a\u0001b/').hostname, 'a%01b', 'C0 control in an opaque host');
assert.same(new URL('nonspecial://a\u007Fb/').hostname, 'a%7Fb', 'DELETE in an opaque host');
// https://www.unicode.org/reports/tr46/#IDNA_Mapping_Table
assert.same(new URL('http://a\u00ADb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u200Bb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u206Ab.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u180Eb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u115Fb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u17B4b.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u3164b.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\uFFA0b.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://\uFF27\uFF4F.com/').hostname, 'go.com', 'full-width form mapped to ASCII');
assert.same(new URL('http://\uFF10\uFF38\uFF43\uFF10\uFF0E\uFF10\uFF12\uFF15\uFF10\uFF0E\uFF10\uFF11/').hostname, '192.168.0.1', 'full-width form mapped to an IPv4 address');
// a domain is percent-decoded first, https://url.spec.whatwg.org/#concept-host-parser
assert.same(new URL('http://%41.example.com/').hostname, 'a.example.com', 'percent-decoded domain');
assert.same(new URL('http://%D0%B1.com/').hostname, 'xn--90a.com', 'percent-decoded non-ASCII domain');
assert.throws(() => new URL('http://%01.example.com/'), 'percent-decoded forbidden domain code point');
assert.throws(() => new URL('http://a%b'), 'invalid percent sequence in a domain');
assert.same(new URL('http://%31%32%37.0.0.1/').hostname, '127.0.0.1', 'percent-decoded domain parsed as IPv4');
assert.same(new URL('http://127.0.0.%31/').hostname, '127.0.0.1', 'percent-decoded IPv4 last part');
assert.same(new URL('http://%30x7f.0.0.1/').hostname, '127.0.0.1', 'percent-decoded hexadecimal IPv4 part');
assert.throws(() => new URL('http://a%D0/'), 'percent sequence truncated by the end of a domain');
// a code point whose UTS#46 mapping holds a forbidden domain code point is rejected too
assert.throws(() => new URL('http://a\uFE6Ab.example.com/'), 'small percent sign in a domain');
assert.throws(() => new URL('http://a\uFE5Fb.example.com/'), 'small number sign in a domain');
assert.throws(() => new URL('http://a\uFE68b.example.com/'), 'small reverse solidus in a domain');
assert.throws(() => new URL('http://a\u2100b.example.com/'), 'account-of sign in a domain');
assert.throws(() => new URL('http://a\u2A74b.example.com/'), 'double colon equal in a domain');
// assert.throws(() => new URL('http://a\u00A8b.example.com/'), 'diaeresis in a domain'); // 'xn--a%20b-eec' in Chromium
assert.same(new URL('http://a\u180Ab.example.com/').hostname, 'xn--ab-t0o.example.com', 'code point just outside the ignored range');
// the mapping table holds no supplementary code point, and must not be probed with a truncated one
assert.same(new URL('http://a\uD807\uDFC0b.example.com/').hostname, 'xn--ab-ed4p.example.com', 'supplementary code point sharing low units with the table');
assert.same(new URL('http://a\uD808\uDD00b.example.com/').hostname, 'xn--ab-t44p.example.com', 'supplementary code point sharing low units with the table');
// a lone surrogate is percent-encoded as U+FFFD, https://encoding.spec.whatwg.org/#utf-8-encoder
assert.same(new URL('http://example.com/a\uD800b').pathname, '/a%EF%BF%BDb', 'lone surrogate in a path');
assert.same(new URL('http://example.com/?a\uD800b').search, '?a%EF%BF%BDb', 'lone surrogate in a query');
assert.same(new URL('http://example.com/#a\uD800b').hash, '#a%EF%BF%BDb', 'lone surrogate in a fragment');
assert.same(new URL('nonspecial://a\uD800b/').hostname, 'a%EF%BF%BDb', 'lone surrogate in an opaque host');
assert.same(new URL('http://a\[email protected]/').username, 'a%EF%BF%BDb', 'lone surrogate in a username');
assert.same(new URL('http://example.com/\uDC00').pathname, '/%EF%BF%BD', 'trailing lone surrogate');
assert.same(new URL('http://example.com/\uD83D\uDE00').pathname, '/%F0%9F%98%80', 'surrogate pair is kept');
assert.same(String(new URL('file:///var/log/system.log')), 'file:///var/log/system.log', 'file scheme');
// Chromium ~ 145 on Windows works differently
@@ -367,6 +433,11 @@ QUnit.test('URL#host', assert => {
});
QUnit.test('URL#hostname', assert => {
// a value the domain parser rejects leaves the URL untouched
const forbiddenMapped = new URL('http://example.com/p?q#h');
forbiddenMapped.hostname = 'a\uFE6Ab.example.com';
assert.same(forbiddenMapped.href, 'http://example.com/p?q#h', 'hostname setter is a no-op on a rejected domain');
let url = new URL('http://zloirock.ru:81/');
if (DESCRIPTORS) {
+3
View File
@@ -16,6 +16,9 @@ QUnit.test('URL.parse', assert => {
assert.deepEqual(parse('q:/w', undefined), new URL('q:/w', undefined), 'q:/w, undefined');
assert.deepEqual(parse(undefined, 'q:/w'), new URL(undefined, 'q:/w'), 'undefined, q:/w');
assert.same(parse('https://login:password@examp:le.com:8080/?a=1&b=2&a=3&c=4#fragment'), null, 'https://login:password@examp:le.com:8080/?a=1&b=2&a=3&c=4#fragment');
assert.same(parse('http://a\u0001b.example.com/'), null, 'forbidden domain code point');
assert.same(parse('http://a\uFE6Ab.example.com/'), null, 'code point mapped onto a forbidden domain code point');
assert.same(parse('http://a\u180Eb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.deepEqual(parse('https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment'), new URL('https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment'), 'https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment');
assert.deepEqual(parse('https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment', undefined), new URL('https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment', undefined), 'https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment, undefined');
// eslint-disable-next-line unicorn/relative-url-style -- required for testing
+7
View File
@@ -23,6 +23,13 @@ QUnit.test('URLSearchParams', assert => {
assert.same(String(new URLSearchParams('?a=b')), 'a=b', 'leading ? should be ignored');
assert.same(String(new URLSearchParams('??a=b')), '%3Fa=b');
// a lone surrogate is percent-encoded as U+FFFD, https://encoding.spec.whatwg.org/#utf-8-encoder
assert.same(String(new URLSearchParams([['k', 'a\uD800b']])), 'k=a%EF%BF%BDb', 'lone surrogate in a value');
assert.same(String(new URLSearchParams([['a\uD800b', 'v']])), 'a%EF%BF%BDb=v', 'lone surrogate in a key');
assert.same(String(new URLSearchParams([['k', '\uDC00\uD800']])), 'k=%EF%BF%BD%EF%BF%BD', 'reversed surrogates');
assert.same(String(new URLSearchParams([['k', '\uD83D\uDE00']])), 'k=%F0%9F%98%80', 'surrogate pair is kept');
assert.same(new URLSearchParams('a=%ED%A0%80').get('a'), '\uFFFD\uFFFD\uFFFD', 'percent-encoded surrogate decodes to U+FFFD');
assert.same(String(new URLSearchParams('?')), '');
assert.same(String(new URLSearchParams('??')), '%3F=');
+4
View File
@@ -19,6 +19,10 @@ QUnit.test('URL.canParse', assert => {
assert.true(canParse('x', 'https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment'), 'x, https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment');
assert.throws(() => canParse(), 'no args');
assert.false(canParse('http://a\u0001b.example.com/'), 'forbidden domain code point');
assert.false(canParse('http://a\uFE6Ab.example.com/'), 'code point mapped onto a forbidden domain code point');
assert.true(canParse('http://a\u180Eb.example.com/'), 'ignored code point in a domain');
assert.throws(() => canParse({ toString() { throw new Error('thrower'); } }), 'conversion thrower #1');
assert.throws(() => canParse('q:w', { toString() { throw new Error('thrower'); } }), 'conversion thrower #2');
});
+71
View File
@@ -46,6 +46,72 @@ QUnit.test('URL constructor', assert => {
assert.throws(() => new URL('http://1.2.3.4.5/'), 'IPv4 with > 4 parts');
assert.throws(() => new URL('http://a.b.c.d.5/'), 'host ending in number with non-numeric parts');
assert.throws(() => new URL('http://foo.1/'), 'host ending in number with non-IPv4');
// https://url.spec.whatwg.org/#forbidden-domain-code-point
assert.throws(() => new URL('http://a\u0001b.example.com/'), 'C0 control in a domain');
assert.throws(() => new URL('https://a\u001Fb/'), 'C0 control in a domain');
assert.throws(() => new URL('ws://a\u000Bb/'), 'C0 control in a domain');
// assert.throws(() => new URL('file://a\u000Cb/c'), 'C0 control in a file: domain'); // 'ab' in Chromium and WebKit, '' in FF
assert.throws(() => new URL('http://a\u007Fb.example.com/'), 'DELETE in a domain');
assert.throws(() => new URL('http://a\u0085b.example.com/'), 'C1 control in a domain');
assert.throws(() => new URL('http://a\u202Eb.example.com/'), 'right-to-left override in a domain');
assert.throws(() => new URL('http://a\uFFFDb.example.com/'), 'replacement character in a domain');
assert.throws(() => new URL('http://a\uFDD0b.example.com/'), 'noncharacter in a domain');
assert.throws(() => new URL('http://a\uFFFFb.example.com/'), 'noncharacter in a domain');
assert.throws(() => new URL('http://a\uE000b.example.com/'), 'private use character in a domain');
assert.throws(() => new URL('http://a\uD800b.example.com/'), 'lone surrogate in a domain');
// assert.throws(() => new URL('http://a\u00A0b.example.com/'), 'no-break space in a domain'); // 'a%20b.example.com' in Chromium
// assert.throws(() => new URL('http://a\u3000b.example.com/'), 'ideographic space in a domain'); // 'a%20b.example.com' in Chromium
assert.throws(() => new URL('http://a\uFF03b.example.com/'), 'full-width form of a forbidden domain code point');
assert.throws(() => new URL('http://\u00AD/'), 'domain mapped to the empty string');
// the forbidden host code point set is smaller - an opaque host keeps them percent-encoded
assert.same(new URL('nonspecial://a\u0001b/').hostname, 'a%01b', 'C0 control in an opaque host');
assert.same(new URL('nonspecial://a\u007Fb/').hostname, 'a%7Fb', 'DELETE in an opaque host');
// https://www.unicode.org/reports/tr46/#IDNA_Mapping_Table
assert.same(new URL('http://a\u00ADb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u200Bb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u206Ab.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u180Eb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u115Fb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u17B4b.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\u3164b.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://a\uFFA0b.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.same(new URL('http://\uFF27\uFF4F.com/').hostname, 'go.com', 'full-width form mapped to ASCII');
assert.same(new URL('http://\uFF10\uFF38\uFF43\uFF10\uFF0E\uFF10\uFF12\uFF15\uFF10\uFF0E\uFF10\uFF11/').hostname, '192.168.0.1', 'full-width form mapped to an IPv4 address');
// a domain is percent-decoded first, https://url.spec.whatwg.org/#concept-host-parser
assert.same(new URL('http://%41.example.com/').hostname, 'a.example.com', 'percent-decoded domain');
assert.same(new URL('http://%D0%B1.com/').hostname, 'xn--90a.com', 'percent-decoded non-ASCII domain');
assert.throws(() => new URL('http://%01.example.com/'), 'percent-decoded forbidden domain code point');
assert.throws(() => new URL('http://a%b'), 'invalid percent sequence in a domain');
assert.same(new URL('http://%31%32%37.0.0.1/').hostname, '127.0.0.1', 'percent-decoded domain parsed as IPv4');
assert.same(new URL('http://127.0.0.%31/').hostname, '127.0.0.1', 'percent-decoded IPv4 last part');
assert.same(new URL('http://%30x7f.0.0.1/').hostname, '127.0.0.1', 'percent-decoded hexadecimal IPv4 part');
assert.throws(() => new URL('http://a%D0/'), 'percent sequence truncated by the end of a domain');
// a code point whose UTS#46 mapping holds a forbidden domain code point is rejected too
assert.throws(() => new URL('http://a\uFE6Ab.example.com/'), 'small percent sign in a domain');
assert.throws(() => new URL('http://a\uFE5Fb.example.com/'), 'small number sign in a domain');
assert.throws(() => new URL('http://a\uFE68b.example.com/'), 'small reverse solidus in a domain');
assert.throws(() => new URL('http://a\u2100b.example.com/'), 'account-of sign in a domain');
assert.throws(() => new URL('http://a\u2A74b.example.com/'), 'double colon equal in a domain');
// assert.throws(() => new URL('http://a\u00A8b.example.com/'), 'diaeresis in a domain'); // 'xn--a%20b-eec' in Chromium
assert.same(new URL('http://a\u180Ab.example.com/').hostname, 'xn--ab-t0o.example.com', 'code point just outside the ignored range');
// the mapping table holds no supplementary code point, and must not be probed with a truncated one
assert.same(new URL('http://a\uD807\uDFC0b.example.com/').hostname, 'xn--ab-ed4p.example.com', 'supplementary code point sharing low units with the table');
assert.same(new URL('http://a\uD808\uDD00b.example.com/').hostname, 'xn--ab-t44p.example.com', 'supplementary code point sharing low units with the table');
// a lone surrogate is percent-encoded as U+FFFD, https://encoding.spec.whatwg.org/#utf-8-encoder
assert.same(new URL('http://example.com/a\uD800b').pathname, '/a%EF%BF%BDb', 'lone surrogate in a path');
assert.same(new URL('http://example.com/?a\uD800b').search, '?a%EF%BF%BDb', 'lone surrogate in a query');
assert.same(new URL('http://example.com/#a\uD800b').hash, '#a%EF%BF%BDb', 'lone surrogate in a fragment');
assert.same(new URL('nonspecial://a\uD800b/').hostname, 'a%EF%BF%BDb', 'lone surrogate in an opaque host');
assert.same(new URL('http://a\[email protected]/').username, 'a%EF%BF%BDb', 'lone surrogate in a username');
assert.same(new URL('http://example.com/\uDC00').pathname, '/%EF%BF%BD', 'trailing lone surrogate');
assert.same(new URL('http://example.com/\uD83D\uDE00').pathname, '/%F0%9F%98%80', 'surrogate pair is kept');
assert.same(String(new URL('file:///var/log/system.log')), 'file:///var/log/system.log', 'file scheme');
// Chromium ~ 145 on Windows works differently
@@ -368,6 +434,11 @@ QUnit.test('URL#host', assert => {
});
QUnit.test('URL#hostname', assert => {
// a value the domain parser rejects leaves the URL untouched
const forbiddenMapped = new URL('http://example.com/p?q#h');
forbiddenMapped.hostname = 'a\uFE6Ab.example.com';
assert.same(forbiddenMapped.href, 'http://example.com/p?q#h', 'hostname setter is a no-op on a rejected domain');
let url = new URL('http://zloirock.ru:81/');
if (DESCRIPTORS) {
+3
View File
@@ -14,6 +14,9 @@ QUnit.test('URL.parse', assert => {
assert.deepEqual(parse('q:/w', undefined), new URL('q:/w', undefined), 'q:/w, undefined');
assert.deepEqual(parse(undefined, 'q:/w'), new URL(undefined, 'q:/w'), 'undefined, q:/w');
assert.same(parse('https://login:password@examp:le.com:8080/?a=1&b=2&a=3&c=4#fragment'), null, 'https://login:password@examp:le.com:8080/?a=1&b=2&a=3&c=4#fragment');
assert.same(parse('http://a\u0001b.example.com/'), null, 'forbidden domain code point');
assert.same(parse('http://a\uFE6Ab.example.com/'), null, 'code point mapped onto a forbidden domain code point');
assert.same(parse('http://a\u180Eb.example.com/').hostname, 'ab.example.com', 'ignored code point in a domain');
assert.deepEqual(parse('https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment'), new URL('https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment'), 'https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment');
assert.deepEqual(parse('https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment', undefined), new URL('https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment', undefined), 'https://login:[email protected]:8080/?a=1&b=2&a=3&c=4#fragment, undefined');
// eslint-disable-next-line unicorn/relative-url-style -- required for testing