diff --git a/ext/standard/VmMail.php b/ext/standard/VmMail.php index f0e00a91a8e..a6963e79a55 100644 --- a/ext/standard/VmMail.php +++ b/ext/standard/VmMail.php @@ -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. */ @@ -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). */ diff --git a/ext/standard/mail.php b/ext/standard/mail.php index 392ec3ba66a..c53891cc4e5 100644 --- a/ext/standard/mail.php +++ b/ext/standard/mail.php @@ -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) { diff --git a/test/compliance/cases/stdlib/mail_headers_array.phpt b/test/compliance/cases/stdlib/mail_headers_array.phpt new file mode 100644 index 00000000000..a1ba096af28 --- /dev/null +++ b/test/compliance/cases/stdlib/mail_headers_array.phpt @@ -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-- + '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 diff --git a/test/repro/issue_21432_mail_headers_array.php b/test/repro/issue_21432_mail_headers_array.php new file mode 100644 index 00000000000..3c07f5944b9 --- /dev/null +++ b/test/repro/issue_21432_mail_headers_array.php @@ -0,0 +1,18 @@ + '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";