{"id":"GHSA-8hcv-x26h-mcgp","revision":"7ae4e6a07c5840c292033538c25be41a","records":[{"revision":"7ae4e6a07c5840c292033538c25be41a","observedAt":"2026-10-24T13:17:17.793+00:00","statement":{"source":"osv","id":"GHSA-8hcv-x26h-mcgp","aliases":["CVE-2026-71430"],"title":"node-re2: String.prototype.replace(re2, template) aborts the Node process (uncatchable ToLocalChecked on empty MaybeLocal) when the result exceeds V8's max string length","description":"## Description\n\n`WrappedRE2::Replace` builds the replacement result and hands it to V8 with `.ToLocalChecked()` **without checking for the empty `MaybeLocal`** that V8 returns when the string/buffer exceeds its maximum length:\n\n`lib/replace.cc` (v1.24.1):\n```cpp\n// L553 — Buffer return path\ninfo.GetReturnValue().Set(Nan::CopyBuffer(result.data(), result.size()).ToLocalChecked());\n// L556 — String return path\ninfo.GetReturnValue().Set(Nan::New(result).ToLocalChecked());\n```\n\nWhen a global replace uses an output-amplifying template — `$'` (text after the match) or `` $` `` (text before the match) — the result grows to **O(input²)**. For an input of ~40,000+ identical single-char matches the result exceeds V8's `String::kMaxLength` (~536,870,888 chars on 64-bit). `Nan::New(result)` then returns an **empty `MaybeLocal`**, and the unchecked `.ToLocalChecked()` calls `v8::Utils::ReportApiFailure` → **`FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`** → `abort()` (SIGABRT).\n\nThis is an **uncatchable** crash: it is not a JavaScript exception, so a surrounding `try/catch` cannot stop it — the entire Node process (or worker) dies.\n\n**The built-in regex engine handles the identical case correctly** by throwing a *catchable* `RangeError: Invalid string length`. node-re2 diverges from that contract and aborts instead.\n\n## Proof of concept\n\n```\nnpm i re2\nnode poc.js\n```\n\n```js\nconst RE2 = require('re2');\n\n// Built-in engine: same case -> CATCHABLE RangeError (correct)\ntry { 'a'.repeat(50000).replace(/a/g, \"$'\"); }\ncatch (e) { console.log('native:', e.constructor.name, e.message); } // RangeError: Invalid string length\n\n// re2: ABORTS the whole process (uncatchable; try/catch does not help)\n'a'.repeat(50000).replace(new RE2('a', 'g'), \"$'\");\n// -> FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal   (process exits 134 / SIGABRT)\n```\n\nObserved (Node v24, clean `npm i re2` → re2@1.24.1): native branch prints `RangeError: Invalid string length`; the re2 branch aborts with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`, stack top `WrappedRE2::Replace`, process exit code **134**.\n\nThreshold matches the mechanism precisely: input of 30,000 chars completes; 40,000 aborts (30000²/2 ≈ 4.5e8 < 5.37e8 max; 40000²/2 ≈ 8e8 > max). `$&`/constant templates and non-global replaces do not amplify and do not crash.\n\n## Impact\n\nA remote, unauthenticated denial of service against any service that runs `String.prototype.replace` / the re2 `[Symbol.replace]` path where either the **replacement template** (containing `$'` or `` $` ``) or the **input size** is attacker-influenced. Because the failure is a native `abort()`, it cannot be contained by `try/catch` or domains — one request takes down the whole process/worker. This is especially impactful for re2's core audience, who adopt it specifically to process untrusted patterns/inputs safely.\n\n## Suggested fix\n\nCheck the `MaybeLocal` before `ToLocalChecked` on both return paths (and the intermediate group-string builds), and throw a catchable `RangeError` to match the built-in engine:\n\n```cpp\nauto maybe = Nan::New(result);\nif (maybe.IsEmpty()) { Nan::ThrowRangeError(\"Invalid string length\"); return; }\ninfo.GetReturnValue().Set(maybe.ToLocalChecked());\n```\n\n(Apply equivalently to the `Nan::CopyBuffer(...)` buffer path at L553 and to the per-group `Nan::New(data, size).ToLocalChecked()` sites used by the replacer-function path.)\n\n## Resolution\n\nResolved in `re2` `1.25.1`. `WrappedRE2::Replace` now checks the returned `MaybeLocal` on every result path and throws a catchable `RangeError: Invalid string length` (matching the built-in engine) instead of aborting the process with an uncatchable `SIGABRT`. No API changes --- upgrade to `re2` >= `1.25.1` via a plain `npm upgrade` to receive the fix.","state":0,"publishedAt":"2026-08-06T21:19:36+00:00","modifiedAt":"2026-08-06T21:40:58.226072+00:00","affected":[{"ecosystem":"npm","name":"re2","ranges":[{"type":"SEMVER","events":[{"kind":"introduced","version":"0"},{"kind":"fixed","version":"1.25.1"}]}],"versions":[],"rawVersionEvidence":null}],"metrics":[{"system":"CVSS_V3","vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H","score":null,"severity":null},{"system":"Publisher rating","vector":null,"score":null,"severity":"MODERATE"}],"references":["https://github.com/uhop/node-re2/security/advisories/GHSA-8hcv-x26h-mcgp","https://github.com/uhop/node-re2"],"sourceUrl":"https://github.com/advisories/GHSA-8hcv-x26h-mcgp","license":"GitHub Advisory Database · CC-BY-4.0","key":"osv:GHSA-8hcv-x26h-mcgp","identifiers":["GHSA-8hcv-x26h-mcgp","CVE-2026-71430"]},"retained":false,"historicalAffected":[]}],"coverage":[]}