Skip to content

Update dependency re2 to v1.26.1 [SECURITY] - #137

Open
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/npm-re2-vulnerability
Open

Update dependency re2 to v1.26.1 [SECURITY]#137
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/npm-re2-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
re2 1.17.11.26.1 age confidence

re2: Global String.prototype.match with an empty-matchable pattern never advances → infinite loop with unbounded native memory growth (DoS)

CVE-2026-68499 / GHSA-6hxr-mr5r-9836

More information

Details

Summary

String.prototype.match with a global RE2 collects all matches in a native loop that advances the cursor by the match length. A zero-width (empty) match has length 0, so the cursor never advances: the same empty match is found forever and appended to an ever-growing native vector. Any pattern that can match the empty string (a*, b?, x{0,3}, (a)|, (?:), …) therefore causes an infinite loop with unbounded memory growth. The call is synchronous native code, so it blocks the entire event loop and cannot be interrupted by try/catch, AbortController, --max-old-space-size, or timers — the process must be killed externally. This diverges from the built-in engine, where 'xxxx'.match(/a*/g) returns a finite array.

Root cause
// lib/match.cc:44 — global branch of WrappedRE2::Match
while (re2->regexp.Match(str, byteIndex, str.size, anchor, &match, 1)) {
    groups.push_back(match);
    byteIndex = match.data() - str.data + match.size();   // += 0 for a zero-width match
}

When match.size() == 0, byteIndex is unchanged, so the next iteration matches the same empty position again; groups grows without bound. The other iteration paths already guard this: lib/split.cc:50-55 advances by getUtf8CharSize on an empty match, and exec advances lastIndex. Only this global Match loop is missing the guard.

Proof of concept
const RE2 = require('re2');
'x'.match(new RE2('a*', 'g'));   // never returns; grows memory until OOM
// also: 'b?', 'x{0,3}', '(a)|', 'c*d*', '(?:)'; empty subject '' triggers it too

Compare with the built-in engine, which terminates:

'xxxx'.match(/a*/g);   // -> ["", "", "", "", ""]

Measured on a clean npm install re2@1.25.1 (latest), stock prebuilt binary: resident memory grew ~550 MB → 2.3 GB in ~3 seconds at 100% CPU, and the process had to be SIGKILLed externally.

Impact

Denial of service. Reachable remotely and without authentication wherever an application runs a global RE2 through String.prototype.match and either the pattern or the subject is attacker-influenced — e.g. a user-supplied regular expression, or a fixed empty-matchable pattern applied to user input. Because the loop blocks the event loop and exhausts memory in seconds, a single request can wedge a worker and, via memory exhaustion, affect the whole host.

Suggested fix

Mirror the empty-match handling already present in split.cc: when the match is zero-width, advance the cursor by one code point.

// lib/match.cc, inside the global while-loop
groups.push_back(match);
size_t off = match.data() - str.data;
if (match.size()) {
    byteIndex = off + match.size();
} else {
    byteIndex = off + (off < str.size ? getUtf8CharSize(str.data[off]) : 1);
}
Resolution

Fixed in re2 1.25.2.

The global match loop in lib/match.cc now advances the cursor by one Unicode
code point when a match is zero-width, so a pattern that can match the empty
string terminates with a finite result identical to the built-in engine
('xxxx'.match(/a*/g) returns five empty strings). This mirrors the guard
already present in split.

Remediation: upgrade to re2@1.25.2 or later.

Workaround (if you cannot upgrade): do not run a global RE2 through
String.prototype.match when the pattern is attacker-influenced or can match
the empty string. Iterate with matchAll/exec, or use the non-global form;
both already advanced the cursor correctly.

Severity

  • CVSS Score: 6.2 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


re2: Out-of-bounds heap read in exec/test/match via attacker-influenced lastIndex on a non-ASCII subject → uncatchable process crash (DoS)

CVE-2026-67550 / GHSA-ff84-5f28-78qj

More information

Details

Summary

re2 validates the user-settable lastIndex against the subject's UTF-8 byte length but then uses it as a UTF-16 code-unit count to walk the subject buffer, with no bounds check. For any non-ASCII subject, the byte length is larger than the true character count, so a lastIndex between those two values passes validation while pointing past the end of the buffer. The subsequent walk reads out of bounds. With a large subject the read marches into unmapped memory and the process dies with SIGABRT/SIGSEGV — an uncatchable crash (try/catch cannot stop it), i.e. a denial of service for any worker/process that runs the match. In some cases the out-of-bounds bytes are copied into the returned value (a bounded, best-effort heap information leak).

Root cause

The subject wrapper stores the UTF-8 byte length in StrVal::length:

  • lib/addon.cc:200 auto argLength = utf8Length(s, isolate); — UTF-8 byte count
  • lib/addon.cc:209 lastStringValue.reset(buffer, argSize, argLength, startFrom, false, isAscii);

setIndex then validates the (UTF-16) lastIndex against that byte length and walks the buffer by character count:

// lib/addon.cc:229
void StrVal::setIndex(size_t newIndex) {
    isValidIndex = newIndex <= length;   // length == UTF-8 BYTE length, not UTF-16 length
    if (!isValidIndex) { index = newIndex; byteIndex = 0; return; }
    ...
    // addon.cc:263
    byteIndex = index < newIndex
        ? getUtf16PositionByCounter(data, byteIndex, newIndex - index)
        : getUtf16PositionByCounter(data, 0, newIndex);
    index = newIndex;
}

getUtf16PositionByCounter reads data[from] and advances by the UTF-8 char size with no check of from against the buffer size:

// lib/wrapped_re2.h:264
inline size_t getUtf16PositionByCounter(const char *data, size_t from, size_t n) {
    for (; n > 0; --n) {
        size_t s = getUtf8CharSize(data[from]);   // <-- OOB read once `from` passes the buffer end
        from += s;
        if (s == 4 && n >= 2) --n;
    }
    return from;
}

lastIndex is user-settable to any positive integer (capped only at >= 0, no upper bound):

// lib/accessors.cc:166
NAN_SETTER(WrappedRE2::SetLastIndex) {
    ...
    int n = value->NumberValue(...).FromMaybe(0);
    re2->lastIndex = n <= 0 ? 0 : n;   // no upper bound relative to the subject
}

For an ASCII subject the byte length equals the UTF-16 length, so the guard is correct — this only triggers on non-ASCII subjects. The out-of-bounds read happens inside prepareArgument for any global/sticky regex, reached by exec, test, String.prototype.match, replace, and split.

Proof of concept

Minimal (AddressSanitizer, deterministic OOB read):

const RE2 = require('re2');
const re = new RE2('a', 'y');   // sticky; 'g' also works
re.lastIndex = 3;               // 3 <= byteLen(4) passes the guard; only 2 real chars exist
re.exec('éé');                  // U+00E9 = 2 bytes each

Built with -fsanitize=address, this aborts with:

ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 1
  #&#8203;0 getUtf16PositionByCounter   wrapped_re2.h:268
  #&#8203;1 StrVal::reset               addon.cc:277
  #&#8203;2 WrappedRE2::prepareArgument addon.cc:209
  #&#8203;3 WrappedRE2::Exec            exec.cc:17

The overflowed region is the subject buffer allocated by node::Buffer::New at addon.cc:205.

Real-world impact on the shipped prebuilt binary (no ASAN) — uncatchable crash:

const RE2 = require('re2');
const s = '中'.repeat(40000000);        // UTF-16 length 40M, UTF-8 bytes 120M
const re = new RE2('a', 'y');
re.lastIndex = Buffer.byteLength(s) - 1; // passes the byte-length guard, far exceeds real char count
re.exec(s);                              // walks into unmapped memory -> SIGSEGV (exit 139)

try { ... } catch (e) {} around the call does not prevent termination — it is a native fault, not a JS exception. Validated on a clean npm install re2@1.25.1 (latest): stock prebuilt → SIGSEGV; ASAN build → the heap-buffer-overflow read above.

Impact
  • Denial of service (primary): an uncatchable native crash that terminates the Node process/worker. Reachable remotely and without authentication wherever an application (a) uses a global or sticky RE2, (b) applies it to a non-ASCII subject, and (c) sets lastIndex from attacker-influenced data (e.g. resuming a scan/pagination at a client-supplied offset).
  • Information disclosure (secondary, best-effort): the out-of-bounds byteIndex can cause adjacent heap bytes to be copied into the returned value (e.g. the leading segment of a replace result). This is bounded and unreliable — the subject buffer is calloc-allocated (zero-filled) and the over-read distance depends on interpreting out-of-bounds bytes as UTF-8 sizes — so it is noted for completeness, not as a dependable primitive.

This is distinct from GHSA-8hcv-x26h-mcgp (the global replace() output-amplification abort), which was fixed in 1.25.1. This lastIndex out-of-bounds read is a separate defect and remains present in 1.25.1.

Suggested fix

Two independent hardenings; either closes the crash, both is safest:

  1. Bound the walk so it can never read past the buffer:
inline size_t getUtf16PositionByCounter(const char *data, size_t size, size_t from, size_t n) {
    for (; n > 0 && from < size; --n) {
        size_t s = getUtf8CharSize(data[from]);
        from += s;
        if (s == 4 && n >= 2) --n;
    }
    return from > size ? size : from;
}

(thread size through the two call sites in StrVal::setIndex).

  1. Validate lastIndex against the true UTF-16 length, not the UTF-8 byte length — e.g. store s->Length() (UTF-16 units) as the value compared in isValidIndex = newIndex <= <utf16Length>, so an out-of-range lastIndex takes the existing !isValidIndex early-return path.
Resolution

Fixed in re2 1.25.2.

lastIndex is now validated against the subject's UTF-16 length instead of its
UTF-8 byte length (lib/addon.cc), so an out-of-range lastIndex is rejected
before the buffer is walked. As defense in depth, the code-unit walk
(getUtf16PositionByCounter in lib/wrapped_re2.h) is now bounded by the
buffer size and can no longer read past the end.

Remediation: upgrade to re2@1.25.2 or later.

Workaround (if you cannot upgrade): do not assign lastIndex from untrusted
input, or clamp it to the subject's string length (str.length) before calling
exec/test/match/replace/split on a non-ASCII subject.

Severity

  • CVSS Score: 5.7 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


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

CVE-2026-71430 / GHSA-8hcv-x26h-mcgp

More information

Details

Description

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:

lib/replace.cc (v1.24.1):

// L553 — Buffer return path
info.GetReturnValue().Set(Nan::CopyBuffer(result.data(), result.size()).ToLocalChecked());
// L556 — String return path
info.GetReturnValue().Set(Nan::New(result).ToLocalChecked());

When 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::ReportApiFailureFATAL ERROR: v8::ToLocalChecked Empty MaybeLocalabort() (SIGABRT).

This 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.

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.

Proof of concept
npm i re2
node poc.js
const RE2 = require('re2');

// Built-in engine: same case -> CATCHABLE RangeError (correct)
try { 'a'.repeat(50000).replace(/a/g, "$'"); }
catch (e) { console.log('native:', e.constructor.name, e.message); } // RangeError: Invalid string length

// re2: ABORTS the whole process (uncatchable; try/catch does not help)
'a'.repeat(50000).replace(new RE2('a', 'g'), "$'");
// -> FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal   (process exits 134 / SIGABRT)

Observed (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.

Threshold 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.

Impact

A 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.

Suggested fix

Check 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:

auto maybe = Nan::New(result);
if (maybe.IsEmpty()) { Nan::ThrowRangeError("Invalid string length"); return; }
info.GetReturnValue().Set(maybe.ToLocalChecked());

(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.)

Resolution

Resolved 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.

Severity

  • CVSS Score: 6.2 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


node-re2: Out-of-bounds heap read in replace/split via a Buffer ending in a truncated multi-byte UTF-8 character → adjacent heap memory disclosed to JavaScript

CVE-2026-71498 / GHSA-j4r3-hg7j-8chg

More information

Details

Summary

re2 infers a character's byte length from its UTF-8 lead byte alone, with no bound on the
bytes actually remaining in the input. Buffer arguments reach the native layer verbatim —
only strings are re-encoded into well-formed UTF-8 — so a Buffer whose last byte is a
multi-byte lead promises continuation bytes that are not there, and the result builders read
up to 3 bytes past the end of the buffer. In replace() and split() those bytes are copied
into the returned Buffer, disclosing adjacent heap memory to JavaScript. The trigger is
deterministic and requires no special heap grooming.

Only Buffer input is affected. String input was never at risk: re-encoding guarantees every
multi-byte sequence is complete.

Root cause

getUtf8CharSize maps a lead byte to a length of 1–4 and never sees the input size:

// lib/wrapped_re2.h
inline size_t getUtf8CharSize(char ch)
{
      return ((0xE5000000 >> ((ch >> 3) & 0x1E)) & 3) + 1;
}

Callers then read that many bytes. In the zero-width branch of replace(), the guard proves
only that at least one byte remains:

// lib/replace.cc
else if ((size_t)offset < size)
{
      auto sym_size = getUtf8CharSize(data[offset]);   // may claim up to 4 bytes
      result.append(data + offset, sym_size);          // reads data[offset .. offset + 3]
      byteIndex = offset + sym_size;
}

offset < size permits offset == size - 1, so a lead byte of 0xF0 makes append read
data[size], data[size + 1] and data[size + 2].

Seven read sites shared the defect:

Site Argument Disclosed to JS
lib/replace.cc (zero-width branch) subject yes
lib/replace.cc (callback replacer) subject yes
lib/replace.cc (replacement scan) replacement yes
lib/split.cc subject yes
lib/pattern.cc translateRegExp (x2) pattern no
lib/pattern.cc escapeRegExp pattern no

Three further callers were not vulnerable, because they use the result only to advance an
index and never dereference past the end: getUtf16PositionByCounter in lib/wrapped_re2.h
(clamps its return to the buffer size), lib/match.cc (the value feeds RE2::Match, which
rejects startpos > endpos), and the getMaxSubmatch scan in lib/replace.cc (an overshoot
just ends the loop).

Proof of concept

Each call returns more bytes than were supplied; the trailing bytes are heap contents and vary
between runs.

const RE2 = require('re2');
const hex = buf => [...buf].map(b => b.toString(16).padStart(2, '0')).join(' ');

// subject: 2 bytes in, 5 bytes out
console.log(hex(new RE2('', 'g').replace(Buffer.from([0x41, 0xf0]), '')));
// 41 f0 61 7b eb   <- last 3 bytes are adjacent heap memory

// replacement argument
console.log(hex(new RE2('A', 'g').replace(Buffer.from('A'), Buffer.from([0x42, 0xf0]))));
// 42 f0 41 26 d6

// split
console.log(new RE2('', 'g').split(Buffer.from([0x41, 0xf0])).map(hex));
// [ '41', 'f0 e2 e4 df' ]

0xC2 (2-byte lead) and 0xE2 (3-byte lead) over-read 1 and 2 bytes respectively; 0xF0
over-reads 3.

For the pattern path the over-read occurs in translateRegExp / escapeRegExp, which run
before RE2 validates the pattern, but RE2 then rejects the malformed input, so the bytes are
discarded rather than returned:

new RE2(Buffer.from([0xf0]));   // SyntaxError: invalid UTF-8 — read already happened
Impact

Information disclosure (replace, split). Up to 3 bytes of heap memory adjacent to the
input buffer are returned to JavaScript per call. The read is repeatable, so an attacker who
controls Buffer input and observes output can sample heap memory incrementally. What lands
there depends on allocator layout and is not directly steerable, but it may include fragments
of other buffers.

Out-of-bounds read (pattern compilation). No disclosure path, since the malformed pattern
is rejected — but the read is still undefined behavior and can fault if the buffer ends on a
page boundary.

Applications that pass only strings, or only well-formed UTF-8 buffers, are unaffected. The
exposure matters most where re2 is used as intended: running patterns or subjects derived
from untrusted input.

Suggested fix

Clamp the inferred character size to the bytes that actually remain, at every site whose result
indexes the buffer:

inline size_t getUtf8CharSize(char ch, size_t remaining)
{
      size_t size = getUtf8CharSize(ch);
      return size < remaining ? size : remaining;
}

This is O(1) and changes no algorithm's complexity. A truncated tail then round-trips as the
bytes it really holds, which preserves the documented contract that Buffer input is passed
through verbatim. Rejecting malformed UTF-8 in Buffer input would also close the hole, but
is a breaking API change.

Resolution

Fixed in re2@1.26.1.

All seven read sites now clamp the character size to the remaining input, so a Buffer ending
in a truncated multi-byte character round-trips as its own bytes instead of reading past the
end. Regression tests cover the subject, replacement and pattern positions for 2-, 3- and
4-byte leads, including partially truncated sequences.

Remediation: upgrade to re2@1.26.1 or later.

Workaround (if you cannot upgrade): pass strings rather than Buffers, or validate that
Buffer input is well-formed UTF-8 before calling replace, split, or the RE2
constructor — for example Buffer.compare(Buffer.from(buf.toString('utf8')), buf) === 0.

Reported by @​OvOhao in #​272.

Severity

  • CVSS Score: 5.1 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

uhop/node-re2 (re2)

v1.26.1

Compare Source

v1.26.0

Compare Source

v1.25.2

Compare Source

v1.25.1

Compare Source

v1.25.0

Compare Source

v1.24.1

Compare Source

v1.24.0

Compare Source

v1.23.3

Compare Source

v1.23.2

Compare Source

v1.23.1

Compare Source

v1.23.0

Compare Source

v1.22.3

Compare Source

v1.22.2

Compare Source

v1.22.1

Compare Source

v1.22.0

Compare Source

v1.21.5

Compare Source

v1.21.4

Compare Source

v1.21.3

Compare Source

v1.21.2

Compare Source

v1.21.1

Compare Source

v1.21.0

Compare Source

v1.20.12

Compare Source

v1.20.11

Compare Source

v1.20.10

Compare Source

v1.20.9

Compare Source

v1.20.8

Compare Source

v1.20.7

Compare Source

v1.20.5

Compare Source

v1.20.4

Compare Source

v1.20.3

Compare Source

v1.20.2

Compare Source

v1.20.1

Compare Source

v1.20.0

Compare Source

v1.19.2

Compare Source

v1.19.1

Compare Source

v1.19.0

Compare Source

v1.18.3

Compare Source

v1.18.2

Compare Source

v1.18.1

Compare Source

v1.18.0

Compare Source

v1.17.8

Compare Source

v1.17.7

Compare Source

v1.17.6

Compare Source

v1.17.4

Compare Source

v1.17.3

Compare Source

v1.17.2

Compare Source


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate Bot added the security label Jul 31, 2026
@renovate
renovate Bot requested a review from JaneJeon July 31, 2026 21:36
@renovate renovate Bot added the security label Jul 31, 2026
@renovate
renovate Bot force-pushed the renovate/npm-re2-vulnerability branch from 84b053c to 0ab7325 Compare August 8, 2026 09:03
@renovate renovate Bot changed the title Update dependency re2 to v1.25.2 [SECURITY] Update dependency re2 to v1.26.1 [SECURITY] Aug 8, 2026
@renovate
renovate Bot force-pushed the renovate/npm-re2-vulnerability branch from 0ab7325 to ee0a549 Compare August 26, 2026 14:12
@renovate
renovate Bot force-pushed the renovate/npm-re2-vulnerability branch from ee0a549 to 8441ca3 Compare September 2, 2026 22:53
@renovate
renovate Bot force-pushed the renovate/npm-re2-vulnerability branch from 8441ca3 to 8d7b218 Compare September 7, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants