Files
urlshort-qr-gen/src/Support/UrlValidator.php
T
florian.wilde f48f7916b3 feat(links): Kurzlinks, Weiterleitung und Klickstatistik
Slugs aus 34 Zeichen (0 und 1 fehlen wegen Verwechslung mit o und l),
kollisionssicher mit wachsender Laenge. Wunsch-Slugs werden normalisiert
und gegen eine Liste reservierter Pfade geprueft.

Die URL-Validierung kommt bewusst ohne FILTER_VALIDATE_URL aus: dieses
verwirft jedes Nicht-ASCII-Zeichen und lehnt damit Umlaut-Domains und
Umlaute im Pfad ab. Erlaubt sind nur http und https, darin aber auch
IDN, IPv6 und interne Hostnamen ohne Punkt. Gespeichert wird die
lesbare Schreibweise; Punycode entsteht erst im Location-Header, weil
HTTP-Header laut RFC 7230 ASCII sein muessen.

Die Statistik speichert keine IP-Adressen (optional nur einen taeglich
rotierenden Hash) und laeuft nach dem Ausliefern der Weiterleitung.
2026-08-03 18:11:39 +02:00

277 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
/**
* Validierung und Normalisierung der Ziel-URLs.
*
* Erlaubt sind ausschliesslich http und https. Innerhalb dieser Schemata
* ist die Pruefung bewusst grosszuegig: internationalisierte Domains
* ("müller.de"), Umlaute in Pfad und Query, IPv6-Adressen und interne
* Hostnamen ohne Punkt ("intranet", "nas:5000") sind zulaessig.
*
* FILTER_VALIDATE_URL wird absichtlich NICHT verwendet - es verwirft
* jedes Nicht-ASCII-Zeichen und lehnt damit genau die Faelle ab, die in
* der Praxis am haeufigsten vorkommen.
*
* Gespeichert wird die lesbare Schreibweise. Erst beim Erzeugen des
* Location-Headers wird der Host per Punycode nach ASCII gewandelt.
*/
final class UrlValidator
{
public const MAX_LENGTH = 2048;
/** Maximale Laenge des Hostnamens nach RFC 1035. */
private const MAX_HOST_LENGTH = 253;
/**
* Bereitet eine Benutzereingabe auf:
* - Leerraum entfernen, fehlendes Schema durch https ersetzen
* - Schema und Host in Kleinbuchstaben
* - Pfad, Query und Fragment prozentkodieren (ohne Doppelkodierung)
*
* Der Host bleibt in seiner lesbaren Form erhalten.
*/
public static function normalize(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
// Schema-relative URLs (//example.com) auf https anheben.
if (str_starts_with($url, '//')) {
$url = 'https:' . $url;
} elseif (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $url)) {
// Kein Schema angegeben -> https ergaenzen.
$url = 'https://' . $url;
}
$parts = self::split($url);
if ($parts === null) {
return $url;
}
// Fremde Schemata unveraendert lassen; die Validierung lehnt sie ab.
if (!in_array($parts['scheme'], ['http', 'https'], true)) {
return $url;
}
$result = $parts['scheme'] . '://';
if ($parts['userinfo'] !== '') {
$result .= self::encode($parts['userinfo'], ':') . '@';
}
$result .= $parts['host'];
if ($parts['port'] !== '') {
$result .= ':' . $parts['port'];
}
$result .= self::encode($parts['path'], "/:@!$&'()*+,;=");
if ($parts['query'] !== '') {
$result .= '?' . self::encode($parts['query'], "/?:@!$&'()*+,;=");
}
if ($parts['fragment'] !== '') {
$result .= '#' . self::encode($parts['fragment'], "/?:@!$&'()*+,;=");
}
return $result;
}
/**
* Erzeugt die ASCII-Form fuer den Location-Header.
* HTTP-Header duerfen laut RFC 7230 kein UTF-8 enthalten.
*/
public static function toAsciiUrl(string $url): string
{
$parts = self::split($url);
if ($parts === null || $parts['host'] === '' || Punycode::isAscii($parts['host'])) {
return $url;
}
$result = $parts['scheme'] . '://';
if ($parts['userinfo'] !== '') {
$result .= $parts['userinfo'] . '@';
}
$result .= Punycode::encodeDomain($parts['host']);
if ($parts['port'] !== '') {
$result .= ':' . $parts['port'];
}
$result .= $parts['path'];
if ($parts['query'] !== '') {
$result .= '?' . $parts['query'];
}
if ($parts['fragment'] !== '') {
$result .= '#' . $parts['fragment'];
}
return $result;
}
public static function isValid(string $url): bool
{
return self::validationError($url) === null;
}
/**
* Liefert eine Fehlermeldung oder null, wenn die URL gueltig ist.
*/
public static function validationError(string $url): ?string
{
if (trim($url) === '') {
return 'Bitte eine Ziel-URL angeben.';
}
if (strlen($url) > self::MAX_LENGTH) {
return sprintf('Die URL darf hoechstens %d Zeichen lang sein.', self::MAX_LENGTH);
}
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return 'Die URL enthaelt ungueltige Steuerzeichen.';
}
$parts = self::split($url);
if ($parts === null) {
// Schemata ohne "//" (javascript:, data:, mailto:) klar benennen.
if (preg_match('#^([a-zA-Z][a-zA-Z0-9+.-]*):#', trim($url), $matches) === 1
&& !in_array(strtolower($matches[1]), ['http', 'https'], true)) {
return 'Nur http:// und https:// sind als Ziel erlaubt.';
}
return 'Die URL ist syntaktisch ungueltig.';
}
if (!in_array($parts['scheme'], ['http', 'https'], true)) {
return 'Nur http:// und https:// sind als Ziel erlaubt.';
}
if ($parts['host'] === '') {
return 'Die URL enthaelt keinen gueltigen Hostnamen.';
}
if (strlen($parts['host']) > self::MAX_HOST_LENGTH) {
return 'Der Hostname der URL ist zu lang.';
}
if ($parts['port'] !== '') {
$port = (int) $parts['port'];
if ($port < 1 || $port > 65535) {
return 'Die Portangabe der URL ist ungueltig.';
}
}
if (!self::isValidHost($parts['host'])) {
return 'Der Hostname der URL sieht nicht gueltig aus.';
}
return null;
}
/**
* Zerlegt eine URL, ohne an Nicht-ASCII-Zeichen zu scheitern.
*
* @return array{scheme:string,userinfo:string,host:string,port:string,path:string,query:string,fragment:string}|null
*/
private static function split(string $url): ?array
{
$pattern = '#^'
. '(?<scheme>[a-zA-Z][a-zA-Z0-9+.-]*)://'
. '(?:(?<userinfo>[^/?\#@]*)@)?'
. '(?<host>\[[0-9A-Fa-f:.]+\]|[^/?\#:]*)'
. '(?::(?<port>[0-9]*))?'
. '(?<path>/[^?\#]*)?'
. '(?:\?(?<query>[^\#]*))?'
. '(?:\#(?<fragment>.*))?'
. '$#';
if (preg_match($pattern, $url, $matches) !== 1) {
return null;
}
return [
'scheme' => strtolower($matches['scheme']),
'userinfo' => $matches['userinfo'] ?? '',
'host' => mb_strtolower($matches['host'], 'UTF-8'),
'port' => $matches['port'] ?? '',
'path' => $matches['path'] ?? '',
'query' => $matches['query'] ?? '',
'fragment' => $matches['fragment'] ?? '',
];
}
/**
* Erlaubt IPv4, IPv6 in eckigen Klammern sowie Hostnamen - auch
* einteilige (Intranet) und internationalisierte.
*/
private static function isValidHost(string $host): bool
{
// IPv6-Literal, z. B. [2001:db8::1]
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
return filter_var(substr($host, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
}
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return true;
}
// Abschliessender Punkt (absolute Domain) ist zulaessig.
$host = rtrim($host, '.');
if ($host === '') {
return false;
}
foreach (explode('.', $host) as $label) {
if ($label === '' || strlen($label) > 63) {
return false;
}
// Buchstaben (inkl. Unicode), Ziffern und Bindestriche;
// Bindestrich nicht am Anfang oder Ende.
if (preg_match('/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u', $label) !== 1) {
return false;
}
}
return true;
}
/**
* Prozentkodiert alles ausser den erlaubten Zeichen. Bereits
* kodierte Sequenzen (%XX) bleiben unveraendert, damit nicht
* doppelt kodiert wird.
*/
private static function encode(string $value, string $allowed): string
{
if ($value === '') {
return '';
}
$pattern = '/%[0-9A-Fa-f]{2}|[^A-Za-z0-9\-._~' . preg_quote($allowed, '/') . ']/';
return preg_replace_callback(
$pattern,
static function (array $matches): string {
// Schon kodiert: nur die Schreibweise vereinheitlichen.
if ($matches[0][0] === '%') {
return strtoupper($matches[0]);
}
return rawurlencode($matches[0]);
},
$value,
) ?? $value;
}
}