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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 !== ''));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user