232 Tests, unter anderem: - Slug-Erzeugung, Kollisionsbehandlung und Validierung - URL-Validierung inklusive IDN, Prozentkodierung und abgelehnter Protokolle; Punycode gegengeprueft gegen idn_to_ascii - QR-Erzeugung in allen Groessen, mit Logo und Level-H-Automatik - Weiterleitung gegen eine echte SQLite-Datenbank samt Domain- Aufloesung ueber den Host-Header - Update einer bestehenden Installation auf mehrere Domains - Angriffsseitig: gefaelschte Proxy-Header, Host-Header-Injektion, SVG mit Skripten, XXE und externen Referenzen
190 lines
7.6 KiB
PHP
190 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Support\UrlValidator;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class UrlValidatorTest extends TestCase
|
|
{
|
|
/**
|
|
* @return array<string,array{0:string,1:bool}>
|
|
*/
|
|
public static function urlProvider(): array
|
|
{
|
|
return [
|
|
// --- akzeptiert ------------------------------------------
|
|
'https' => ['https://www.beispiel.de/pfad?a=1#anker', true],
|
|
'http' => ['http://beispiel.de', true],
|
|
'IP-Adresse' => ['http://192.168.1.10:8080/x', true],
|
|
'IPv6' => ['https://[2001:db8::1]/x', true],
|
|
'IPv6 mit Port' => ['https://[2001:db8::1]:8443/x', true],
|
|
'localhost' => ['http://localhost:8080/test', true],
|
|
'Umlaut-Domain' => ['https://müller.de', true],
|
|
'Umlaut-Domain mit Pfad' => ['https://münchen.de/veranstaltung', true],
|
|
'Punycode-Domain' => ['https://xn--mller-kva.de', true],
|
|
'Umlaute im Pfad' => ['https://de.wikipedia.org/wiki/München', true],
|
|
'Umlaute im Query' => ['https://beispiel.de/suche?q=Grüße&x=1', true],
|
|
'interner Host ohne Punkt' => ['http://intranet/seite', true],
|
|
'interner Host mit Port' => ['http://nas:5000/freigabe', true],
|
|
'lange TLD' => ['https://beispiel.photography', true],
|
|
'Benutzerinfo' => ['https://user:pass@beispiel.de/geschuetzt', true],
|
|
'absoluter Punkt am Ende' => ['https://beispiel.de./x', true],
|
|
'Bindestrich in der Domain' => ['https://mein-shop.de', true],
|
|
|
|
// --- abgelehnt -------------------------------------------
|
|
'leer' => ['', false],
|
|
'ftp' => ['ftp://beispiel.de/datei.zip', false],
|
|
'javascript' => ['javascript:alert(1)', false],
|
|
'data-URI' => ['data:text/html,<script>alert(1)</script>', false],
|
|
'mailto' => ['mailto:info@beispiel.de', false],
|
|
'file' => ['file:///etc/passwd', false],
|
|
'ohne Host' => ['https://', false],
|
|
'Steuerzeichen' => ["https://beispiel.de/\nX", false],
|
|
'zu lang' => ['https://beispiel.de/' . str_repeat('a', 2100), false],
|
|
'Bindestrich am Labelanfang' => ['https://-beispiel.de', false],
|
|
'Bindestrich am Labelende' => ['https://beispiel-.de', false],
|
|
'leeres Label' => ['https://beispiel..de', false],
|
|
'ungueltiger Port' => ['https://beispiel.de:99999/x', false],
|
|
'Unterstrich im Host' => ['https://mein_host.de', false],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider urlProvider
|
|
*/
|
|
public function testValidation(string $url, bool $expected): void
|
|
{
|
|
self::assertSame($expected, UrlValidator::isValid($url), $url);
|
|
self::assertSame($expected, UrlValidator::validationError($url) === null);
|
|
}
|
|
|
|
public function testNormalizeAddsHttpsWhenSchemeIsMissing(): void
|
|
{
|
|
self::assertSame('https://beispiel.de/pfad', UrlValidator::normalize('beispiel.de/pfad'));
|
|
self::assertSame('https://www.beispiel.de', UrlValidator::normalize(' www.beispiel.de '));
|
|
self::assertSame('https://beispiel.de', UrlValidator::normalize('//beispiel.de'));
|
|
self::assertSame('https://müller.de', UrlValidator::normalize('müller.de'));
|
|
}
|
|
|
|
public function testNormalizeKeepsExistingScheme(): void
|
|
{
|
|
self::assertSame('http://beispiel.de', UrlValidator::normalize('http://beispiel.de'));
|
|
self::assertSame('https://beispiel.de', UrlValidator::normalize('https://beispiel.de'));
|
|
}
|
|
|
|
public function testNormalizeLowercasesSchemeAndHostOnly(): void
|
|
{
|
|
self::assertSame(
|
|
'https://beispiel.de/Pfad/Mit/Grossbuchstaben',
|
|
UrlValidator::normalize('HTTPS://Beispiel.DE/Pfad/Mit/Grossbuchstaben'),
|
|
);
|
|
}
|
|
|
|
public function testNormalizeKeepsTheReadableHost(): void
|
|
{
|
|
// Punycode gehoert erst in den Location-Header, nicht in die Anzeige.
|
|
self::assertSame('https://münchen.de/fest', UrlValidator::normalize('https://münchen.de/fest'));
|
|
}
|
|
|
|
public function testNormalizePercentEncodesPathAndQuery(): void
|
|
{
|
|
self::assertSame(
|
|
'https://de.wikipedia.org/wiki/M%C3%BCnchen',
|
|
UrlValidator::normalize('https://de.wikipedia.org/wiki/München'),
|
|
);
|
|
self::assertSame(
|
|
'https://beispiel.de/suche?q=Gr%C3%BC%C3%9Fe&x=1',
|
|
UrlValidator::normalize('https://beispiel.de/suche?q=Grüße&x=1'),
|
|
);
|
|
self::assertSame(
|
|
'https://beispiel.de/a%20b',
|
|
UrlValidator::normalize('https://beispiel.de/a b'),
|
|
);
|
|
}
|
|
|
|
public function testNormalizeDoesNotDoubleEncode(): void
|
|
{
|
|
$alreadyEncoded = 'https://de.wikipedia.org/wiki/M%C3%BCnchen';
|
|
|
|
self::assertSame($alreadyEncoded, UrlValidator::normalize($alreadyEncoded));
|
|
// Zweifaches Normalisieren aendert nichts mehr.
|
|
self::assertSame(
|
|
UrlValidator::normalize('https://de.wikipedia.org/wiki/München'),
|
|
UrlValidator::normalize(UrlValidator::normalize('https://de.wikipedia.org/wiki/München')),
|
|
);
|
|
}
|
|
|
|
public function testNormalizeKeepsReservedCharactersUsable(): void
|
|
{
|
|
$url = 'https://beispiel.de/a/b?x=1&y=2#teil-3';
|
|
|
|
self::assertSame($url, UrlValidator::normalize($url));
|
|
}
|
|
|
|
public function testNormalizeDoesNotTurnOtherSchemesIntoHttps(): void
|
|
{
|
|
$normalized = UrlValidator::normalize('javascript:alert(1)');
|
|
|
|
self::assertSame('javascript:alert(1)', $normalized);
|
|
self::assertFalse(UrlValidator::isValid($normalized));
|
|
}
|
|
|
|
public function testNormalizedUserInputPassesValidation(): void
|
|
{
|
|
foreach (['beispiel.de', 'www.beispiel.de/a/b?c=d', 'müller.de', 'intranet/seite'] as $input) {
|
|
self::assertTrue(
|
|
UrlValidator::isValid(UrlValidator::normalize($input)),
|
|
$input,
|
|
);
|
|
}
|
|
}
|
|
|
|
public function testToAsciiUrlConvertsOnlyTheHost(): void
|
|
{
|
|
self::assertSame(
|
|
'https://xn--mller-kva.de/gr%C3%BC%C3%9Fe',
|
|
UrlValidator::toAsciiUrl('https://müller.de/gr%C3%BC%C3%9Fe'),
|
|
);
|
|
self::assertSame(
|
|
'https://www.xn--mnchen-3ya.de:8080/a?b=1#c',
|
|
UrlValidator::toAsciiUrl('https://www.münchen.de:8080/a?b=1#c'),
|
|
);
|
|
}
|
|
|
|
public function testToAsciiUrlLeavesAsciiUrlsAlone(): void
|
|
{
|
|
foreach ([
|
|
'https://beispiel.de/pfad?a=1#b',
|
|
'http://192.168.1.10:8080/x',
|
|
'https://[2001:db8::1]/x',
|
|
'http://intranet/seite',
|
|
] as $url) {
|
|
self::assertSame($url, UrlValidator::toAsciiUrl($url));
|
|
}
|
|
}
|
|
|
|
public function testToAsciiUrlResultIsAlwaysAscii(): void
|
|
{
|
|
foreach (['https://müller.de/x', 'https://grüße.example.com/ä', 'https://münchen.de'] as $url) {
|
|
$ascii = UrlValidator::toAsciiUrl(UrlValidator::normalize($url));
|
|
|
|
self::assertSame(
|
|
1,
|
|
preg_match('/^[\x20-\x7E]+$/', $ascii),
|
|
'Location-Header muss ASCII sein: ' . $ascii,
|
|
);
|
|
}
|
|
}
|
|
|
|
public function testErrorMessagesAreHelpful(): void
|
|
{
|
|
self::assertStringContainsString('http', (string) UrlValidator::validationError('ftp://beispiel.de'));
|
|
self::assertStringContainsString('http', (string) UrlValidator::validationError('javascript:alert(1)'));
|
|
self::assertStringContainsString('Ziel-URL', (string) UrlValidator::validationError(''));
|
|
self::assertStringContainsString('Hostnamen', (string) UrlValidator::validationError('https://'));
|
|
}
|
|
}
|