93 lines
4.6 KiB
PHP
93 lines
4.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Steuerkram\Services\SmtpMailer;
|
||
use Steuerkram\Services\SsrfGuard;
|
||
|
||
/**
|
||
* Test 98: SSRF-Schutz gegen DNS-Rebinding/TOCTOU im SMTP-Pfad (Issue #193).
|
||
* SsrfGuard::resolvePinnedHost() liefert für einen SMTP-Host die bereits
|
||
* geprüfte(n) IP-Adresse(n) zurück; SmtpMailer::buildConnectTarget() nutzt
|
||
* diese für stream_socket_client(), statt den Hostnamen beim eigentlichen
|
||
* Verbindungsaufbau erneut über den System-Resolver aufzulösen.
|
||
*/
|
||
class SmtpSsrfPinningTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 98: SSRF-Schutz gegen DNS-Rebinding im SMTP-Pfad (IP-Pinning)';
|
||
|
||
public function run(): void
|
||
{
|
||
// SsrfGuard::resolvePinnedHost() liefert für einen gültigen Hostnamen
|
||
// mindestens eine geprüfte, öffentliche IP-Adresse zurück.
|
||
$ips = SsrfGuard::resolvePinnedHost('example.com');
|
||
$this->assertNotEmpty($ips, 'resolvePinnedHost() liefert IP-Adressen für einen gültigen Hostnamen');
|
||
foreach ($ips as $ip) {
|
||
$this->assert(
|
||
filter_var($ip, FILTER_VALIDATE_IP) !== false,
|
||
'Jeder Eintrag ist eine gültige IP-Adresse (Eintrag: ' . $ip . ')'
|
||
);
|
||
}
|
||
|
||
// IP-Literal als Host: kein DNS-Lookup nötig, aber weiterhin gegen
|
||
// private/interne Bereiche geprüft.
|
||
$this->assertEquals(['8.8.8.8'], SsrfGuard::resolvePinnedHost('8.8.8.8'), 'IP-Literal wird unverändert zurückgegeben');
|
||
$this->assertRejected('127.0.0.1');
|
||
$this->assertRejected('localhost');
|
||
$this->assertRejected('169.254.169.254');
|
||
|
||
// SmtpMailer::buildConnectTarget(): verbindet zur gepinnten IP, nicht
|
||
// erneut zum Hostnamen – und hält den Hostnamen für SNI/Zertifikats-
|
||
// prüfung über den Stream-Context ('peer_name') fest.
|
||
$mailer = new SmtpMailer('example.com', 465, 'ssl', 'user', 'pass', 'from@example.com', 'Absender');
|
||
[$address, $ctx] = $this->buildConnectTarget($mailer);
|
||
|
||
$this->assert(str_starts_with($address, 'ssl://'), 'Verbindungsziel nutzt das SSL-Schema für encryption=ssl');
|
||
$host = substr($address, strlen('ssl://'));
|
||
$this->assert(
|
||
filter_var($host, FILTER_VALIDATE_IP) !== false,
|
||
'Verbindungsziel enthält eine IP-Adresse statt des Hostnamens (Ziel: ' . $address . ')'
|
||
);
|
||
$this->assert(
|
||
in_array($host, SsrfGuard::resolvePinnedHost('example.com'), true),
|
||
'Ziel-IP stammt aus der SsrfGuard-Auflösung des Hostnamens (Ziel: ' . $host . ')'
|
||
);
|
||
|
||
$sslOptions = stream_context_get_options($ctx)['ssl'] ?? [];
|
||
$this->assertEquals('example.com', $sslOptions['peer_name'] ?? null, 'Stream-Context pinnt peer_name auf den ursprünglichen Hostnamen (für SNI/Zertifikatsprüfung)');
|
||
$this->assert(($sslOptions['verify_peer'] ?? false) === true, 'Zertifikatsprüfung bleibt trotz IP-Verbindung aktiv (verify_peer)');
|
||
$this->assert(($sslOptions['verify_peer_name'] ?? false) === true, 'Zertifikatsprüfung bleibt trotz IP-Verbindung aktiv (verify_peer_name)');
|
||
|
||
// tcp:// für unverschlüsselt/STARTTLS statt ssl://
|
||
$mailerTls = new SmtpMailer('example.com', 587, 'tls', 'user', 'pass', 'from@example.com', 'Absender');
|
||
[$addressTls] = $this->buildConnectTarget($mailerTls);
|
||
$this->assert(str_starts_with($addressTls, 'tcp://'), 'Verbindungsziel nutzt das TCP-Schema für encryption=tls (STARTTLS folgt später)');
|
||
|
||
// Private/interne SMTP-Hosts werden weiterhin abgelehnt, bevor überhaupt
|
||
// ein Verbindungsversuch unternommen wird.
|
||
$mailerPrivate = new SmtpMailer('127.0.0.1', 25, 'none', 'user', 'pass', 'from@example.com', 'Absender');
|
||
try {
|
||
$this->buildConnectTarget($mailerPrivate);
|
||
$this->assert(false, 'buildConnectTarget() lehnt private SMTP-Hosts ab');
|
||
} catch (\RuntimeException $e) {
|
||
$this->assert(true, 'buildConnectTarget() lehnt private SMTP-Hosts ab');
|
||
}
|
||
}
|
||
|
||
private function assertRejected(string $host): void
|
||
{
|
||
try {
|
||
SsrfGuard::resolvePinnedHost($host);
|
||
$this->assert(false, 'Privater/interner Host (' . $host . ') wird abgelehnt');
|
||
} catch (\RuntimeException $e) {
|
||
$this->assert(true, 'Privater/interner Host (' . $host . ') wird abgelehnt');
|
||
}
|
||
}
|
||
|
||
private function buildConnectTarget(SmtpMailer $mailer): array
|
||
{
|
||
$method = new \ReflectionMethod($mailer, 'buildConnectTarget');
|
||
$method->setAccessible(true);
|
||
return $method->invoke($mailer);
|
||
}
|
||
}
|