Steuerkram/tests/98_SmtpSsrfPinningTest.php

93 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}