From b3259a3f2c5338a3078a0f227b03411fd8afc12c Mon Sep 17 00:00:00 2001 From: Roberto Bianchi Date: Tue, 13 Oct 2026 07:56:08 +0200 Subject: [PATCH 1/2] url: skip plain prefixes in percent-escape checks decodeFormComponent() already finds the first percent character, but hasPercentHex() scans the plain prefix again. Start the check at the known position to avoid repeating that work. Replacing plus signs with spaces preserves character positions, so escape validation and decoding behavior remain unchanged. Signed-off-by: Roberto Bianchi --- .../url/url-searchparams-percent-scan.js | 35 +++++++++++++++++++ lib/internal/url.js | 6 ++-- ...st-whatwg-url-searchparams-percent-scan.js | 31 ++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 benchmark/url/url-searchparams-percent-scan.js create mode 100644 test/parallel/test-whatwg-url-searchparams-percent-scan.js diff --git a/benchmark/url/url-searchparams-percent-scan.js b/benchmark/url/url-searchparams-percent-scan.js new file mode 100644 index 000000000000..03a6abe2a85f --- /dev/null +++ b/benchmark/url/url-searchparams-percent-scan.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('../common.js'); +const assert = require('node:assert'); + +const bench = common.createBenchmark(main, { + n: [1e5], + length: [32, 1024], + position: ['start', 'end'], + encoding: ['valid', 'invalid', 'unicode', 'plain'], +}); + +function main({ n, length, position, encoding }) { + const prefix = 'a'.repeat(length); + const [encoded, decoded] = { + valid: ['%20', ' '], + invalid: ['%zz', '%zz'], + unicode: ['%E2%82%AC', '\u20ac'], + plain: ['text', 'text'], + }[encoding]; + const input = position === 'start' ? encoded + prefix : prefix + encoded; + const expected = position === 'start' ? decoded + prefix : prefix + decoded; + const query = `key=${input}`; + for (let index = 0; index < 1000; index++) { + new URLSearchParams(query); + } + + let result; + bench.start(); + for (let index = 0; index < n; index++) { + result = new URLSearchParams(query); + } + bench.end(n); + assert.deepStrictEqual([...result], [['key', expected]]); +} diff --git a/lib/internal/url.js b/lib/internal/url.js index 471df4db439e..da5921ff4f14 100644 --- a/lib/internal/url.js +++ b/lib/internal/url.js @@ -1322,9 +1322,9 @@ function unescapeFormComponent(s) { } } -function hasPercentHex(s) { +function hasPercentHex(s, start) { const end = s.length - 2; - for (let i = 0; i < end; i++) { + for (let i = start; i < end; i++) { if (StringPrototypeCharCodeAt(s, i) === 37 && // '%' isHexTable[StringPrototypeCharCodeAt(s, i + 1)] === 1 && isHexTable[StringPrototypeCharCodeAt(s, i + 2)] === 1) { @@ -1348,7 +1348,7 @@ function decodeFormComponent(qs, start, end) { // Only percent-decode when a complete %HH sequence exists. A lone '%' or // a '%' followed by a non-hex character must be left intact so later // serialization can encode the raw bytes. - if (pct === -1 || !hasPercentHex(replaced)) { + if (pct === -1 || !hasPercentHex(replaced, pct)) { return replaced; } return unescapeFormComponent(replaced); diff --git a/test/parallel/test-whatwg-url-searchparams-percent-scan.js b/test/parallel/test-whatwg-url-searchparams-percent-scan.js new file mode 100644 index 000000000000..9b8b2d8c0851 --- /dev/null +++ b/test/parallel/test-whatwg-url-searchparams-percent-scan.js @@ -0,0 +1,31 @@ +'use strict'; + +require('../common'); +const assert = require('node:assert'); + +const cases = [ + ['%41', 'A'], + ['%zz', '%zz'], + ['%zz%41', '%zzA'], + ['%', '%'], + ['%A', '%A'], + ['%C3%A9', '\u00e9'], + ['%F0%9F%98%80', '\ud83d\ude00'], + ['%FF', '\ufffd'], + ['%E2%82', '\ufffd'], +]; + +for (const prefix of ['', 'k'.repeat(32), 'k'.repeat(1024), 'k+'.repeat(64)]) { + const decodedPrefix = prefix.replaceAll('+', ' '); + for (const [encoded, decoded] of cases) { + for (const position of ['start', 'end']) { + const input = position === 'start' ? encoded + prefix : prefix + encoded; + const expected = position === 'start' ? + decoded + decodedPrefix : decodedPrefix + decoded; + const query = `${input}=${input}`; + assert.deepStrictEqual([...new URLSearchParams(query)], [[expected, expected]]); + const url = new URL(`https://example.org/?${query}`); + assert.deepStrictEqual([...url.searchParams], [[expected, expected]]); + } + } +} From 60030ebc169226ecc7ca853349228518b9e8596f Mon Sep 17 00:00:00 2001 From: Roberto Bianchi Date: Wed, 14 Oct 2026 18:31:10 +0200 Subject: [PATCH 2/2] Update lib/internal/url.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gürgün Dayıoğlu --- lib/internal/url.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/internal/url.js b/lib/internal/url.js index da5921ff4f14..86bd6ce5c221 100644 --- a/lib/internal/url.js +++ b/lib/internal/url.js @@ -1324,9 +1324,9 @@ function unescapeFormComponent(s) { function hasPercentHex(s, start) { const end = s.length - 2; - for (let i = start; i < end; i++) { - if (StringPrototypeCharCodeAt(s, i) === 37 && // '%' - isHexTable[StringPrototypeCharCodeAt(s, i + 1)] === 1 && + for (let i = start; i !== -1 && i < end; + i = StringPrototypeIndexOf(s, '%', i + 1)) { + if (isHexTable[StringPrototypeCharCodeAt(s, i + 1)] === 1 && isHexTable[StringPrototypeCharCodeAt(s, i + 2)] === 1) { return true; }