Skip to content

Commit 77c5bcd

Browse files
committed
src: fix TextDecoder large-input and error paths
ConverterObject::Decode() sized its ICU target buffer as the input length, or the pending byte count when flushing if that is larger, times min_char_size(), times 2. min_char_size() is the minimum number of bytes per character, so multiplying by it inflates the bound instead of tightening it: for UTF-16 (min_char_size() == 2) a 256 MiB input requested 2^30 UChars, which fails ucnv_toUnicode()'s internal targetLimit validation before any input is examined, and the failure was then reported as ERR_ENCODING_INVALID_ENCODED_DATA. Bound the buffer by 2 * (input length + pending bytes) / min_char_size instead: each character consumes at least min_char_size bytes and emits at most one surrogate pair, and bytes carried over from previous chunks complete a character in this one. The request is also clamped to String::kMaxLength + 1 UChars, one extra for a leading BOM that the success path strips: a result that overflows the clamped buffer cannot become a V8 string even after the strip, so it is reported as ERR_STRING_TOO_LONG, the same error StringBytes::Encode() throws for oversized results. This decodes every input whose result fits in a V8 string; inputs above ICU's 2 GiB single-call source limit keep their existing error behaviour. Also return after a failed StringBytes::Encode() instead of falling through, so the exception it scheduled (such as ERR_STRING_TOO_LONG for results beyond the string limit) is no longer masked by ERR_ENCODING_INVALID_ENCODED_DATA. The `2 *` factor dates to 98ec909, which restored the effective capacity that an earlier targetLimit arithmetic bug had provided by accident. The min_char_size() multiplier itself is older, from ed21cb1. Fixes: #47645 Refs: #41026 Refs: #61559 Signed-off-by: Yusufhan Saçak <yusufhansacak@icloud.com>
1 parent e7a6370 commit 77c5bcd

3 files changed

Lines changed: 193 additions & 12 deletions

File tree

‎src/node_i18n.cc‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
#include <unicode/utypes.h>
6767
#include <unicode/uvernum.h>
6868
#include <unicode/uversion.h>
69+
#include <algorithm>
6970
#include "nbytes.h"
7071

7172
#ifdef NODE_HAVE_SMALL_ICU
@@ -446,18 +447,30 @@ void ConverterObject::Decode(const FunctionCallbackInfo<Value>& args) {
446447

447448
UBool flush = (flags & CONVERTER_FLAGS_FLUSH) == CONVERTER_FLAGS_FLUSH;
448449

449-
// When flushing the final chunk, the limit is the maximum
450-
// of either the input buffer length or the number of pending
451-
// characters times the min char size, multiplied by 2 as unicode may
452-
// take up to 2 UChars to encode a character
453-
size_t limit = 2 * converter->min_char_size() *
454-
(!flush ?
455-
input.length() :
456-
std::max(
457-
input.length(),
458-
static_cast<size_t>(
459-
ucnv_toUCountPending(converter->conv(), &status))));
450+
// The result has to be materialised as a V8 string, which holds at most
451+
// String::kMaxLength UChars; StringBytes::Encode() rejects anything
452+
// longer. One extra UChar leaves room for a leading BOM, which the
453+
// success path below strips before the string is created.
454+
constexpr size_t kMaxTargetUChars = String::kMaxLength + 1;
455+
456+
// Each character consumes at least min_char_size() bytes and produces
457+
// at most 2 UChars (a surrogate pair). ICU's data format allows longer
458+
// per-character outputs, but no converter reachable through
459+
// TextDecoder ships one: lib/internal/encoding.js handles UTF-8 and
460+
// the single-byte encodings in JS, and the UTF-16 and CJK multibyte
461+
// converters that reach this path all emit at most one UChar per input
462+
// byte. Were that ever to change, ucnv_toUnicode() reports
463+
// U_BUFFER_OVERFLOW_ERROR rather than overrunning the target. Count
464+
// the bytes the converter is still holding from previous chunks too:
465+
// they belong to a character whose remaining bytes may arrive in this
466+
// chunk. Clamping loses nothing: a result that does not fit the
467+
// clamped buffer cannot become a string either way.
468+
int32_t pending = ucnv_toUCountPending(converter->conv(), &status);
460469
status = U_ZERO_ERROR;
470+
size_t limit = std::min(
471+
2 * (input.length() + (pending > 0 ? static_cast<size_t>(pending) : 0)) /
472+
converter->min_char_size(),
473+
kMaxTargetUChars);
461474

462475
if (limit > 0)
463476
result.AllocateSufficientStorage(limit);
@@ -519,8 +532,19 @@ void ConverterObject::Decode(const FunctionCallbackInfo<Value>& args) {
519532
if (StringBytes::Encode(env->isolate(), value, length, UCS2)
520533
.ToLocal(&ret)) {
521534
args.GetReturnValue().Set(ret);
522-
return;
523535
}
536+
// If Encode() failed, it has already scheduled an exception; do not
537+
// replace it with ERR_ENCODING_INVALID_ENCODED_DATA below.
538+
return;
539+
}
540+
541+
if (status == U_BUFFER_OVERFLOW_ERROR) {
542+
// The result did not fit the clamped target buffer, so it cannot fit
543+
// a V8 string even after a leading BOM is stripped. Surface the same
544+
// error Encode() throws for oversized results instead of mislabelling
545+
// the input as invalid.
546+
env->isolate()->ThrowException(ERR_STRING_TOO_LONG(env->isolate()));
547+
return;
524548
}
525549

526550
node::THROW_ERR_ENCODING_INVALID_ENCODED_DATA(
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use strict';
2+
const common = require('../common');
3+
4+
// Input large enough that the old 4x target bound exceeded ICU's
5+
// 0x3fffffff UChar limit; also needs more than a 32-bit heap.
6+
common.skipIf32Bits();
7+
8+
if (!common.hasIntl)
9+
common.skip('missing Intl');
10+
11+
// Peak RSS is around 1.6 GiB: the input, the ICU target buffer, and two
12+
// result strings.
13+
if (require('os').totalmem() < 8 * 2 ** 30)
14+
common.skip('less than 8 GiB of total memory');
15+
16+
const assert = require('assert');
17+
18+
const size = 2 ** 27;
19+
20+
let input;
21+
22+
try {
23+
input = Buffer.allocUnsafe(size * 2);
24+
} catch (e) {
25+
if (
26+
e.code === 'ERR_MEMORY_ALLOCATION_FAILED' ||
27+
/Array buffer allocation failed/.test(e.message)
28+
) {
29+
common.skip('insufficient space for Buffer.allocUnsafe');
30+
}
31+
32+
throw e;
33+
}
34+
35+
// Non-uniform repeating pattern of A, a U+1F600 surrogate pair and 中,
36+
// written as explicit little-endian bytes so the input is identical on
37+
// big-endian hosts. Corrupted or misplaced output cannot match it.
38+
input.fill(Buffer.from([0x41, 0x00, 0x3D, 0xD8, 0x00, 0xDE, 0x2D, 0x4E]));
39+
40+
const decoder = new TextDecoder('utf-16le');
41+
42+
// 2 ** 27 UTF-16 code units used to fail with
43+
// ERR_ENCODING_INVALID_ENCODED_DATA because the target buffer request
44+
// exceeded ICU's internal targetLimit validation.
45+
// Refs: https://github.com/nodejs/node/issues/47645
46+
const result = decoder.decode(input);
47+
assert.strictEqual(result.length, size);
48+
assert.strictEqual(result[0], 'A');
49+
assert.strictEqual(result[1], '\uD83D');
50+
assert.strictEqual(result[2], '\uDE00');
51+
assert.strictEqual(result[size / 2], 'A');
52+
assert.strictEqual(result[size - 1], '中');
53+
54+
// Guard against over-correction: one code unit below the failure boundary
55+
// decodes at HEAD too and must keep doing so. The truncation removes the
56+
// trailing 中, so it does not split a surrogate pair.
57+
assert.strictEqual(decoder.decode(input.subarray(0, size * 2 - 2)).length,
58+
size - 1);
59+
60+
// Streaming with an odd byte split lands mid-code-unit, so one byte stays
61+
// pending in the converter across the chunk boundary. The full content is
62+
// compared against the non-streaming result, so any corruption at the
63+
// boundary fails the test.
64+
const split = 2 ** 26 + 1;
65+
const streamed = decoder.decode(input.subarray(0, split), { stream: true }) +
66+
decoder.decode(input.subarray(split));
67+
assert.strictEqual(streamed.length, result.length);
68+
assert.strictEqual(streamed, result);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
'use strict';
2+
const common = require('../common');
3+
4+
// The working set peaks around 4 GiB, far beyond a 32-bit heap.
5+
common.skipIf32Bits();
6+
7+
if (!common.hasIntl)
8+
common.skip('missing Intl');
9+
10+
// Peak RSS is around 4 GiB in the BOM case: a 1 GiB input, an ICU target
11+
// buffer of String::kMaxLength + 1 UChars (about 1 GiB), and the decoded
12+
// string with its transient copy.
13+
if (require('os').totalmem() < 8 * 2 ** 30)
14+
common.skip('less than 8 GiB of total memory');
15+
16+
const assert = require('assert');
17+
const kStringMaxLength = require('buffer').constants.MAX_STRING_LENGTH;
18+
19+
function allocOrSkip(bytes) {
20+
try {
21+
return Buffer.allocUnsafe(bytes);
22+
} catch (e) {
23+
if (
24+
e.code === 'ERR_MEMORY_ALLOCATION_FAILED' ||
25+
/Array buffer allocation failed/.test(e.message)
26+
) {
27+
common.skip('insufficient space for Buffer.allocUnsafe');
28+
}
29+
30+
throw e;
31+
}
32+
}
33+
34+
function assertThrowsTooLong(fn) {
35+
assert.throws(fn, (e) => {
36+
assert.strictEqual(e.code, 'ERR_STRING_TOO_LONG');
37+
return true;
38+
});
39+
}
40+
41+
{
42+
// One UTF-16 code unit beyond the maximum string length. The target
43+
// buffer is clamped to kStringMaxLength + 1 UChars, so the result fits
44+
// the buffer exactly, there is no leading BOM to strip, and
45+
// StringBytes::Encode() rejects the oversized result. That must surface
46+
// as ERR_STRING_TOO_LONG rather than ERR_ENCODING_INVALID_ENCODED_DATA.
47+
const size = 2 * kStringMaxLength + 2;
48+
const input = allocOrSkip(size);
49+
input.fill(0x20);
50+
assertThrowsTooLong(() => new TextDecoder('utf-16le').decode(input));
51+
}
52+
53+
{
54+
// A BOM plus exactly kStringMaxLength characters: the decode fills the
55+
// clamped buffer exactly, the BOM is stripped, and the result is the
56+
// longest possible string. This must succeed; it is why the clamp is
57+
// kStringMaxLength + 1 and not kStringMaxLength.
58+
const size = 2 * kStringMaxLength + 2;
59+
const input = allocOrSkip(size);
60+
input.fill(0x20);
61+
input[0] = 0xFF;
62+
input[1] = 0xFE;
63+
const result = new TextDecoder('utf-16le').decode(input);
64+
assert.strictEqual(result.length, kStringMaxLength);
65+
assert.strictEqual(result.charCodeAt(0), 0x2020);
66+
}
67+
68+
{
69+
// Two characters beyond the buffer through a min_char_size() == 1
70+
// encoding: pure-ASCII input of kStringMaxLength + 2 bytes wants
71+
// kStringMaxLength + 2 UChars, overflows the clamped target buffer
72+
// inside ICU, and the U_BUFFER_OVERFLOW_ERROR path must report
73+
// ERR_STRING_TOO_LONG rather than ERR_ENCODING_INVALID_ENCODED_DATA.
74+
// gb18030 needs full-icu; skip the case silently on small-icu builds
75+
// (the utf-16le cases above ran).
76+
let decoder;
77+
try {
78+
decoder = new TextDecoder('gb18030');
79+
} catch (e) {
80+
if (e.code !== 'ERR_ENCODING_NOT_SUPPORTED')
81+
throw e;
82+
}
83+
84+
if (decoder !== undefined) {
85+
const input = allocOrSkip(kStringMaxLength + 2);
86+
input.fill(0x41);
87+
assertThrowsTooLong(() => decoder.decode(input));
88+
}
89+
}

0 commit comments

Comments
 (0)