mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-11-04 03:18:49 +03:00
Merge remote-tracking branch 'ghsa-crj4-pmx2-cxqm/advisory-fix-1'
This commit is contained in:
commit
fcf45b51ac
21 changed files with 930 additions and 30 deletions
71
test/crypt-forgery.php
Normal file
71
test/crypt-forgery.php
Normal 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
48
test/header-injection.php
Normal 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
45
test/raw-inline-type.php
Normal 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
37
test/smime-partid.php
Normal 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
61
test/smime-verify.php
Normal 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";
|
||||
|
|
@ -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
40
test/ssrf-ranges.php
Normal 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";
|
||||
75
test/two-factor-reauth.php
Normal file
75
test/two-factor-reauth.php
Normal 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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue