Commit f59539b5143 for nodejs
commit f59539b514344ed0a0945f8790d61121df88a5d4
Author: Roberto Bianchi <rbianchidev@gmail.com>
Date: Fri Oct 16 18:59:40 2026 +0200
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 <roberto.bianchi@spendesk.com>
PR-URL: https://github.com/nodejs/node/pull/66696
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
diff --git a/benchmark/url/url-searchparams-percent-scan.js b/benchmark/url/url-searchparams-percent-scan.js
new file mode 100644
index 00000000000..03a6abe2a85
--- /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 c97295d4d69..8c04ac70c07 100644
--- a/lib/internal/url.js
+++ b/lib/internal/url.js
@@ -1321,11 +1321,11 @@ function unescapeFormComponent(s) {
}
}
-function hasPercentHex(s) {
+function hasPercentHex(s, start) {
const end = s.length - 2;
- for (let i = 0; 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;
}
@@ -1347,7 +1347,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 00000000000..9b8b2d8c085
--- /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]]);
+ }
+ }
+}