From b6607bf6e16955fab34bbbcc877f7418be190e57 Mon Sep 17 00:00:00 2001 From: the-djmaze <> Date: Sat, 24 Oct 2026 11:50:10 +0200 Subject: [PATCH] =?UTF-8?q?@ZaphodB=20fix(security):=20strip=20line=20brea?= =?UTF-8?q?ks=20from=20decoded=20and=20outgoing=20header=20values=20Decode?= =?UTF-8?q?HeaderValue=20removed=20CR=20and=20LF=20before=20decoding=20enc?= =?UTF-8?q?oded-words,=20so=20an=20encoded-word=20could=20still=20decode?= =?UTF-8?q?=20to=20a=20line=20break.=20A=20message=20whose=20Message-ID=20?= =?UTF-8?q?was=20=3D=3Futf-8=3Fb=3F...=3F=3D=20with=20CRLF=20inside=20reac?= =?UTF-8?q?hed=20the=20client=20with=20the=20break=20intact,=20the=20clien?= =?UTF-8?q?t=20copies=20Message-ID=20into=20In-Reply-To=20when=20replying?= =?UTF-8?q?=20or=20forwarding,=20and=20SetInReplyTo=20only=20trimmed=20it.?= =?UTF-8?q?=20The=20reply=20then=20carried=20header=20lines=20chosen=20by?= =?UTF-8?q?=20the=20sender=20of=20the=20original,=20or=20a=20body=20if=20t?= =?UTF-8?q?he=20payload=20held=20a=20blank=20line,=20sent=20and=20DKIM-sig?= =?UTF-8?q?ned=20as=20the=20user=20and=20saved=20to=20Sent.=20Subject=20on?= =?UTF-8?q?=20Edit-as-new=20had=20the=20same=20shape=20through=20the=20ASC?= =?UTF-8?q?II=20path.?= 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. --- .../0.0.0/app/libraries/MailSo/Base/Utils.php | 6 ++- .../app/libraries/MailSo/Mime/Header.php | 9 +++- test/header-injection.php | 52 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 test/header-injection.php diff --git a/snappymail/v/0.0.0/app/libraries/MailSo/Base/Utils.php b/snappymail/v/0.0.0/app/libraries/MailSo/Base/Utils.php index f16598be8..d96e2f74c 100644 --- a/snappymail/v/0.0.0/app/libraries/MailSo/Base/Utils.php +++ b/snappymail/v/0.0.0/app/libraries/MailSo/Base/Utils.php @@ -300,7 +300,11 @@ abstract class Utils $sValue = static::ConvertEncoding($sValue, $sMainCharset, Enumerations\Charset::UTF_8); } - return $sValue; + // The strip above runs before decoding, so an encoded-word such as + // =?utf-8?b?...?= can still decode to a CR or LF. A decoded header value + // is one line; a line break here would be carried into the headers of a + // reply (In-Reply-To is copied from Message-ID) and inject new ones. + return \preg_replace('/[\r\n\0]+/', ' ', $sValue); } public static function RemoveHeaderFromHeaders(string $sIncHeaders, array $aHeadersToRemove = array()) : string diff --git a/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Header.php b/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Header.php index f48cdf0f4..1abbb989e 100644 --- a/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Header.php +++ b/snappymail/v/0.0.0/app/libraries/MailSo/Mime/Header.php @@ -37,8 +37,13 @@ class Header implements \JsonSerializable private function initInputData(string $sName, string $sValue, string $sEncodedValueForReparse) : void { - $this->sName = \trim($sName); - $this->sFullValue = \trim($sValue); + // A header is one logical line. A CR or LF in a name or value lets the + // value end its own header and start another (or the body), and values + // reach here from decoded encoded-words and from client parameters, so + // no caller can be relied on to have stripped them. Folding is added + // back by __toString(). + $this->sName = \trim(\preg_replace('/[\r\n\0]+/', ' ', $sName)); + $this->sFullValue = \trim(\preg_replace('/[\r\n\0]+/', ' ', $sValue)); $this->sEncodedValue = ''; if (\strlen($sEncodedValueForReparse) && ($this->IsEmail() || $this->IsSubject() || $this->IsParameterized())) { diff --git a/test/header-injection.php b/test/header-injection.php new file mode 100644 index 000000000..a1115151d --- /dev/null +++ b/test/header-injection.php @@ -0,0 +1,52 @@ +\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("\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: 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";