Merge remote-tracking branch 'ghsa-crj4-pmx2-cxqm/advisory-fix-1'

This commit is contained in:
djmaze 2026-11-03 15:23:55 +01:00
commit fcf45b51ac
21 changed files with 930 additions and 30 deletions

71
test/crypt-forgery.php Normal file
View file

@ -0,0 +1,71 @@
<?php
// Run with: php test/crypt-forgery.php
// Crypt logs through SnappyMail\Log and reads its cipher from RainLoop\Api at
// load time, both of which need the whole application. Stand-ins are declared
// before the autoloader could load the real classes; Config returns defaults.
namespace SnappyMail {
abstract class Log
{
public static function __callStatic(string $name, array $arguments) : void {}
}
}
namespace RainLoop {
abstract class Api
{
public static function Config() : object
{
return new class {
public function Get(string $section, string $name, $default = null) { return $default; }
};
}
}
}
namespace {
// Enable SnappyMail Api and include index file
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = true;
require __DIR__ . '/../index.php';
// define('APP_SALT', 'test-salt-'.bin2hex(random_bytes(8)));
$_COOKIE['smctoken'] = base64_encode(random_bytes(16));
function check(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$Crypt = 'SnappyMail\\Crypt';
check(is_callable('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt'), 'test needs sodium');
// Forge a token without knowing APP_SALT: the attacker picks the 16 byte
// salt, which under the old derivation was the whole XXTEA key.
$salt = random_bytes(16);
$forged = \MailSo\Base\Xxtea::encrypt(json_encode(['e' => 'victim@example.com', 'admin' => true]), $salt);
foreach (['xxtea', 'XXTEA', 'Xxtea'] as $algo) {
check(null === $Crypt::Decrypt([$algo, $salt, $forged], 'server-key'), "forged {$algo} token accepted");
}
// Unknown algorithm names are refused rather than dispatched.
check(null === $Crypt::Decrypt(['jsonDecode', $salt, $forged], 'server-key'), 'arbitrary method name dispatched');
// Normal tokens still round-trip, and only with the right key.
$data = ['e' => 'user@example.com', 'n' => 42];
check($data === $Crypt::Decrypt($Crypt::Encrypt($data, 'server-key'), 'server-key'), 'sodium round trip broke');
check($data === $Crypt::DecryptUrlSafe($Crypt::EncryptUrlSafe($data, 'server-key'), 'server-key'), 'url-safe round trip broke');
check(null === $Crypt::Decrypt($Crypt::Encrypt($data, 'server-key'), 'other-key'), 'decrypted with the wrong key');
// The xxtea primitives themselves now depend on the server secret.
$salt = random_bytes(16);
$cipher = $Crypt::XxteaEncrypt('{"a":1}', $salt, 'server-key');
check('{"a":1}' === $Crypt::XxteaDecrypt($cipher, $salt, 'server-key'), 'xxtea round trip broke');
check('{"a":1}' !== $Crypt::XxteaDecrypt($cipher, $salt, 'other-key'), 'xxtea key does not depend on the secret');
check('{"a":1}' !== \MailSo\Base\Xxtea::decrypt($cipher, $salt), 'xxtea still keyed by the salt alone');
echo "crypt-forgery: ok\n";
}

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

@ -0,0 +1,48 @@
<?php
// Run with: php test/header-injection.php
// Enable SnappyMail Api and include index file
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = true;
require __DIR__ . '/../index.php';
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";

45
test/raw-inline-type.php Normal file
View file

@ -0,0 +1,45 @@
<?php
// Run with: php test/raw-inline-type.php
// Enable SnappyMail Api and include index file
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = true;
require __DIR__ . '/../index.php';
function check(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
class RawInlineType
{
use \RainLoop\Actions\Raw;
}
$cases = [
// shown as declared
'image/png' => 'image/png',
'IMAGE/JPEG; name="x.jpg"' => 'image/jpeg',
'application/pdf' => 'application/pdf',
'video/mp4' => 'video/mp4',
'audio/ogg; codecs=opus' => 'audio/ogg',
'text/plain; charset=utf-8' => 'text/plain',
// shown as source, never rendered
'text/html' => 'text/plain',
'Text/HTML; charset=utf-8' => 'text/plain',
'text/xml' => 'text/plain',
// downloaded
'image/svg+xml' => null,
'application/xhtml+xml' => null,
'application/xml' => null,
'application/octet-stream' => null,
'multipart/related' => null,
'' => null,
];
foreach ($cases as $in => $expected) {
$got = RawInlineType::inlineContentType($in);
check($expected === $got, "'{$in}': expected ".var_export($expected, true).', got '.var_export($got, true));
}
echo "raw-inline-type: ok\n";

37
test/smime-partid.php Normal file
View file

@ -0,0 +1,37 @@
<?php
// Run with: php test/smime-partid.php
// Enable SnappyMail Api and include index file
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = true;
require __DIR__ . '/../index.php';
function check(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$actions = (new ReflectionClass(\RainLoop\Actions::class))->newInstanceWithoutConstructor();
$params = new ReflectionProperty(\RainLoop\Actions::class, 'aCurrentActionParams');
$method = new ReflectionMethod(\RainLoop\Actions::class, 'smimePartIdParam');
$read = function (string $value) use ($actions, $params, $method) : ?string {
$params->setValue($actions, ['partId' => $value]);
try {
return $method->invoke($actions, 'partId');
} catch (\RainLoop\Exceptions\ClientException $e) {
return null;
}
};
foreach (['', '1', '2', '1.2', '1.2.10', 'TEXT'] as $ok) {
check($ok === $read($ok), "valid part id '{$ok}' refused");
}
foreach (["1]\r\nA1 LOGOUT\r\n", '1] BODY[HEADER', "1\r\nA1 LOGOUT", '1 2', '0', '1.0', '01', '1.', '.1', 'text', 'HEADER'] as $bad) {
check(null === $read($bad), 'part id '.json_encode($bad).' accepted');
}
// Surrounding whitespace is trimmed away, and the trimmed value is what is used.
check('1' === $read("1\n"), 'trailing newline not trimmed');
echo "smime-partid: ok\n";

61
test/smime-verify.php Normal file
View file

@ -0,0 +1,61 @@
<?php
// Enable SnappyMail Api and include index file
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = true;
require __DIR__ . '/../index.php';
function check(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$dir = sys_get_temp_dir().'/smime-verify-test-'.getmypid();
mkdir($dir);
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$cert = openssl_csr_sign(openssl_csr_new(['commonName' => 'signer@example.com'], $key), null, $key, 1);
openssl_x509_export_to_file($cert, "{$dir}/cert.pem");
openssl_pkey_export_to_file($key, "{$dir}/key.pem");
$content = "Content-Type: text/plain\r\n\r\nPay 100 EUR to account A.\r\n";
file_put_contents("{$dir}/in.txt", $content);
$smime = new \SnappyMail\SMime\OpenSSL($dir);
// Detached (multipart/signed) and opaque (application/pkcs7-mime) signatures.
foreach (['detached' => PKCS7_DETACHED, 'opaque' => 0] as $kind => $flags) {
check(openssl_pkcs7_sign("{$dir}/in.txt", "{$dir}/signed.eml", "file://{$dir}/cert.pem", "file://{$dir}/key.pem", [], $flags),
"{$kind}: signing failed: ".openssl_error_string());
$signed = file_get_contents("{$dir}/signed.eml");
$opaque = 'opaque' === $kind;
$result = $smime->verify($signed, null, $opaque);
check(true === $result['success'], "{$kind}: intact message not reported as validly signed");
if ($opaque) {
check(str_contains($result['body'], 'Pay 100 EUR to account A.'), "{$kind}: content not extracted");
}
// Alter the signed content. Detached: the clear text part. Opaque: a byte of
// the base64 payload, chosen inside the encapsulated content.
if ($opaque) {
[$head, $b64] = explode("\n\n", str_replace("\r\n", "\n", $signed), 2);
$der = base64_decode($b64);
$pos = strpos($der, 'account A');
check(false !== $pos, "{$kind}: test could not find the content to alter");
$der[$pos + 8] = 'B';
$tampered = $head."\n\n".chunk_split(base64_encode($der), 64, "\n");
} else {
$tampered = str_replace('account A', 'account B', $signed);
}
check($tampered !== $signed, "{$kind}: test failed to alter the message");
$result = $smime->verify($tampered, null, $opaque);
check(false === $result['success'], "{$kind}: ALTERED message reported as validly signed");
if ($opaque) {
check(str_contains($result['body'], 'account B'), "{$kind}: altered content no longer readable");
}
}
array_map('unlink', glob("{$dir}/*"));
rmdir($dir);
echo "smime-verify: ok\n";

View file

@ -2,7 +2,7 @@
// Enable SnappyMail Api and include index file
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = true;
require '../index.php';
require __DIR__ . '/../index.php';
/**
* Get SSO hash

40
test/ssrf-ranges.php Normal file
View file

@ -0,0 +1,40 @@
<?php
// Run with: php test/ssrf-ranges.php (needs no network: only IP literals)
// Enable SnappyMail Api and include index file
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = true;
require __DIR__ . '/../index.php';
function check(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$Request = 'SnappyMail\\Host';
$public = fn(string $host) => $Request::URIHasPublicHost("http://{$host}/x.png");
foreach (['100.64.0.1', '100.100.100.200', '100.127.255.254', '192.0.0.1', '198.18.0.1',
'198.19.255.254', '224.0.0.1', '239.255.255.250', '[fec0::1]', '[ff02::1]',
'127.0.0.1', '10.0.0.1', '169.254.169.254', '[::1]', '[fc00::1]', '[::ffff:100.64.0.1]'] as $host) {
check(!$public($host), "{$host} treated as public");
}
foreach (['100.63.255.255', '100.128.0.1', '198.17.255.255', '198.20.0.1', '8.8.8.8',
'[2001:4860:4860::8888]'] as $host) {
check($public($host), "{$host} wrongly blocked");
}
// The server's own addresses. With --network=none only loopback exists, so
// hand the cache an address to stand for this host's public IP.
$prop = new ReflectionProperty($Request, 'aLocalAddresses');
$prop->setValue(null, [inet_pton('203.0.113.7'), inet_pton('2001:db8:1::53')]);
check(!$public('203.0.113.7'), "the server's own IPv4 address treated as public");
check(!$public('[2001:db8:1::53]'), "the server's own IPv6 address treated as public");
check($public('203.0.113.8'), 'a neighbouring address wrongly blocked');
check($public('snappymail.eu'), 'snapymail.eu wrongly blocked');
check(!$public('localhost'), 'localhost should be blocked');
echo "ssrf-ranges: ok\n";

View file

@ -0,0 +1,75 @@
<?php
// Run with: php test/two-factor-reauth.php
// Enable SnappyMail Api and include index file
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = true;
require __DIR__ . '/../index.php';
require dirname(__DIR__).'/plugins/two-factor-auth/index.php';
require_once dirname(__DIR__).'/plugins/two-factor-auth/providers/interface.php';
function check(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
// Storage, provider and logger replaced; the gate itself is the real code.
class TestTwoFactor extends TwoFactorAuthPlugin
{
public array $info = [];
public array $spent = [];
protected function getTwoFactorInfo(\RainLoop\Model\MainAccount $oAccount, bool $bRemoveSecret = false) : array { return $this->info; }
protected function TwoFactorAuthProvider(\RainLoop\Model\MainAccount $oAccount) : ?\TwoFactorAuthInterface
{
return new class implements \TwoFactorAuthInterface {
public function Label() : string { return 'test'; }
public function VerifyCode(string $sSecret, string $sCode) : bool { return 'SECRET' === $sSecret && '123456' === $sCode; }
public function CreateSecret() : string { return 'SECRET'; }
};
}
protected function Logger() : \MailSo\Log\Logger { return new \MailSo\Log\Logger(false); }
protected function removeBackupCodeFromTwoFactorInfo(\RainLoop\Model\MainAccount $oAccount, string $sCode) : bool { $this->spent[] = $sCode; return true; }
}
$plugin = (new ReflectionClass(TestTwoFactor::class))->newInstanceWithoutConstructor();
$actions = (new ReflectionClass(\RainLoop\Actions::class))->newInstanceWithoutConstructor();
$manager = (new ReflectionClass(\RainLoop\Plugins\Manager::class))->newInstanceWithoutConstructor();
(new ReflectionProperty(\RainLoop\Plugins\Manager::class, 'oActions'))->setValue($manager, $actions);
(new ReflectionProperty(\RainLoop\Plugins\AbstractPlugin::class, 'oPluginManager'))->setValue($plugin, $manager);
$account = (new ReflectionClass(\RainLoop\Model\MainAccount::class))->newInstanceWithoutConstructor();
$gate = (new ReflectionMethod(TwoFactorAuthPlugin::class, 'requireCurrentCode'));
$params = new ReflectionProperty(\RainLoop\Actions::class, 'aCurrentActionParams');
$allowed = function (array $values) use ($plugin, $actions, $account, $gate, $params) : bool {
$params->setValue($actions, $values);
try {
$gate->invoke($plugin, $account);
return true;
} catch (\RainLoop\Exceptions\ClientException $e) {
return false;
}
};
// Not set up, or set up but not enabled: nothing to protect yet.
$plugin->info = ['IsSet' => false, 'Enable' => false, 'Secret' => ''];
check($allowed([]), 'blocked although 2FA is not set up');
$plugin->info = ['IsSet' => true, 'Enable' => false, 'Secret' => 'SECRET', 'BackupCodes' => '111111111'];
check($allowed([]), 'blocked although 2FA is not enabled');
// Enabled: a valid TOTP or backup code is required.
$plugin->info = ['IsSet' => true, 'Enable' => true, 'Secret' => 'SECRET', 'BackupCodes' => '111111111 222222222'];
check(!$allowed([]), 'change allowed with no code while enabled');
check(!$allowed(['Code' => '000000']), 'change allowed with a wrong code');
check(!$allowed(['Code' => '33333333']), 'change allowed with an unknown backup code');
check($allowed(['Code' => '123456']), 'valid TOTP code refused');
check($allowed(['Code' => '222222222']), 'valid backup code refused');
check(['222222222'] === $plugin->spent, 'used backup code was not spent');
// Backup codes come from random_int now, not rand().
$source = file_get_contents(dirname(__DIR__).'/plugins/two-factor-auth/index.php');
check(!preg_match('/\\\\rand\(/', $source) && str_contains($source, '\\random_int('), 'backup codes not from random_int()');
echo "two-factor-reauth: ok\n";