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 = '#^' . '(?[a-zA-Z][a-zA-Z0-9+.-]*)://' . '(?:(?[^/?\#@]*)@)?' . '(?\[[0-9A-Fa-f:.]+\]|[^/?\#:]*)' . '(?::(?[0-9]*))?' . '(?/[^?\#]*)?' . '(?:\?(?[^\#]*))?' . '(?:\#(?.*))?' . '$#'; 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; } }