Skip to content

Commit 272f6a9

Browse files
Fix integer underflow in Chakra napi_get_value_string_* zero-bufsize handling (#197)
## Summary Fixes an integer underflow in the Chakra Node-API string getters when a caller passes a non-null buffer with `bufsize == 0`. `napi_get_value_string_utf16` forwarded `bufsize - 1` to `JsCopyStringUtf16` as the destination capacity and stored the null terminator at `buf[bufsize - 1]`. With `bufsize == 0`, `bufsize - 1` underflows to `SIZE_MAX`, so: - the **entire** JS string is copied into the zero-length buffer, and - the terminator is written at `buf[SIZE_MAX]`. That's an attacker-sized out-of-bounds write reachable from any native caller that passes `(buf != nullptr, bufsize == 0)` (CWE-191 → CWE-787/CWE-120). ## Fix - `napi_get_value_string_utf16`: gate the copy on `bufsize != 0` (mirroring the upstream Node implementation). A non-null buffer with `bufsize == 0` now reports zero copied and writes nothing. - `napi_get_value_string_latin1` / `napi_get_value_string_utf8`: these shared a related, milder bug — with `bufsize == 0` they took the slow path (a needless allocation) and stored the terminator at `buf[0]`, one byte past a zero-length buffer. Both now short-circuit `bufsize == 0` identically. ## Test Adds a native regression test (`NodeApi.GetValueStringUtf16HandlesZeroBufsize`) — the path isn't reachable from JS, so it's exercised through the C API. It passes a sentinel-filled buffer with `bufsize == 0` and asserts nothing is written and the reported length is zero, while a normally-sized buffer still copies and null-terminates. Guarded off the V8JSI backend (which doesn't expose `napi_get_value_string_utf16`), same as the `napi_create_dataview` regression test from #181. **Verified (Win32/Chakra):** builds clean; the new test and the full UnitTests suite pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 99457c0 commit 272f6a9

3 files changed

Lines changed: 93 additions & 1 deletion

File tree

Core/Node-API/Source/js_native_api_chakra.cc

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,14 @@ napi_status napi_get_value_string_latin1(napi_env env,
14051405
CHECK_JSRT_EXPECTED(env,
14061406
JsCopyString(jsValue, nullptr, 0, result, CP_LATIN1),
14071407
napi_string_expected);
1408+
} else if (bufsize == 0) {
1409+
// A non-null buffer with bufsize == 0 has no room for the null terminator;
1410+
// report zero and write nothing. The slow path below would otherwise run a
1411+
// needless allocation and store the terminator at buf[0], one byte past the
1412+
// zero-length buffer.
1413+
if (result != nullptr) {
1414+
*result = 0;
1415+
}
14081416
} else {
14091417
size_t count = 0;
14101418
CHECK_JSRT_EXPECTED(env,
@@ -1489,6 +1497,14 @@ napi_status napi_get_value_string_utf8(napi_env env,
14891497
CHECK_JSRT_EXPECTED(env,
14901498
JsCopyString(jsValue, nullptr, 0, result),
14911499
napi_string_expected);
1500+
} else if (bufsize == 0) {
1501+
// A non-null buffer with bufsize == 0 has no room for the null terminator;
1502+
// report zero and write nothing. The slow path below would otherwise run a
1503+
// needless allocation and store the terminator at buf[0], one byte past the
1504+
// zero-length buffer.
1505+
if (result != nullptr) {
1506+
*result = 0;
1507+
}
14921508
} else {
14931509
size_t count = 0;
14941510
CHECK_JSRT_EXPECTED(env,
@@ -1574,7 +1590,7 @@ napi_status napi_get_value_string_utf16(napi_env env,
15741590
CHECK_JSRT_EXPECTED(env,
15751591
JsCopyStringUtf16(jsValue, nullptr, 0, result),
15761592
napi_string_expected);
1577-
} else {
1593+
} else if (bufsize != 0) {
15781594
size_t copied = 0;
15791595
CHECK_JSRT_EXPECTED(env,
15801596
JsCopyStringUtf16(
@@ -1593,6 +1609,12 @@ napi_status napi_get_value_string_utf16(napi_env env,
15931609
if (result != nullptr) {
15941610
*result = copied;
15951611
}
1612+
} else if (result != nullptr) {
1613+
// A non-null buffer with bufsize == 0 has no room for any character or the
1614+
// null terminator. Report zero copied and write nothing, instead of letting
1615+
// bufsize - 1 underflow to SIZE_MAX (which would copy the whole string into
1616+
// the zero-length buffer and store the terminator at buf[SIZE_MAX]).
1617+
*result = 0;
15961618
}
15971619

15981620
return napi_ok;

Core/Node-API/Source/js_native_api_javascriptcore.cc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,6 +1815,12 @@ napi_status napi_get_value_string_latin1(napi_env env,
18151815

18161816
if (buf == nullptr) {
18171817
*result = string.LengthLatin1();
1818+
} else if (bufsize == 0) {
1819+
// No room for any character or the null terminator; report zero and write
1820+
// nothing rather than letting the copy helper underflow bufsize - 1.
1821+
if (result != nullptr) {
1822+
*result = 0;
1823+
}
18181824
} else {
18191825
string.CopyToLatin1(buf, bufsize, result);
18201826
}
@@ -1844,6 +1850,12 @@ napi_status napi_get_value_string_utf8(napi_env env,
18441850

18451851
if (buf == nullptr) {
18461852
*result = string.LengthUTF8();
1853+
} else if (bufsize == 0) {
1854+
// No room for any byte or the null terminator; report zero and write
1855+
// nothing rather than letting the copy helper underflow bufsize - 1.
1856+
if (result != nullptr) {
1857+
*result = 0;
1858+
}
18471859
} else {
18481860
string.CopyToUTF8(buf, bufsize, result);
18491861
}
@@ -1873,6 +1885,12 @@ napi_status napi_get_value_string_utf16(napi_env env,
18731885

18741886
if (buf == nullptr) {
18751887
*result = string.Length();
1888+
} else if (bufsize == 0) {
1889+
// No room for any code unit or the null terminator; report zero and write
1890+
// nothing rather than letting the copy helper underflow bufsize - 1.
1891+
if (result != nullptr) {
1892+
*result = 0;
1893+
}
18761894
} else {
18771895
static_assert(sizeof(char16_t) == sizeof(JSChar));
18781896
string.CopyTo(reinterpret_cast<JSChar*>(buf), bufsize, result);

Tests/UnitTests/Shared/Shared.cpp

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,58 @@ TEST(NodeApi, CreateDataViewRejectsOverflowingRange)
351351
}
352352
#endif
353353

354+
// The V8JSI Node-API shim does not expose napi_get_value_string_utf16, so this
355+
// native test only builds on the Chakra, V8, and JavaScriptCore backends.
356+
#if !defined(JSRUNTIMEHOST_NAPI_ENGINE_JSI)
357+
TEST(NodeApi, GetValueStringUtf16HandlesZeroBufsize)
358+
{
359+
// Regression: napi_get_value_string_utf16 with a non-null buffer and
360+
// bufsize == 0 must not evaluate bufsize - 1. On the Chakra backend the
361+
// pre-fix code forwarded bufsize - 1 (== SIZE_MAX) to JsCopyStringUtf16 as
362+
// the destination capacity, copying the entire JS string into the
363+
// zero-length buffer, and then stored the terminator at buf[bufsize - 1]
364+
// (== buf[SIZE_MAX]). The call must instead write nothing and report zero.
365+
Babylon::AppRuntime runtime{};
366+
367+
std::promise<bool> zeroSafe;
368+
std::promise<bool> normalWorks;
369+
370+
runtime.Dispatch([&zeroSafe, &normalWorks](Napi::Env env) {
371+
napi_env nenv{env};
372+
373+
napi_value strValue{Napi::String::New(env, "hello world")};
374+
375+
// Sentinel-filled buffer. With bufsize == 0 nothing may be written, so
376+
// every element must survive unchanged (a SIZE_MAX-capacity copy would
377+
// clobber it / overflow).
378+
char16_t guard[8];
379+
for (auto& c : guard)
380+
{
381+
c = static_cast<char16_t>(0x7FFF);
382+
}
383+
384+
size_t copied{0xDEAD};
385+
napi_status status{napi_get_value_string_utf16(nenv, strValue, guard, 0, &copied)};
386+
387+
bool safe{status == napi_ok && copied == 0};
388+
for (auto c : guard)
389+
{
390+
safe = safe && (c == static_cast<char16_t>(0x7FFF));
391+
}
392+
zeroSafe.set_value(safe);
393+
394+
// A sufficiently-sized buffer must still copy and null-terminate.
395+
char16_t buf[32];
396+
size_t copied2{0};
397+
napi_status status2{napi_get_value_string_utf16(nenv, strValue, buf, 32, &copied2)};
398+
normalWorks.set_value(status2 == napi_ok && copied2 == 11 && buf[copied2] == 0);
399+
});
400+
401+
EXPECT_TRUE(zeroSafe.get_future().get());
402+
EXPECT_TRUE(normalWorks.get_future().get());
403+
}
404+
#endif
405+
354406
int RunTests()
355407
{
356408
testing::InitGoogleTest();

0 commit comments

Comments
 (0)