Skip to content

Commit 1fda487

Browse files
committed
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.
1 parent 416df40 commit 1fda487

4 files changed

Lines changed: 79 additions & 3 deletions

File tree

‎Changes‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Revision history for Perl extension JavaScript::Minifier::XS.
44
- Fixes CVE-2026-56017, which caused Perl to SEGFAULT when calling
55
minify(). Thanks to CPANSec for raising the issue, and providing a
66
prototype fix.
7+
- Fixes CVE-2026-56018, caused by a memory leak in minify() where each
8+
tokenized Node's "contents" buffer were not properly freed, resulting in a
9+
memory leak on every call.
710
- Updated author tests for "does the JS still compile?", to use "node"
811
instead of "jsl".
912

‎XS.xs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ Node* JsPruneNodes(Node *head) {
702702
* ****************************************************************************
703703
*/
704704
char* JsMinify(const char* string) {
705-
char* results;
705+
char* results = NULL;
706706
JsDoc doc;
707707

708708
/* initialize our JS document object */
@@ -716,12 +716,12 @@ char* JsMinify(const char* string) {
716716

717717
/* PASS 1: tokenize JS into a list of nodes */
718718
Node* head = JsTokenizeString(&doc, string);
719-
if (!head) return NULL;
719+
if (!head) goto cleanup;
720720
/* PASS 2: collapse nodes */
721721
JsCollapseNodes(head);
722722
/* PASS 3: prune nodes */
723723
head = JsPruneNodes(head);
724-
if (!head) return NULL;
724+
if (!head) goto cleanup;
725725
/* PASS 4: re-assemble JS into single string */
726726
{
727727
Node* curr;
@@ -741,10 +741,16 @@ char* JsMinify(const char* string) {
741741
*ptr = 0;
742742
}
743743
/* free memory used by the NodeSets */
744+
cleanup:
744745
{
745746
NodeSet* curr = doc.head_set;
746747
while (curr) {
747748
NodeSet* next = curr->next;
749+
/* free each node's contents buffer before freeing the set */
750+
size_t idx;
751+
for (idx=0; idx < curr->next_node; idx++)
752+
JsClearNodeContents( &curr->nodes[idx] );
753+
/* free the set, now that it's empty */
748754
Safefree(curr);
749755
curr = next;
750756
}

‎cpanfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ author_requires 'File::Slurp';
66
author_requires 'File::Which';
77
author_requires 'IPC::Run';
88
author_requires 'JavaScript::Minifier';
9+
author_requires 'Linux::Smaps';
910
author_requires 'Number::Format';
1011
author_requires 'Test::LeakTrace';

‎xt/author/leaks-xs.t‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/perl
2+
3+
use strict;
4+
use warnings;
5+
use Test::More;
6+
use JavaScript::Minifier::XS qw(minify);
7+
8+
BEGIN {
9+
eval "use Linux::Smaps";
10+
plan skip_all => "Linux::Smaps required for XS leak testing" if $@;
11+
}
12+
use Linux::Smaps;
13+
14+
###############################################################################
15+
my $ITERS_WARMUP = 2_000;
16+
my $ITERS_TESTING = 50_000;
17+
18+
###############################################################################
19+
# A small snippet exercising several token types: identifiers, whitespace,
20+
# sigils, a literal, and a comment. Each becomes a node whose content could
21+
# leak.
22+
my $js = <<'END_JS';
23+
var foo = 1; // a comment
24+
function bar() {
25+
return foo + "baz";
26+
}
27+
END_JS
28+
29+
###############################################################################
30+
# Sanity check: minify actually does something.
31+
ok minify($js), 'minify() returned minified JS';
32+
33+
###############################################################################
34+
# Warm things up. Runs a handful of iterations so that our memory allocator
35+
# can reach a steady state.
36+
minify($js) for (1 .. $ITERS_WARMUP);
37+
38+
###############################################################################
39+
# Measure RSS growth over repeated calls to the minifier. If the XS code is
40+
# leaking any memory, our RSS should grow.
41+
my $smaps = Linux::Smaps->new;
42+
43+
my $rss_before = $smaps->update->rss;
44+
minify($js) for (1 .. $ITERS_TESTING);
45+
my $rss_after = $smaps->update->rss;
46+
47+
my $rss_growth = $rss_after - $rss_before;
48+
note sprintf(
49+
"RSS before: %d KB, after: %d KB, growth: %d KB over %d calls (%.3f KB/call)",
50+
$rss_before,
51+
$rss_after,
52+
$rss_growth,
53+
$ITERS_TESTING,
54+
$rss_growth / $ITERS_TESTING,
55+
);
56+
57+
###############################################################################
58+
# Allow for some memory allocator noise and fragmentation.
59+
#
60+
# If total growth exceeds this threshold, odds are high that we're leaking.
61+
my $THRESHOLD_KB = 4_000;
62+
cmp_ok $rss_growth, '<', $THRESHOLD_KB,
63+
"minify() does not leak memory (RSS growth $rss_growth KB < $THRESHOLD_KB KB)";
64+
65+
###############################################################################
66+
done_testing();

0 commit comments

Comments
 (0)