Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Reject non-mimepart resources in `mailparse_msg_*` instead of crashing
- Fix memory leaks
- Fix `MimeMessage::extract_uue()` ignoring index greater than zero
- Fix use-after-free when a MIME part outlives its parent, or when a child part is freed before its parent. `mailparse_msg_free()` on a part that still belongs to a parent now returns true without freeing it; the part is freed with its root. (Firas Fatnassi, Oz Kaiser)

# Version 3.2.0 - 2026-04-09

Expand Down
13 changes: 12 additions & 1 deletion mailparse.c
Original file line number Diff line number Diff line change
Expand Up @@ -1155,10 +1155,21 @@ PHP_FUNCTION(mailparse_msg_parse_file)
PHP_FUNCTION(mailparse_msg_free)
{
zval *arg;
zend_resource *res;
php_mimepart *part;

if (zend_parse_parameters(ZEND_NUM_ARGS(), "r", &arg) == FAILURE) {
RETURN_FALSE;
}
zend_list_close(Z_RES_P(arg));
res = Z_RES_P(arg);
if (res->type == le_mime_part) {
part = res->ptr;
/* A child part is owned by its parent and freed along with it */
if (part != NULL && part->parent != NULL) {
RETURN_TRUE;
}
}
zend_list_close(res);
RETURN_TRUE;
}
/* }}} */
Expand Down
13 changes: 11 additions & 2 deletions php_mailparse_mime.c
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ PHP_MAILPARSE_API php_mimepart *php_mimepart_alloc()
PHP_MAILPARSE_API void php_mimepart_free(php_mimepart *part)
{
zval *childpart_z;
php_mimepart *childpart;
HashPosition pos;

/* Prevent the resource destructor from freeing this part again */
Expand All @@ -335,10 +336,15 @@ PHP_MAILPARSE_API void php_mimepart_free(php_mimepart *part)
/* Release child resources via zval_ptr_dtor, which respects refcounts:
* children with no external references are freed immediately, while
* children held by userland (e.g. via mailparse_msg_get_part) stay
* alive until their own refcount reaches zero. The idempotent
* mimepart_dtor (res->ptr NULL guard) prevents double-free at shutdown. */
* alive until their own refcount reaches zero, so they are detached
* from this part first. The idempotent mimepart_dtor (res->ptr NULL
* guard) prevents double-free at shutdown. */
zend_hash_internal_pointer_reset_ex(&part->children, &pos);
while ((childpart_z = zend_hash_get_current_data_ex(&part->children, &pos)) != NULL) {
childpart = Z_RES_P(childpart_z)->ptr;
if (childpart != NULL) {
childpart->parent = NULL;
}
zval_ptr_dtor(childpart_z);
zend_hash_move_forward_ex(&part->children, &pos);
}
Expand Down Expand Up @@ -1010,6 +1016,9 @@ PHP_MAILPARSE_API void php_mimepart_remove_from_parent(php_mimepart *part)
}

part->parent = NULL;
if (parent->parsedata.lastpart == part) {
parent->parsedata.lastpart = NULL;
}

zend_hash_internal_pointer_reset_ex(&parent->children, &pos);
while((childpart_z = zend_hash_get_current_data_ex(&parent->children, &pos)) != NULL) {
Expand Down
27 changes: 27 additions & 0 deletions tests/child_outlives_parent.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
--TEST--
A child MimeMessage can safely outlive its parent
--SKIPIF--
<?php
if (!extension_loaded("mailparse")) die("skip mailparse extension not available");
?>
--FILE--
<?php
function firstChild(string $data): MimeMessage
{
$root = new MimeMessage('var', $data);
return $root->get_child(0);
}

$data = "MIME-Version: 1.0\r\n"
. "Content-Type: multipart/mixed; boundary=x\r\n\r\n"
. "--x\r\nContent-Type: text/plain\r\n\r\nbody\r\n--x--\r\n";

$child = firstChild($data);
var_dump($child instanceof MimeMessage);
var_dump($child->get_parent());
var_dump($child->get_child_count());
?>
--EXPECT--
bool(true)
NULL
int(0)
33 changes: 33 additions & 0 deletions tests/free_child_part.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
--TEST--
mailparse_msg_free() on a child part leaves the parent intact
--SKIPIF--
<?php
if (!extension_loaded("mailparse")) die("skip mailparse extension not available");
?>
--FILE--
<?php
$root = mailparse_msg_create();
mailparse_msg_parse($root, "MIME-Version: 1.0\r\n"
. "Content-Type: multipart/mixed; boundary=x\r\n\r\n"
. "--x\r\nContent-Type: text/plain\r\n\r\nbody\r\n");

$child = mailparse_msg_get_part($root, '1.1');
var_dump(mailparse_msg_free($child));

mailparse_msg_parse($root, "more\r\n--x\r\nContent-Type: text/html\r\n\r\n<p>\r\n--x--\r\n");
var_dump(mailparse_msg_get_structure($root));
var_dump(mailparse_msg_get_part_data(mailparse_msg_get_part($root, '1.1'))['content-type']);
var_dump(mailparse_msg_free($root));
?>
--EXPECT--
bool(true)
array(3) {
[0]=>
string(1) "1"
[1]=>
string(3) "1.1"
[2]=>
string(3) "1.2"
}
string(10) "text/plain"
bool(true)
26 changes: 26 additions & 0 deletions tests/mimemessage_remove_lastpart.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
--TEST--
Freeing a part removed with MimeMessage::remove() leaves its former parent intact
--SKIPIF--
<?php if (!extension_loaded("mailparse")) print "skip"; ?>
--FILE--
<?php
$data = "MIME-Version: 1.0\r\n"
. "Content-Type: multipart/mixed; boundary=x\r\n\r\n"
. "--x\r\nContent-Type: text/plain\r\n\r\nbody\r\n";

$root = new MimeMessage('var', $data);
$child = $root->get_child(1);
$child->remove();

var_dump(mailparse_msg_free(((array)$child)[0]));
$res = ((array)$root)[0];
var_dump(mailparse_msg_parse($res, "more\r\n"));
var_dump(mailparse_msg_get_structure($res));
?>
--EXPECT--
bool(true)
bool(true)
array(1) {
[0]=>
string(1) "1"
}
28 changes: 28 additions & 0 deletions tests/resource_child_outlives_parent.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
--TEST--
A child mailparse resource can safely outlive its parent
--SKIPIF--
<?php
if (!extension_loaded("mailparse")) die("skip mailparse extension not available");
?>
--FILE--
<?php
$data = "MIME-Version: 1.0\r\n"
. "Content-Type: multipart/mixed; boundary=x\r\n\r\n"
. "--x\r\nContent-Type: text/plain\r\n\r\nbody\r\n";

$root = mailparse_msg_create();
mailparse_msg_parse($root, $data);
$child = mailparse_msg_get_part($root, '1.1');

var_dump(mailparse_msg_free($root));
var_dump(mailparse_msg_parse($child, "X-Test: value\r\n"));
$info = mailparse_msg_get_part_data($child);
var_dump($info['content-type'], $info['line-count']);
var_dump(mailparse_msg_free($child));
?>
--EXPECT--
bool(true)
bool(true)
string(10) "text/plain"
int(4)
bool(true)
Loading