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.
277 lines
8.3 KiB
PHP
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;
|
|
}
|
|
}
|