Skip to content

Commit fa479da

Browse files
Rewrite decoder as single-pass UTF-8 scanner and expand tests
Fixes CVE-2026-45822.
1 parent 38bdbec commit fa479da

2 files changed

Lines changed: 362 additions & 23 deletions

File tree

‎index.js‎

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,121 @@
1+
// Matches one or more consecutive percent-encoded bytes (e.g. `%C3%A5`).
12
const token = '%[a-f0-9]{2}';
2-
const singleMatcher = new RegExp('(' + token + ')|([^%]+?)', 'gi');
3-
const multiMatcher = new RegExp('(' + token + ')+', 'gi');
3+
const multiMatcher = new RegExp(`(${token})+`, 'gi');
44

5-
function decodeComponents(components, split) {
6-
try {
7-
// Try to decode the entire string first
8-
return [decodeURIComponent(components.join(''))];
9-
} catch {
10-
// Do nothing
5+
const hexPair = /^[a-f\d]{2}$/i;
6+
7+
// Read a `%XX` sequence at `position`, returning the byte value and where to continue scanning.
8+
function parsePercentByte(input, position) {
9+
if (input.codePointAt(position) !== 37 || position + 3 > input.length) {
10+
return;
1111
}
1212

13-
if (components.length === 1) {
14-
return components;
13+
const digits = input.slice(position + 1, position + 3);
14+
15+
if (!hexPair.test(digits)) {
16+
return;
1517
}
1618

17-
split = split || 1;
19+
return {byte: Number.parseInt(digits, 16), next: position + 3};
20+
}
21+
22+
/**
23+
* Return how many bytes a UTF-8 code point needs based on its lead byte.
24+
* Returns 0 for continuation bytes and other invalid lead bytes.
25+
*/
26+
function utf8SequenceLength(byte) {
27+
if (byte <= 0x7F) {
28+
return 1;
29+
}
1830

19-
// Split the array in 2 parts
20-
const left = components.slice(0, split);
21-
const right = components.slice(split);
31+
// Start at 0xC2 to exclude overlong 2-byte encodings (0xC0/0xC1).
32+
if (byte >= 0xC2 && byte <= 0xDF) {
33+
return 2;
34+
}
2235

23-
return Array.prototype.concat.call([], decodeComponents(left), decodeComponents(right));
36+
if (byte >= 0xE0 && byte <= 0xEF) {
37+
return 3;
38+
}
39+
40+
if (byte >= 0xF0 && byte <= 0xF4) {
41+
return 4;
42+
}
43+
44+
return 0;
2445
}
2546

47+
function isContinuationByte(byte) {
48+
return byte >= 0x80 && byte <= 0xBF;
49+
}
50+
51+
/**
52+
* Decode as much of `input` as possible without throwing.
53+
*Scans left-to-right in O(n), decoding valid UTF-8 runs and leaving the rest literal.
54+
*/
2655
function decode(input) {
2756
try {
2857
return decodeURIComponent(input);
2958
} catch {
30-
let tokens = input.match(singleMatcher) || [];
59+
let output = '';
60+
let position = 0;
61+
62+
while (position < input.length) {
63+
if (input.codePointAt(position) !== 37) {
64+
output += input.charAt(position);
65+
position++;
66+
continue;
67+
}
3168

32-
for (let i = 1; i < tokens.length; i++) {
33-
input = decodeComponents(tokens, i).join('');
69+
const firstByte = parsePercentByte(input, position);
3470

35-
tokens = input.match(singleMatcher) || [];
71+
// `%` not followed by two hex digits (e.g. `%` or `%G`).
72+
if (!firstByte) {
73+
output += input.charAt(position);
74+
position++;
75+
continue;
76+
}
77+
78+
const sequenceLength = utf8SequenceLength(firstByte.byte);
79+
80+
// Continuation byte or invalid lead byte — emit one `%XX` literally.
81+
if (sequenceLength === 0) {
82+
output += input.slice(position, position + 3);
83+
position += 3;
84+
continue;
85+
}
86+
87+
let end = firstByte.next;
88+
let validSequence = true;
89+
90+
for (let index = 1; index < sequenceLength; index++) {
91+
const nextByte = parsePercentByte(input, end);
92+
93+
if (!nextByte || !isContinuationByte(nextByte.byte)) {
94+
validSequence = false;
95+
break;
96+
}
97+
98+
end = nextByte.next;
99+
}
100+
101+
if (validSequence) {
102+
const encodedSequence = input.slice(position, end);
103+
104+
try {
105+
output += decodeURIComponent(encodedSequence);
106+
position = end;
107+
continue;
108+
} catch {
109+
// Invalid UTF-8 despite correct structure — emit the first byte literally.
110+
}
111+
}
112+
113+
// Missing continuation bytes or decode failure — emit the first byte literally.
114+
output += input.slice(position, position + 3);
115+
position += 3;
36116
}
37117

38-
return input;
118+
return output;
39119
}
40120
}
41121

@@ -46,7 +126,9 @@ function customDecodeURIComponent(input) {
46126
'%FF%FE': '\uFFFD\uFFFD',
47127
};
48128

129+
// Find percent-encoded runs separated by literal text or lone `%` characters.
49130
let match = multiMatcher.exec(input);
131+
50132
while (match) {
51133
try {
52134
// Decode as big chunks as possible
@@ -77,7 +159,7 @@ function customDecodeURIComponent(input) {
77159

78160
export default function decodeUriComponent(encodedURI) {
79161
if (typeof encodedURI !== 'string') {
80-
throw new TypeError('Expected `encodedURI` to be of type `string`, got `' + typeof encodedURI + '`');
162+
throw new TypeError(`Expected \`encodedURI\` to be of type \`string\`, got \`${typeof encodedURI}\``);
81163
}
82164

83165
try {

0 commit comments

Comments
 (0)