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