From c4c6fcf6cb9b54a172d3982e924aaa417db0e604 Mon Sep 17 00:00:00 2001 From: Russell Seligmann Date: Wed, 21 Oct 2026 13:52:31 -0500 Subject: [PATCH] Fix quadratic malformed-run replacement --- index.js | 46 ++++++++++------------------------------------ test.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 36 deletions(-) diff --git a/index.js b/index.js index 501f80d..1a3bd03 100644 --- a/index.js +++ b/index.js @@ -79,6 +79,12 @@ function decode(input) { // Continuation byte or invalid lead byte — emit one `%XX` literally. if (sequenceLength === 0) { + if (input.startsWith('%FE%FF', position) || input.startsWith('%FF%FE', position)) { + output += '\uFFFD\uFFFD'; + position += 6; + continue; + } + output += input.slice(position, position + 3); position += 3; continue; @@ -111,7 +117,8 @@ function decode(input) { } // Missing continuation bytes or decode failure — emit the first byte literally. - output += input.slice(position, position + 3); + const literalByte = input.slice(position, position + 3); + output += literalByte === '%C2' ? '\uFFFD' : literalByte; position += 3; } @@ -120,41 +127,8 @@ function decode(input) { } function customDecodeURIComponent(input) { - // Keep track of all the replacements and prefill the map with the `BOM` - const replaceMap = { - '%FE%FF': '\uFFFD\uFFFD', - '%FF%FE': '\uFFFD\uFFFD', - }; - - // Find percent-encoded runs separated by literal text or lone `%` characters. - let match = multiMatcher.exec(input); - - while (match) { - try { - // Decode as big chunks as possible - replaceMap[match[0]] = decodeURIComponent(match[0]); - } catch { - const result = decode(match[0]); - - if (result !== match[0]) { - replaceMap[match[0]] = result; - } - } - - match = multiMatcher.exec(input); - } - - // Add `%C2` at the end of the map to make sure it does not replace the combinator before everything else - replaceMap['%C2'] = '\uFFFD'; - - const entries = Object.keys(replaceMap); - - for (const key of entries) { - // Replace all decoded components - input = input.replace(new RegExp(key, 'g'), replaceMap[key]); - } - - return input; + // Decode each original run once. A callback inserts decoded dollar signs and percent signs literally. + return input.replace(multiMatcher, decode); } export default function decodeUriComponent(encodedURI) { diff --git a/test.js b/test.js index c4dcaeb..dbbbaba 100644 --- a/test.js +++ b/test.js @@ -1,6 +1,44 @@ +import {execFileSync} from 'node:child_process'; +import process from 'node:process'; import test from 'ava'; import m from './index.js'; +test('inserts decoded text literally without another decoding pass', t => { + const cases = { + '%24%26-%ab': '$&-%ab', + '%24%60-%ab': '$`-%ab', + '%24%27-%ab': '$\'-%ab', + '%24%24-%ab': '$$-%ab', + '%25%34%31-%41-%ab': '%41-A-%ab', + '%25%43%32-%C2': '%C2-\uFFFD', + '%25%46%45%25%46%46-%FE%FF': '%FE%FF-\uFFFD\uFFFD', + '%FE%FF%C2': '\uFFFD\uFFFD\uFFFD', + '%FF%FE%41%C2': '\uFFFD\uFFFDA\uFFFD', + '%fe%ff%C2': '%fe%ff\uFFFD', + }; + + for (const [input, expected] of Object.entries(cases)) { + t.is(m(input), expected); + } +}); + +test('bounds malformed input with many distinct valid runs', t => { + const result = execFileSync(process.execPath, [ + '--input-type=module', + '-e', + `const {default: decode} = await import(process.argv[1]); +const words = Array.from({length: 60000}, (_, index) => 'v' + index); +const encoded = words.map(word => Array.from(word, character => '%' + character.charCodeAt(0).toString(16)).join('')).join('-'); +if (decode(encoded + '-%ab') !== words.join('-') + '-%ab') process.exit(1); +if (decode('%ab'.repeat(20000) + '%41') !== '%ab'.repeat(20000) + 'A') process.exit(1); +if (decode('%41'.repeat(20000) + '%') !== 'A'.repeat(20000) + '%') process.exit(1); +process.stdout.write('decoded');`, + new URL('index.js', import.meta.url).href, + ], {encoding: 'utf8', timeout: 3000, maxBuffer: 1024}); + + t.is(result, 'decoded'); +}); + const tests = { test: 'test', 'a+b': 'a+b',