feat(domains): Pruefung von DNS, Zuordnung und Zertifikat

Eine reine DNS-Pruefung sagt nichts darueber aus, ob der Webserver die
Domain auch dem richtigen Verzeichnis zuordnet - genau das ist der
haeufigste Fehler. Die Pruefung ruft die Domain deshalb von aussen auf
einen signierten Endpunkt auf. Kommt der Aufruf mit gueltigem Token an,
ist die ganze Kette bewiesen, denn nur diese Installation kann ein
solches Token erzeugen.

Erkannt und benannt werden: fehlender DNS-Eintrag, Domain-Weiterleitung
des Registrars, falsches Verzeichnis, ungueltiges Zertifikat.

Link-lokale Adressen werden nicht abgerufen - dort liegen bei Cloud-
Anbietern die Metadaten-Dienste.
This commit is contained in:
2026-08-03 18:11:40 +02:00
parent 8b6e05020b
commit 7238e7a937
3 changed files with 655 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Kurzlebiges, signiertes Token fuer die Domain-Pruefung.
*
* Die Pruefung ruft die zu testende Domain von aussen auf. Der dafuer
* noetige Endpunkt muss ohne Login erreichbar sein - deshalb ist er nur
* mit einem gueltigen Token ansprechbar und antwortet sonst mit 404.
*
* Das Token ist zustandslos (HMAC ueber den Zeitstempel), es muss also
* nichts zwischengespeichert werden. Kommt der Aufruf mit einem
* gueltigen Token an, ist damit zugleich bewiesen, dass er bei DIESER
* Installation gelandet ist - nur sie kennt das Geheimnis.
*/
final class CheckToken
{
/** Gueltigkeitsdauer in Sekunden. */
public const LIFETIME = 180;
public static function create(string $secret, ?int $now = null): string
{
$timestamp = (string) ($now ?? time());
return $timestamp . '.' . hash_hmac('sha256', $timestamp, $secret);
}
public static function isValid(string $token, string $secret, ?int $now = null): bool
{
$parts = explode('.', $token, 2);
if (count($parts) !== 2) {
return false;
}
[$timestamp, $signature] = $parts;
if ($timestamp === '' || !ctype_digit($timestamp)) {
return false;
}
if (abs(($now ?? time()) - (int) $timestamp) > self::LIFETIME) {
return false;
}
return hash_equals(hash_hmac('sha256', $timestamp, $secret), $signature);
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Vergleich von IP-Adressen mit einzelnen Adressen und CIDR-Bereichen.
* Wird fuer die Liste vertrauenswuerdiger Proxys und fuer den Schutz
* interner Netze verwendet.
*/
final class IpRange
{
/**
* Bereiche, die niemals eine oeffentliche Kurzlink-Domain sein
* koennen und deshalb bei der Domain-Pruefung nicht abgerufen
* werden. Besonders wichtig: 169.254.169.254 - ueber diese Adresse
* liefern Cloud-Anbieter Zugangsdaten aus.
*
* @var list<string>
*/
public const LINK_LOCAL = [
'169.254.0.0/16',
'fe80::/10',
];
/**
* @var list<string>
*/
public const PRIVATE_RANGES = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'100.64.0.0/10',
'127.0.0.0/8',
'0.0.0.0/8',
'::1/128',
'fc00::/7',
];
/**
* Prueft eine Adresse gegen eine Liste aus Einzeladressen und
* CIDR-Bereichen.
*
* @param list<string> $ranges
*/
public static function matchesAny(string $ip, array $ranges): bool
{
foreach ($ranges as $range) {
if (self::matches($ip, $range)) {
return true;
}
}
return false;
}
public static function matches(string $ip, string $range): bool
{
$ip = trim($ip);
$range = trim($range);
if ($ip === '' || $range === '') {
return false;
}
$binaryIp = @inet_pton($ip);
if ($binaryIp === false) {
return false;
}
if (!str_contains($range, '/')) {
$binaryRange = @inet_pton($range);
return $binaryRange !== false && hash_equals($binaryRange, $binaryIp);
}
[$subnet, $bits] = explode('/', $range, 2);
$binarySubnet = @inet_pton($subnet);
if ($binarySubnet === false || !ctype_digit($bits)) {
return false;
}
// IPv4 und IPv6 nicht miteinander vergleichen.
if (strlen($binarySubnet) !== strlen($binaryIp)) {
return false;
}
$prefix = (int) $bits;
$maxBits = strlen($binaryIp) * 8;
if ($prefix < 0 || $prefix > $maxBits) {
return false;
}
$wholeBytes = intdiv($prefix, 8);
$remainingBits = $prefix % 8;
if ($wholeBytes > 0 && !hash_equals(
substr($binarySubnet, 0, $wholeBytes),
substr($binaryIp, 0, $wholeBytes),
)) {
return false;
}
if ($remainingBits === 0) {
return true;
}
$mask = 0xFF << (8 - $remainingBits) & 0xFF;
return (ord($binaryIp[$wholeBytes]) & $mask) === (ord($binarySubnet[$wholeBytes]) & $mask);
}
public static function isLinkLocal(string $ip): bool
{
return self::matchesAny($ip, self::LINK_LOCAL);
}
public static function isPrivate(string $ip): bool
{
return self::matchesAny($ip, self::PRIVATE_RANGES);
}
/**
* Zerlegt eine kommaseparierte Konfigurationsangabe.
*
* @return list<string>
*/
public static function parseList(string $value): array
{
$parts = array_map('trim', explode(',', $value));
return array_values(array_filter($parts, static fn (string $part): bool => $part !== ''));
}
}