From 1fda487325e08c042950ef136783fed9dde0036f Mon Sep 17 00:00:00 2001 From: Graham TerMarsch Date: Fri, 26 Jun 2026 20:14:49 -0700 Subject: [PATCH] Fixes CVE-2026-56018, where we leaked memory on each minification. Thanks to drclaw1394 and CPANSec for reporting, and for suggestions on where to address. > CVE-2026-56018: > > JavaScript::Minifier::XS unbounded memory growth > > Root-caused: in JsMinify the cleanup (XS.xs:742-750) frees only the NodeSet structs, never the per-node contents buffers (Newz'd in JsSetNodeContents, XS.xs:261); JsDiscardNode only unlinks. So every token's contents leaks on every minify() call. (The two if (!head) return NULL early-returns also leak the whole ~2 MB NodeSet.) Closes #10. --- Changes | 3 ++ XS.xs | 12 ++++++-- cpanfile | 1 + xt/author/leaks-xs.t | 66 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100755 xt/author/leaks-xs.t diff --git a/Changes b/Changes index 92a83e4..93fe495 100644 --- a/Changes +++ b/Changes @@ -4,6 +4,9 @@ Revision history for Perl extension JavaScript::Minifier::XS. - Fixes CVE-2026-56017, which caused Perl to SEGFAULT when calling minify(). Thanks to CPANSec for raising the issue, and providing a prototype fix. + - Fixes CVE-2026-56018, caused by a memory leak in minify() where each + tokenized Node's "contents" buffer were not properly freed, resulting in a + memory leak on every call. - Updated author tests for "does the JS still compile?", to use "node" instead of "jsl". diff --git a/XS.xs b/XS.xs index 9e207f4..969894e 100644 --- a/XS.xs +++ b/XS.xs @@ -702,7 +702,7 @@ Node* JsPruneNodes(Node *head) { * **************************************************************************** */ char* JsMinify(const char* string) { - char* results; + char* results = NULL; JsDoc doc; /* initialize our JS document object */ @@ -716,12 +716,12 @@ char* JsMinify(const char* string) { /* PASS 1: tokenize JS into a list of nodes */ Node* head = JsTokenizeString(&doc, string); - if (!head) return NULL; + if (!head) goto cleanup; /* PASS 2: collapse nodes */ JsCollapseNodes(head); /* PASS 3: prune nodes */ head = JsPruneNodes(head); - if (!head) return NULL; + if (!head) goto cleanup; /* PASS 4: re-assemble JS into single string */ { Node* curr; @@ -741,10 +741,16 @@ char* JsMinify(const char* string) { *ptr = 0; } /* free memory used by the NodeSets */ + cleanup: { NodeSet* curr = doc.head_set; while (curr) { NodeSet* next = curr->next; + /* free each node's contents buffer before freeing the set */ + size_t idx; + for (idx=0; idx < curr->next_node; idx++) + JsClearNodeContents( &curr->nodes[idx] ); + /* free the set, now that it's empty */ Safefree(curr); curr = next; } diff --git a/cpanfile b/cpanfile index f3278d8..e67b516 100644 --- a/cpanfile +++ b/cpanfile @@ -6,5 +6,6 @@ author_requires 'File::Slurp'; author_requires 'File::Which'; author_requires 'IPC::Run'; author_requires 'JavaScript::Minifier'; +author_requires 'Linux::Smaps'; author_requires 'Number::Format'; author_requires 'Test::LeakTrace'; diff --git a/xt/author/leaks-xs.t b/xt/author/leaks-xs.t new file mode 100755 index 0000000..ee665f1 --- /dev/null +++ b/xt/author/leaks-xs.t @@ -0,0 +1,66 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use Test::More; +use JavaScript::Minifier::XS qw(minify); + +BEGIN { + eval "use Linux::Smaps"; + plan skip_all => "Linux::Smaps required for XS leak testing" if $@; +} +use Linux::Smaps; + +############################################################################### +my $ITERS_WARMUP = 2_000; +my $ITERS_TESTING = 50_000; + +############################################################################### +# A small snippet exercising several token types: identifiers, whitespace, +# sigils, a literal, and a comment. Each becomes a node whose content could +# leak. +my $js = <<'END_JS'; +var foo = 1; // a comment +function bar() { + return foo + "baz"; +} +END_JS + +############################################################################### +# Sanity check: minify actually does something. +ok minify($js), 'minify() returned minified JS'; + +############################################################################### +# Warm things up. Runs a handful of iterations so that our memory allocator +# can reach a steady state. +minify($js) for (1 .. $ITERS_WARMUP); + +############################################################################### +# Measure RSS growth over repeated calls to the minifier. If the XS code is +# leaking any memory, our RSS should grow. +my $smaps = Linux::Smaps->new; + +my $rss_before = $smaps->update->rss; +minify($js) for (1 .. $ITERS_TESTING); +my $rss_after = $smaps->update->rss; + +my $rss_growth = $rss_after - $rss_before; +note sprintf( + "RSS before: %d KB, after: %d KB, growth: %d KB over %d calls (%.3f KB/call)", + $rss_before, + $rss_after, + $rss_growth, + $ITERS_TESTING, + $rss_growth / $ITERS_TESTING, +); + +############################################################################### +# Allow for some memory allocator noise and fragmentation. +# +# If total growth exceeds this threshold, odds are high that we're leaking. +my $THRESHOLD_KB = 4_000; +cmp_ok $rss_growth, '<', $THRESHOLD_KB, + "minify() does not leak memory (RSS growth $rss_growth KB < $THRESHOLD_KB KB)"; + +############################################################################### +done_testing();