Skip to content
Merged
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
228 changes: 227 additions & 1 deletion ext/standard/VmMail.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
namespace PHPCompiler\ext\standard;

use PHPCompiler\Frame;
use PHPCompiler\VM\EnumCaseSupport;
use PHPCompiler\VM\ErrorReporter;
use PHPCompiler\VM\HashTable;
use PHPCompiler\VM\Variable;

/**
* mail() sendmail transport — php-src ext/standard/mail.c `php_mail()` (#3285).
* mail() sendmail transport — php-src ext/standard/mail.c `php_mail()` (#3285, #21432).
*
* PHP-in-PHP: popen(sendmail_path) + RFC822 envelope on stdin; no new runtime C.
*/
Expand All @@ -20,6 +23,229 @@ final class VmMail
/** sysexits.h EX_TEMPFAIL — php_mail() also treats as success. */
private const EX_TEMPFAIL = 75;

/** Headers that reject array values (php_mail_build_headers PHP_MAIL_BUILD_HEADER_CHECK). */
private const STRING_ONLY_HEADERS = [
'orig-date' => true,
'from' => true,
'sender' => true,
'reply-to' => true,
'cc' => true,
'bcc' => true,
'message-id' => true,
'references' => true,
'in-reply-to' => true,
];

/**
* Coerce mail() $additional_headers — string or array (php-src mail.c; #21432).
*
* @throws \TypeError
* @throws \ValueError
*/
public static function coerceAdditionalHeaders(Variable $arg): ?string
{
$arg = $arg->resolveIndirect();
if (Variable::TYPE_NULL === $arg->type) {
return null;
}
if (Variable::TYPE_STRING === $arg->type) {
$headers = $arg->toString();
VmString::rejectNullByteBuiltinStringArg($headers, 'mail', 3, 'additional_headers');
$headers = rtrim($headers);

return '' === $headers ? null : $headers;
}
if (Variable::TYPE_ARRAY !== $arg->type) {
throw new \TypeError(\sprintf(
'mail(): Argument #4 ($additional_headers) must be of type array|string, %s given',
self::valueTypeName($arg)
));
}

return self::buildHeadersFromArray($arg->toArray());
}

/**
* php_mail_build_headers() — php-src ext/standard/mail.c (#21432).
*
* @throws \TypeError
* @throws \ValueError
*/
public static function buildHeadersFromArray(HashTable $headers): ?string
{
$lines = [];
foreach ($headers->iterateKeyed(true) as [$keyVar, $val]) {
$keyVar = $keyVar->resolveIndirect();
if (Variable::TYPE_INTEGER === $keyVar->type
|| (Variable::TYPE_STRING === $keyVar->type && ctype_digit($keyVar->toString()))) {
$numeric = Variable::TYPE_INTEGER === $keyVar->type
? (string) $keyVar->toInt()
: $keyVar->toString();
throw new \TypeError(\sprintf(
'Header name cannot be numeric, %s given',
$numeric
));
}
if (Variable::TYPE_STRING !== $keyVar->type) {
throw new \TypeError(\sprintf(
'Header name cannot be numeric, %s given',
self::valueTypeName($keyVar)
));
}
$name = $keyVar->toString();
$lower = strtolower($name);
if ('to' === $lower) {
throw new \ValueError('The additional headers cannot contain the "To" header');
}
if ('subject' === $lower) {
throw new \ValueError('The additional headers cannot contain the "Subject" header');
}
$val = $val->resolveIndirect();
if (Variable::TYPE_STRING === $val->type) {
$lines[] = self::formatHeaderLine($name, $val->toString());
} elseif (Variable::TYPE_ARRAY === $val->type) {
if (isset(self::STRING_ONLY_HEADERS[$lower])) {
throw new \TypeError(\sprintf(
'Header "%s" must be of type string, array given',
$lower
));
}
foreach ($val->toArray()->iterateKeyed(true) as [$subKeyVar, $subVal]) {
$subKeyVar = $subKeyVar->resolveIndirect();
if (Variable::TYPE_STRING === $subKeyVar->type && !ctype_digit($subKeyVar->toString())) {
throw new \TypeError(\sprintf(
'Header "%s" must only contain numeric keys, "%s" found',
$name,
$subKeyVar->toString()
));
}
if (Variable::TYPE_INTEGER !== $subKeyVar->type
&& Variable::TYPE_STRING !== $subKeyVar->type) {
throw new \TypeError(\sprintf(
'Header "%s" must only contain numeric keys, "%s" found',
$name,
self::valueTypeName($subKeyVar)
));
}
$subVal = $subVal->resolveIndirect();
if (Variable::TYPE_STRING !== $subVal->type) {
throw new \TypeError(\sprintf(
'Header "%s" must only contain values of type string, %s found',
$name,
self::valueTypeName($subVal)
));
}
$lines[] = self::formatHeaderLine($name, $subVal->toString());
}
} else {
throw new \TypeError(\sprintf(
'Header "%s" must be of type array|string, %s given',
$name,
self::valueTypeName($val)
));
}
}
if ([] === $lines) {
return null;
}

return implode("\r\n", $lines);
}

/**
* php_mail_build_headers_elem() field name/value checks.
*
* @throws \ValueError
*/
private static function formatHeaderLine(string $name, string $value): string
{
if (!self::isValidHeaderName($name)) {
throw new \ValueError(\sprintf(
'Header name "%s" contains invalid characters',
$name
));
}
self::assertValidHeaderValue($name, $value);

return $name.': '.$value;
}

private static function isValidHeaderName(string $name): bool
{
$n = \strlen($name);
for ($i = 0; $i < $n; ++$i) {
$ord = \ord($name[$i]);
if ($ord < 33 || $ord > 126 || ':' === $name[$i]) {
return false;
}
}

return true;
}

/**
* @throws \ValueError
*/
private static function assertValidHeaderValue(string $name, string $value): void
{
$n = \strlen($value);
$i = 0;
while ($i < $n) {
$ch = $value[$i];
if ("\0" === $ch) {
throw new \ValueError(\sprintf(
'Header "%s" contains NULL character that is not allowed in the header',
$name
));
}
if ("\r" === $ch) {
if (($i + 1) >= $n || "\n" !== $value[$i + 1]) {
throw new \ValueError(\sprintf(
'Header "%s" contains CR character that is not allowed in the header',
$name
));
}
if (($i + 2) < $n && (' ' === $value[$i + 2] || "\t" === $value[$i + 2])) {
$i += 3;
continue;
}
throw new \ValueError(\sprintf(
'Header "%s" contains CRLF characters that are used as a line separator and are not allowed in the header',
$name
));
}
if ("\n" === $ch) {
if (($i + 1) < $n && (' ' === $value[$i + 1] || "\t" === $value[$i + 1])) {
$i += 2;
continue;
}
throw new \ValueError(\sprintf(
'Header "%s" contains LF character that is not allowed in the header',
$name
));
}
++$i;
}
}

private static function valueTypeName(Variable $var): string
{
if (EnumCaseSupport::isEnumCaseVariable($var)) {
return EnumCaseSupport::typeNameForVariable($var);
}

return match ($var->type) {
Variable::TYPE_NULL => 'null',
Variable::TYPE_BOOLEAN => 'bool',
Variable::TYPE_INTEGER => 'int',
Variable::TYPE_FLOAT => 'float',
Variable::TYPE_STRING => 'string',
Variable::TYPE_ARRAY => 'array',
Variable::TYPE_OBJECT => 'object',
default => 'unknown type',
};
}

/**
* Deliver via INI `sendmail_path` (mirrored host / `-d` / PHP_COMPILER_INI_SENDMAIL_PATH).
*/
Expand Down
3 changes: 1 addition & 2 deletions ext/standard/mail.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ public function execute(Frame $frame): void
$message = VmString::coercePathBuiltinArg($frame->calledArgs[2], 'mail', 2, 'message');
$headers = null;
if ($argc >= 4) {
$headers = VmString::coerceStringBuiltinArg($frame->calledArgs[3], 'mail', 3, 'additional_headers');
VmString::rejectNullByteBuiltinStringArg($headers, 'mail', 3, 'additional_headers');
$headers = VmMail::coerceAdditionalHeaders($frame->calledArgs[3]);
}
$extraParams = null;
if (5 === $argc) {
Expand Down
44 changes: 44 additions & 0 deletions test/compliance/cases/stdlib/mail_headers_array.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
--TEST--
stdlib mail() additional_headers array|string + ValueError (#21432, ext/standard/mail.c)
--INI--
sendmail_path={PWD}/mail_fixtures/mock_sendmail.sh
--FILE--
<?php
$mock = ini_get('sendmail_path');
$out = dirname($mock) . '/mock_sendmail.last';
@unlink($out);

try {
mail('a@b.c', 'subj', 'body', ['To' => 'evil@x']);
echo "TO_NO_THROW\n";
} catch (ValueError $e) {
echo (str_contains($e->getMessage(), '"To"') ? 'to_value_error' : 'to_other'), "\n";
}

try {
mail('a@b.c', 'subj', 'body', ['Subject' => 'x']);
echo "SUBJ_NO_THROW\n";
} catch (ValueError $e) {
echo (str_contains($e->getMessage(), '"Subject"') ? 'subj_value_error' : 'subj_other'), "\n";
}

try {
mail('a@b.c', 'subj', 'body', ['From' => "a\nb"]);
echo "LF_NO_THROW\n";
} catch (ValueError $e) {
echo (str_contains($e->getMessage(), 'LF') ? 'lf_value_error' : 'lf_other'), "\n";
}

@unlink($out);
$ok = mail('user@example.com', 'Hello', "Body\n", ['From' => 'noreply@example.com']);
var_export($ok);
echo "\n";
$raw = is_file($out) ? file_get_contents($out) : '';
echo (str_contains($raw, 'From: noreply@example.com') ? 'has_from' : 'no_from'), "\n";
@unlink($out);
--EXPECT--
to_value_error
subj_value_error
lf_value_error
true
has_from
18 changes: 18 additions & 0 deletions test/repro/issue_21432_mail_headers_array.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/**
* Repro #21432 — mail() array additional_headers ValueError parity.
*
* php bin/vm.php -d sendmail_path=…/mock_sendmail.sh test/repro/issue_21432_mail_headers_array.php
*/
try {
mail('a@b.c', 'subj', 'body', ['To' => 'evil@x']);
fwrite(STDERR, "NO_THROW\n");
exit(1);
} catch (ValueError $e) {
if (!str_contains($e->getMessage(), '"To"')) {
fwrite(STDERR, "BAD_MSG: ".$e->getMessage()."\n");
exit(1);
}
}
echo "OK\n";