@ZaphodB fix(security): strip line breaks from decoded and outgoing header values

DecodeHeaderValue removed CR and LF before decoding encoded-words, so an
encoded-word could still decode to a line break. A message whose Message-ID
was =?utf-8?b?...?= with CRLF inside reached the client with the break intact,
the client copies Message-ID into In-Reply-To when replying or forwarding, and
SetInReplyTo only trimmed it. The reply then carried header lines chosen by the
sender of the original, or a body if the payload held a blank line, sent and
DKIM-signed as the user and saved to Sent. Subject on Edit-as-new had the same
shape through the ASCII path.

Strip CR, LF and NUL in two places: after decoding in DecodeHeaderValue, and in
Header itself, which every outgoing header is built through, so a value that
arrives by another route (client parameters, plugins) cannot break the line
either. Folding is unaffected; __toString adds it back.

test/header-injection.php covers B and Q encoded-words, parsing, outgoing
In-Reply-To and Subject, and that long values still fold.
This commit is contained in:
the-djmaze 2026-10-24 11:50:10 +02:00
parent 61ebcb6ed4
commit b6607bf6e1
3 changed files with 64 additions and 3 deletions

52
test/header-injection.php Normal file
View file

@ -0,0 +1,52 @@
<?php
// Run with: php test/header-injection.php
define('APP_VERSION', 'test');
spl_autoload_register(static function (string $class): void {
$file = dirname(__DIR__).'/tachyon/v/0.0.0/app/libraries/'.str_replace('\\', '/', $class).'.php';
if (is_file($file)) {
require_once $file;
}
});
function check(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
// An encoded-word that decodes to a line break must not survive decoding.
$payload = "<x@example.com>\r\nBcc: attacker@example.net";
$encoded = '=?utf-8?b?'.base64_encode($payload).'?=';
$decoded = \MailSo\Base\Utils::DecodeHeaderValue($encoded);
check(!preg_match('/[\r\n]/', $decoded), 'B-encoded CRLF survived DecodeHeaderValue');
check(str_contains($decoded, 'Bcc: attacker@example.net'), 'decoded text was lost, not just its line break');
$decoded = \MailSo\Base\Utils::DecodeHeaderValue('=?utf-8?q?a=0D=0AX-Injected:_1?=');
check(!preg_match('/[\r\n]/', $decoded), 'Q-encoded CRLF survived DecodeHeaderValue');
// The same through header parsing, as for an incoming Message-ID.
$header = \MailSo\Mime\Header::NewInstanceFromEncodedString("Message-ID: {$encoded}");
check(!preg_match('/[\r\n]/', $header->Value()), 'parsed Message-ID kept a line break');
// Outgoing: a value handed straight to the message (In-Reply-To and Subject come
// from client parameters) must serialise as one header, whatever it contains.
$message = new \MailSo\Mime\Message();
$message->SetInReplyTo("<x@example.com>\r\nBcc: attacker@example.net");
$message->SetSubject("Re: hello\nX-Injected: 1\r\n\r\nbody text");
$raw = stream_get_contents($message->ToStream());
[$headers] = explode("\r\n\r\n", $raw, 2);
foreach (explode("\r\n", $headers) as $line) {
check(!preg_match('/^(Bcc|X-Injected):/i', $line), "injected header line: {$line}");
}
check(!str_contains($headers, "\n\n"), 'blank line injected into the header block');
check(str_contains($raw, 'In-Reply-To: <x@example.com> Bcc: attacker@example.net'), 'In-Reply-To was not kept as one line');
// Folding must still work: a long value is wrapped with CRLF + space only.
$header = new \MailSo\Mime\Header('Subject', str_repeat('word ', 40));
foreach (array_slice(explode("\r\n", (string) $header), 1) as $continuation) {
check(str_starts_with($continuation, ' '), 'folded line does not start with whitespace');
}
echo "header-injection: ok\n";