From 6922552b0778c90a9587a3894e248be4d3a25e1c Mon Sep 17 00:00:00 2001
From: Paul Johnson <paul@pjcj.net>
Date: Tue, 19 May 2026 20:24:00 +0000
Subject: [PATCH] Fix heap-use-after-free in _decode_entities (CVE-2026-8829)

When the input SV passed to _decode_entities is the same SV stored as a
self-referential value in the entity hash, grow_gap() could realloc the
SV's PV buffer, leaving repl pointing at freed memory. Copy the entity
value into an owned buffer when the hash entry SV aliases the input SV.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---
 t/entities.t | 20 ++++++++++++++++++--
 util.c       | 28 +++++++++++++++++++++++++---
 2 files changed, 43 insertions(+), 5 deletions(-)

diff --git a/t/entities.t b/t/entities.t
index 5f973a1..012e7bb 100644
--- a/t/entities.t
+++ b/t/entities.t
@@ -2,8 +2,9 @@ use strict;
 use warnings;
 use utf8;
 
-use HTML::Entities qw(decode_entities encode_entities encode_entities_numeric);
-use Test::More tests => 31;
+use HTML::Entities
+    qw(_decode_entities decode_entities encode_entities encode_entities_numeric);
+use Test::More tests => 32;
 
 my $x = "V&aring;re norske tegn b&oslash;r &#230res";
 
@@ -96,6 +97,21 @@ is($x, $ent);
     is($got, (values %hash)[0], "decode_entities() decodes a key properly");
 }
 
+# CVE-2026-8829
+# _decode_entities heap-use-after-free when the input SV is the same SV as
+# a self-referential entity value. The payload must be large enough to
+# force grow_gap() to realloc the SV's PV; the fix copies the entity value
+# into an owned buffer so repl is not left pointing at the freed allocation.
+{
+    my $prefix_a = "A" x 32;
+    my $suffix_b = "B" x 8192;
+    my %h;
+    $h{foo} = $prefix_a . "&foo;" . $suffix_b;
+    _decode_entities($h{foo}, \%h);
+    is($h{foo}, ("A" x 64) . "&foo;" . ("B" x 16384),
+        "_decode_entities() with self-aliased entity hash value");
+}
+
 # From: Bill Simpson-Young <bill.simpson-young@cmis.csiro.au>
 # Subject: HTML entities problem with 5.11
 # To: libwww-perl@ics.uci.edu
diff --git a/util.c b/util.c
index ed4fd70..0bdb327 100644
--- a/util.c
+++ b/util.c
@@ -72,6 +72,7 @@ decode_entities(pTHX_ SV* sv, HV* entity2char, bool expand_prefix)
 
     char *repl;
     STRLEN repl_len;
+    char *repl_allocated = 0;
     char buf[UTF8_MAXLEN];
     int repl_utf8;
     int high_surrogate = 0;
@@ -89,6 +90,7 @@ decode_entities(pTHX_ SV* sv, HV* entity2char, bool expand_prefix)
 
 	ent_start = s;
 	repl = 0;
+	repl_allocated = 0;
 
 	if (s < end && *s == '#') {
 	    UV num = 0;
@@ -176,16 +178,34 @@ decode_entities(pTHX_ SV* sv, HV* entity2char, bool expand_prefix)
 		    (*s == ';' && (svp = hv_fetch(entity2char, ent_name, s - ent_name + 1, 0)))
 		   )
 		{
-		    repl = SvPV(*svp, repl_len);
+		    char *src = SvPV(*svp, repl_len);
 		    repl_utf8 = SvUTF8(*svp);
+		    if ((SV*)*svp == sv) {
+			/* Self-aliased: hash entry SV == input SV.
+			 * grow_gap() may realloc sv's PV later; copy
+			 * the entity value into an owned buffer first.
+			 * Freed by the repl_allocated cleanup below. */
+			Newx(repl_allocated, repl_len ? repl_len : 1, char);
+			Copy(src, repl_allocated, repl_len, char);
+			repl = repl_allocated;
+		    } else {
+			repl = src;
+		    }
 		}
 		else if (expand_prefix) {
 		    char *ss = s - 1;
 		    while (ss > ent_name) {
 			svp = hv_fetch(entity2char, ent_name, ss - ent_name, 0);
 			if (svp) {
-			    repl = SvPV(*svp, repl_len);
+			    char *src = SvPV(*svp, repl_len);
 			    repl_utf8 = SvUTF8(*svp);
+			    if ((SV*)*svp == sv) {
+				Newx(repl_allocated, repl_len ? repl_len : 1, char);
+				Copy(src, repl_allocated, repl_len, char);
+				repl = repl_allocated;
+			    } else {
+				repl = src;
+			    }
 			    s = ss;
 			    break;
 			}
@@ -197,7 +217,9 @@ decode_entities(pTHX_ SV* sv, HV* entity2char, bool expand_prefix)
 	}
 
 	if (repl) {
-	    char *repl_allocated = 0;
+	    /* repl_allocated is now function-scoped; set by the
+	     * named-entity self-alias path above or by the UTF8 mismatch
+	     * branch below. Same cleanup in either case. */
 	    if (s < end && *s == ';')
 		s++;
 	    t--;  /* '&' already copied, undo it */

