URL-Validierung im Detail

Ich arbeite gerade an einer Web-Applikation, in der User einen Link zu ihrer Homepage angeben können. Der Link muss clientseitig mit JavaScript (genauergesagt Typescript/Angular) und serverseitig mit Perl validiert werden. Aber was genau sollte als Homepage akzeptiert werden? Und wie geht man die Analyse der übergebenen URLs an?

Table Of Contents

Oft werden für solche Zwecke reguläre Ausdrücke empfohen, wie zum Beispiel in dieser Antwort auf stackoverflow.com:

function validURL(str) {
  var pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol
    '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
    '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
    '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
    '(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
    '(\\#[-a-z\\d_]*)?$','i'); // fragment locator
  return !!pattern.test(str);
}

Auf den ersten Blick schön, aber das Leben ist leider kein Ponyhof ... Schon der zweite Blick offenbar etliche Schwachpunkte. Wenn URLs validiert werden müssen, sind reguläre Ausdrücke praktisch nie ausreichend. Sie taugen allenfalls als Heuristik dazu, URLs aus längeren Strings zu extrahieren, aber nicht um sie zu validieren.

Mathias Bynens hat sogar eine Übersicht über bekannte URL-Validierungs-Regexes zusammengestellt. Bei den Test-URLs gibt es allerdings mindestens zwei Fehler (false negatives).

Es gibt keine allgemeingültige URL-Validierung

Obschon ein formaler Standard für URLs existiert, ist dies nur bedingt hilfreich. Oft gibt es Gründe, formal korrekte URLs wie http://localhost zu verwerfen, aber inkorrekte URLs wie http://www.cantanea.com oder www.cantanea.com zu akzeptieren, bzw. zu re-interpretieren.

Etliche weitere Details müssen dagegen im Einzelfall, den aktuellen Erfordernissen entsprechend geklärt werden. Selbst wenn man sich zur Verwendung einer fertigen Lösung entschließt, sollte man sich über die mit der Validierung verbundenen Grenzfälle und Sicherheitsaspekte bewusst sein.

Vewendung eines URL-Parsers

Ganz gleich, welche Programmiersprache man benutzt, wird irgendjemand bereits einen URL-Parser dafür geschrieben haben. Für JavaScript ist dieser Parser Teil des Sprachkerns als das URL-Interface:

url = new URL(input)

Es ist zu beachten, dass bei Übergabe einer ungültigen URL eine Exception geworfen wird.

Für Perl ist das Modul URI Standard:

use URI;

$url = URI->new($input);

Die Verwendung einer etablierten Lösung zum initialen Parsen bewahrt einen nicht nur davor, Details zu übersehen, sondern erlaubt auch, die Verarbeitung mit einer kanonischen Form der URLs fortzuführen:

url = new URL(input)
// Validate ...
return url.href;

Und in Perl:

$url = URI->new($input);
# Validate ...
return $url->canonical;

Jetzt können die einzelnen Teile der URLs gegen die Projektanforderungen validiert werden. Die individuellen Komponenten sind:

https://Myself:s3cr3t@My-Company.com:8080/path/to/Search?Q=huh&L=en#Results
\___/   \____/ \____/ \____________/ \__/\_____________/ \________/ \_____/
  |       |      |           |        |         |            |        |
Schema  User  Passwort     Host      Port     Pfad         Query   Fragment

Sowohl das JavaScript-Interface URL() als auch die Perl-Klasse URI erzeugen ein Objekt vom URL. In JavaScript können die Eigenschaften direkt gelesen und geschrieben werden, in Perl werden Getter/Setter verwendet:

Javascript URL Perl URI Value (Javascript version)
protocol scheme() https:
username userinfo() Myself
password userinfo() s3cr3t
hostname host() my-company.com
port port() 8080
host n/a my-company.com:8080
origin n/a https://my-company.com:8080
pathname path() /path/to/Search
search query() ?Q=huh&L=en
searchParams query_form() { Q: "en", L: "en" }
hash fragment() #Results

Die URI-Methoden des Perlmoduls geben in der Regel die Trenner nicht mit zurück. So liefert die Methode scheme() nur "https" und nicht wie bei Javascript "https:".

Das Schema

Sehr häufig wird man nur an https://- oder http://-URLs interessiert sein. Selbst, wenn man mehr Schemas zulassen will, sollte immer explizites Whitelisting zur Anwendung kommen. Auf git://-Links kann man nun einmal nicht klicken, und der Browser kann keine Mails an x-letter-to:user@something schicken.

Pfad, Query und Fragment

Hier besteht selten Grund zu Einschränkungen.

Port

Ports sind Ganzzahlen im Wertebereich 1 bis 65536.

Der URL-Parser von JavaScript wirft eine Exception, wenn ein Port größer als 65536 übergeben wird. Der Port Null wird dagegen akzeptiert. Dieser Port ist der sogenante Wildcard-Port, den das System mit einem "geeigneten" Port ersetzen sollte. Im vorliegenden Projekt ist das nicht sinnvoll, weshalb solche Ports verworfen werden:

if (url.port === 0) throw new Error('Port 0 is not allowed.');

Das URI-Package von Perl erlaubt dagegen alle nicht-negativen Ganzzahlen. Deshalb muss die Prüfung manuell erfolgen:

die "port out of range" if ($url->port < 1 || $url->port > 65535);

Im Gegensatz zum URL-Interface von JavaScript, entfernt das Perl-Package URI keine führenden Nullen vom Port, obwohl die URLs http://localhost:0001234/ und http://localhost:1234/ identisch sind. Deshalb müssen Vorkehrungen getroffen werden, dass Portnummern mit führenden Nullen im numerischen Vergleich nicht fälschlicherweise als Oktalzahlen interpretiert werden.

my $port = $url->port;
$url->port($port) if $port =~ s/^0+//;
die "port out of range" if ($port < 1 || $port > 65535);

Username und Passwort

Es wird leicht übersehen, dass URLs Anmeldeinformationen (Username und/oder Passwort) enthalten können. Ob solche URLs akzeptiert werden oder nicht, hängt von der eigenen Policy ab. In JavaScript sähe die Überprüfung so aus:

if (url.username !== '' || url.password !== '') {
    throw new Error('Credentials are not allowed.');
}

Die Perl-Version:

die "userinfo\n" if $url->userinfo;

Das ist übrigens eine populäre Methode, um URLs zu verschleiern (engl. URL obfuscation). Der URL http://facebook.com@3232235521/ führt beispielsweise nicht zu Facebook sondern eher zur Weboberfläche des eigenen Routers unter http://192.168.0.1/. Der Grund ist, dass die Zahl 3232235521 eine von unzähligen Darstellungen der numerischen IP-Adresse 192.168.0.1 ist, und der String "facebook.com" nicht der Hostname, sondern der Username im URL http://facebook.com@3232235521/ ist. Moderne Browser warnen deshalb, bevor solche URLs geöffnet werden.

Hostname

Ein guter Einstieg, um sich mit Hostnamen-Standards vertraut zu machen, ist die Top Level Domain Name Specification.

Kurz gesagt besteht ein vollqualifizierter Domainname (sprich ein Hostname) aus mindestens zwei "Labels", die durch Punkte getrennt werden. Der Hostname "www.example.com" besteht beispielsweise aus den Labels "www", "example" und "com". Jedes Label darf nur alphabetische Zeichen (a-z), ASCII-Ziffern (0-9) sowie den Bindestrich (-) enthalten. Das erste Zeichen muss immer ein alphabetisches Zweichen sein.

Groß- und Kleinschreibung werden ignoriert.

Das Wurzel-Label (Root-Label)

Die Wurzeldomain des Internets hat keinen Namen. Das korrespondierende Label ist der leere String. In der Praxis bedeutet dies, das ein Hostname, der auf einen Punkt endet, bereits voll qualifiziert ist, und keine Sucherweiterung mehr angewendet wird.

Endet der Name nicht mit einem Punkt, wird eine konfigurierbare Liste von Such-Domains durchprobiert und angehangen. Diese Liste wird in der Datei /etc/resolv.conf konfiguriert:

$ cat /etc/resolv.conf 
domain cantanea.com
search cantanea.com
nameserver 127.0.0.1
nameserver 8.8.8.8
$ host smtp
smtp.cantanea.com has address 212.72.196.90
$ host smtp.cantanea.com.
smtp.cantanea.com has address 212.72.196.90
$ host smtp.
Host smtp. not found: 3(NXDOMAIN)

Ein leeres Label ist nur als Wurzellabel zulässig. Daraus ergibt sich, dass ein Hostname nie mehr als zwei oder mehr aufeinanderfolgende Punkte enthalten darf.

Verletzung von Hostnamens-Standards

Dass ein Hostname wie web_server.company.com Standards verletzt, bedeutet nicht, dass er nicht funktioniert. Man kann solche Namen problemlos in /etc/hosts eintragen, und Browser verbinden sich auch problemlos mit solchen Hosts. Andererseits erlaubt Nameserver-Software solche Einträge nicht in Zone-Files, und es ist nicht möglich, Domainnamen, die den Standard verletzen, zu registrieren.

Von einem Usability- oder Sicherheitsstandpunkt aus betrachtet, kommt es jedoch weniger darauf an, ob ein Hostname valide ist, sondern mehr darauf, ob er funktioniert. Es hängt somit von den individuellen Anforderungen ab, wie streng die Überprüfung sein sollte.

Detaillierte Hostnamen-Validierung

Die Validierung des Hostnamens ist der bei weitem komplizierteste Teil der URL-Validierung. Man kann sich das Leben einfacher machen, indem man den Namen zunächst in die einzelnen Label auftrennt:

var labels = url.hostname.split('.');
if (labels[labels.length - 1] === '')
    labels.pop();

Endet der Hostname auf einen Punkt, wird er abgetrennt. Ob das die richtige Entscheidung ist, hängt wieder von den individuellen Erfordernissen ab. Strenggenommen wäre es die robustere Variante, den Punkt am Ende sogar obligatorisch zu machen. Allerdings würden solche URLs für die meisten Nutzerinnen und Nutzer ziemlich merkwürdig anmuten.

Der Code in Perl unterscheidet sich leicht, weil die Implementierung von split() gelinde gesagt überraschend ist, wenn der Trenner am Anfang oder Ende des Strings auftaucht. Es ist deshalb einfacher, ein leeres Root-Label schon am Anfang abzutrennen, bevor der Hostname in die Labels aufgetrennt wird:

$host =~ s/\.$//; # Strip off an empty root label.
my @labels = split /\./, $host;

Leere Labels/Folgen von Punkten

Sind zwei aufeinanderfolgende Punkte, wie in www..example.com, in Hostnamen erlaubt? Nein. Sie ständen für ein leeres Label, und das ist nur als Root-Label erlaubt.

Die Überprüfung wird also so fortgesetzt:

if (labels.filter(label => label === '').length)
    throw new Error('consecutive dots are not allowed');

Falls man sich entschließt, ein eventuell vorhandenes, leeres Root-Label zuzulassen, und nicht abzutrennen, muss ein leeres Label an letzter Position akzeptiert werden.

Wegen der Art und Weise wie split() in Perl funktioniert, wenn der String mit dem Trenner beginnt oder endet, ist es einfacher die Prüfung auf leere Labels mit einem Pattern-Match (oder effizienter mit index()) auf den ursprünglichen Hostnamen durchzuführen:

die "consecutive dots are not allowed\n" if $url->host =~ /\.\./;

Numerische IPv4-Adressen

Numerische IP-Adressen erfordern andere Prüfungen als symbolische Hostnamen. Die Validierung sollte deshalb damit fortgesetzt werden, dass numerische IPv4- oder IPv6-Adressen erkannt werden.

Ein naiver regulärer Ausdrück für IPv4-Adressen sieht so aus:

new Regex(/^(([0-9]{1,3})\.){3}([0-9]{1,3})$/);

Leider matcht der Ausdruck auch auf 256.257.258.999, was keine gültige IP-Adresse repräsentiert, weil nur Zahlen im Bereich 0-255 erlaubt sind.

Gut, wer Mastering Regular Expressions gelesen hat, weiß, wie man das Problem lösen kann. Es gibt aber noch weitere Fallen ...

Eingedenk der Tatsache, dass die Quad-Dotted-Notation oft auch Dot-Decimal-Notation genannt wird, vermag es durchaus zu erstaunen, dass numerische IPv4-Adressen keinesfalls zwingend dezimal ausgedrückt werden müssen. 127.0.0.1 kann auch als 0177.0.0.1, 0x7f.0.0.1 oder gar 0000177.0x0.0000.0x1 geschrieben werden. Alle Versionen sind äquivalent! Das sollte man dringend im Sinn haben, wenn man Blacklisting für IPs implementieren will.

Der Begriff "quad-dotted notation" ist aber noch in anderer Hinsicht irreführend: IPv4-Adressen müssen nicht zwingend in Gruppen von vier Ganzzahlen dargestellt werden. Erlaubt ist vielmehr alles von einer bis zu vier Gruppen. Nehmen wir als Beispiel die Adresse 120.144.171.205. In der quad-dotted Hexnotation entspricht dies 0x78.0x90.0xab.0xcd. Tatsächlich kann man sie aber auch als 0x78.0x90.0xabcd oder 0x78.0x90abcdund sogar als 0x7890abcd darstellen. Dezimal entspricht das 120.144.43981, 120.9481165 oder einfach 2022747085. Alle Varianten sind gültige Darstellungen derselben IP-Adresse.

Die folgende Tabelle verdeutlicht, wie die Einzel-, Zweier-, Dreier- und Vierer-Notation von IPv4-Adressen funktioniert:

Gruppen Muster Bits
4 a.b.c.d 8.8.8.8
3 a.b.c 8.8.16
2 a.b 8.24
1 a 32

Ein Ganzzahlüberlauf führt bei allen Notationen zu einem Fehler, also einer ungültigen IP-Adresse, mit einer Ausnahme: Mac OS X (und eventuell auch weitere BSD-Unix-Varianten) scheint beliebig große Ganzzahlen bei der 32-Bit-Einzel-Notation zu erlauben:

$ ping -c 1 89754893758943759873429
PING 89754893758943759873429 (143.202.181.149): 56 data bytes
64 bytes from 143.202.181.149: icmp_seq=0 ttl=44 time=240.768 ms

--- 89754893758943759873429 ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 240.768/240.768/240.768/0.000 ms

Auf einem GNU/Linux-System (mit glibc als System-Bibliothek) erzeugt die gleiche Eingabe einen Fehler. Genauso reagieren alle von mir getesteten JavaScript-Implementierungen mit einer Exception, wenn dem Konstruktor von URL Werte mit mehr als 32 Bits übergeben werden.

Andererseits ist 0xff.0x1000000 nirgendwo eine numerische IP-Adresse, weil 0x1000000 25 Bits benötigt, und die Variante a.b nur 24 Bits für b erlaubt. Deshalb wird es stattdessen als (ungültiger) symbolischer Hostname interpretiert. Ungültig, weil ein Label nicht mit einer Ziffer beginnen darf. Die Idee hinter dieser Regel dürfte jetzt auch klar sein: Beginnt ein Label mit einer Ziffer, ist es Teil einer numerischen IPv4-Adresse und kann so leicht von Labels eines symbolischen Hostnamen unterschieden werden.

Leider normalisiert das URI-Modul für Perl solche numerischen IPv4-Adressen nicht in ihrer kanonische, dezimale Form. Ich persönlich halte das für einen Bug, der in URI gefixt werden sollte. Stand heute muss man die Normalisierung aber selbst durchführen. Um die Menge Quelltext in diesem Blog-Post auf ein vernünftiges Maß zu reduzieren, verzichte ich für den Rest des Textes auf eine Angabe der Lösung in Perl. Wer sich dafür interessiert, finde eine ausführlich kommentierte Implementierung in Perl unter https://github.com/gflohr/Lingua-Poly/blob/master/apis/users/lib/Lingua/Poly/API/Users/Validator/Homepage.pm.

In JavaScript sind die Dinge einfacher, denn die URL-Klasse liefert die Eigenschaft hostname bereits in der kanonischen, dezimalen Form. Wenn die volle Adresse bereits in die einzelnen Labels aufgetrennt wurde, ist es daher einfach, numerische IPv4-Adressen zu erkennen:

var isIP = false;
if (labels.length === 4) {
    var octetRe = new RegExp('^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$');
    if (labels.filter(label => !!label.match(octetRe)).length === 4) {
        isIP = true;
        // IPv4-specific checks follow ...
    }
}

Dieser reguläre Ausdruck matcht nur auf den Bereich 0-255. Alternativ könnte man auch einfach testen, dass alle Zahlen dezimal (/(0|[1-9][0-9]+)/) und kleiner 256 sind.

Welche weiteren Tests für numerische IP-Adressen durchzuführen sind, hängt einmal mehr von den individuellen Anforderungen oder Policys ab. Eine erwägenswerte Option ist es, die Möglichkeit komplett zu unterbinden. Für mein Projekt habe ich mich entschlossen, nur Adressen auszuschließen, die nicht als öffentlich zugängliche Adresse einer Website verwendet werden können. Das heißt, dass (192.168.x.x, 172.16-31.x.x, 10.x.x.x), link-local Adressen (169.254.x.x) die neuen Carrier-grade NAT-Deployment-Adressen (100.64-127.x.x) und natürlich die ans Loopback-Interface gebundenen Adressen (127.x.x.x) ausgeschlossen werden.

Die komplette Überprüfung sieht so aus:

var isIP = false;
if (labels.length === 4) {
    var octetRe = new RegExp('^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$');
    if (labels.filter(label => !!label.match(octetRe)).length === 4) {
        isIP = true;
        var octets = labels.map(octet => parseInt(octet, 10));

        // IPv4 addresses with special purpose?
        if (// Loopback.
            octets[0] === 127
            // Private IP ranges.
            || octets[0] === 10
            || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
            || (octets[0] === 192 && octets[1] === 168)
            // Carrier-grade NAT deployment.
            || (octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127)
            // Link-local addresses.
            || (octets[0] === 169 && octets[1] === 254)) {
            throw new Error('special purpose IPv4 addresses are not allowed');
        }
    }
}

Aber war 1.0.0.127.in-addr.arpa nicht äquivalent zu 127.0.0.1? Für DNS-Abfragen ist das richtig, aber solche Adressen können nicht im Netzwerk verwendet werden, denn Einträge in der Zone .in-addr.arpa verweisen auf Hostnamen, nicht auf IP-Adressen. Dieser Aspekt kann daher für den Augenblick nicht ignoriert werden.

IPv6-Adressen

Auch IPv6-Adressen können in URLs verwendet werden. Sie müssen allerdings in eckige Klammern eingeschlossen werden, damit die Doppelpunkte unterscheidbar von den Doppelpunkten im Schema und dem Doppelpunkt, das den Port abtrennt, bleiben. Somit ist das IPv6-Äquivalent von http://127.0.0.1 der URL http://[::1] für die IPv6-Adresse ::1.

IPv6-Adressen sind aus acht Gruppen mit je vier Hexziffern aufgebaut, wobei die Gruppen durch einen Doppelpunkt getrennt werden. Unnötige führende Nullen können weggelassen werden, und maximal eine Folge von aufeinanderfolgenden Null-Gruppen (zum Beispiel :0:0:0) können zu zwei Doppelpunkten (::) komprimiert werden.

Wie auch für IPv4, enthält der IPv6-Adress-Raum große Bereiche, die für spezielle Zwecke wie private Netzwerke oder Link-Local-Adressen reserviert sind. Um solche Adressen zuverlässig zu erkennen, ist es sinnvoll, sie zuerst in ihre längste Form zu expandieren, und dann die Pattern-Matches durchzuführen. Von diesem Ansatz ließe sich bei IPv4-Adressen übrigens genauso profitieren, wenn die Adresse in eine vollständige hexadezimale oder oktale Form expandiert würde.

Sogenannte gemappte IPv4-Adressen verursachen weitere Probleme. So kann die IPv4-Adresse 172.16.17.18 als 0000:0000:0000:0000:0000:FFFF:172.16.17.18 bzw. ::FFFF:172.16.17.18 in komprimierter Form ausgedrückt werden. Falls solche Adressen akzeptiert werden, müssen alle Tests, die oben für IPv4 beschrieben sind, analog durchgeführt werden, weil ansonsten unerwünschte IPv4-Adressen einfach als IPv6-Adressen verschleiert werden könnten. Wir wählen den einfacheren Ansatz und lassen gemappte IPv4-Adressen grundsätzlich nicht zu. Das ist für die dezimale Form sehr einfach, weil der reguläre Ausdruck ohnehin nicht matcht. In hexadezimaler Form dagegen müssen sie weiterhin explizit ausgeschlossen werden.

Aus den gleichen Gründen werden auch "übersetzte" IPv4-Adressen (*IPv4 translated addresses") und der IPv4/IPv6-Übersetzungs-Adressraum (6to4) vollständig verworfen.

Die komplette Prüfung sieht so aus:

if (!!url.hostname.match(/^\[([0-9a-fA-F:]+)\]$/)
        && !!url.hostname.match(/:/)) {
    // Uncompress the IPv6 address.
    let groups = url.hostname.substr(1, url.hostname.length - 2).split(':');
    if (groups.length < 8) {
        for (let i = 0; i < groups.length; ++i) {
            if (groups[i] === '') {
                groups[i] = '0';
                let missing = 7 - groups.length;
                for (let j = 0; j <= missing; ++j) {
                    groups.splice(i, 0, '0');
                }
                break;
            }
        }
    }

    // Check it.
    if (groups.filter(group => group.match(/^[0-9a-f]+$/)).length === 8) {
        const igroups = groups.map(group => parseInt(group, 16));
        const max = igroups.reduce((a, b) => Math.max(a, b));

        if (max <= 0xffff) {
            isIP = true;
            const norm = groups.map(group => group.padStart(4, '0')).join(':');
            if (max === 0 // the unspecified address
                // Loopback.
                || '0000:0000:0000:0000:0000:00000:0000:0001' === norm
                // IPv4 mapped addresses.
                || !!norm.match(/^0000:0000:0000:0000:0000:ffff/)
                // IPv4 translated addresses.
                || !!norm.match(/^0000:0000:0000:0000:ffff:0000/)
                // IPv4/IPv6 address translation.
                || !!norm.match(/^0064:ff9b:0000:0000:0000:0000/)
                // IPv4 compatible.
                || !!norm.match(/^0000:0000:0000:0000:0000:0000/)
                // Discard prefix.
                || !!norm.match(/^0100/)
                // Teredo tunneling, ORCHIDv2, documentation, 6to4.
                || !!norm.match(/^200[12]/)
                // Private networks.
                || !!norm.match(/^f[cd]/)
                // Link-local
                || !!norm.match(/^fe[89ab]/)
                // Multicast.
                || !!norm.match(/^ff/)
            ) {
                throw new Error('special purpose IPv6 address');
            }
        }
    }
}

if (isIP) return;

Wichtig! Der Code sollte sehr sorgfältig geprüft werden. Ich bin wahrlich kein Experte für IPv6 und habe auch keine allzu ernsthaften Tests durchgeführt. Wenn korrekte IPv6-Unterstützung geschäftskritisch ist, sollte man den Code einer genauen Überprüfung unterwerfen.

Die return-Anweisung am Ende ist notwendig, weil die weiteren Checks nur für symbolische Hostnamen sinnvoll sind.

Voll-qualifizierte Domain-Namen

Für meinen Use-Cases sollen nur öffentlich erreichbare URLs zugelassen werden. Dies impliziert, dass sie voll-qualifiziert sein müssen. Mit anderen Worten, sie müssen mindestens zwei Label haben:

if (labels.length < 2)
    throw new Error('only fully-qualified hostnames are allowed');

Blöderweise ist das nicht wirklich ausreichend. Einige Top-Level-Domains sind weiter in Unter-Namespaces unterteilt. Ein prominentes Beispiel ist die Domain .uk für das Vereinigte Königreich. Es ist unter anderem nicht möglich, die Domains .co.uk, .ac.uk, .gov.uk für eigene Zwecke zu registrieren. Und .uk ist bei weitem nicht die einzige Top-Level-Domain mit solcherlei Besonderheiten. Auch .in, .au oder .br folgen ähnlichen Prinzipien.

Für den Augenblick schenke ich mir solche domain-spezifischen Überprüfungen, weil der erforderliche Code einen Wartungsalbtraum darstellen würde. Außerdem ist es nicht sicherheitskritisch, wenn solche eigentlich ungültigen Hostnamen nicht korrekt erkannt werden. Dies trifft jedenfalls für meinen Use-Case zu, aber in anderen Projekten kann dies natürlich wieder ganz anders aussehen.

Top-Level-Domain-Checks

Es gibt jedoch weitere Einschränkungen für Top-Level-Domains.

RFC2606 weist den Top-Level-Domains .example, .test, .localhost und .invalid, so wie den Domains der zweiten Ebene .example.com, .example.net und .example.org einen Sonderstatus zu.

Dies bedeutet nicht, dass diese Domains funktionale Einschränkungen haben. Zum Beispiel existiert die Website http://example.com und die URL ist selbstverständlich gültig. Dennoch verwerfe ich alle Hostnamen in diesen Domains, weil solche Adressen im Kontext meiner Applikation keinen Sinn ergeben. In anderen Projekten kann das natürlich wieder anders aussehen.

RFC6762 und RFC7686 erweitern den Kreis reservierter Domains um .local und .onion. Und wo wir schon einmal dabei sind, verbieten wir auch noch .home und .corp, weil sie erstens nicht von der IANA registriert sind, und gelegentlich zur Benutzung für private Zwecke empfohlen werden.

Die Domains .in-addr.arpa und ip6.arpa, die für Reverse-DNS verwendet werden, wurden ja bereits erwähnt. Genau betrachtet wird die Top-Level-Domain .arpa überhaupt nur für technische oder esoterische Zwecke benutzt, weshalb man sie auch direkt komplett verbieten kann. Für .int gilt dasselbe.

Traditionellerweise durften Top-Level-Domains nur alphabetische Zeichen enthalten, also keine Bindestriche, keine dezimalen Ziffern. Diese Einschränkung wurde mittlerweile durch https://tools.ietf.org/id/draft-liman-tld-names-01.html teilweise aufgehoben, um Top-Level-Domains mit beliebigen Unicode-Zeichen zu erlauben, (siehe weiter unten unter). Dennoch sind der ASCII-Bindestrich und Dezimalziffern in Top-Level-Domain-Namen noch immer nur erlaubt, wenn sie mit der IDN-Markierung "xn--" beginnen, siehe die Spezifikation für weitere Einzelheiten.

Zusammengefasst führt dies zu folgendem Code in JavaScript:

var tld = labels[labels.length - 1];
if ('xn--' !== tld.substr(0, 4) && !!tld.match(/[-0-9]/)) {
    throw new Error('Only alphabetic characters allowed in TLD.');
}

if ([
    'example',
    'test',
    'localhost',
    'invalid',
    'local',
    'onion',
    'home',
    'corp',
    'arpa',
    'int'].includes(tld)
    || ('example' === labels[labels.length - 2]
        && ['com', 'net', 'org'].includes(tld))) {
    throw new Error('Reserved top-level domains are not allowed.');
}

Es wird teilweise die Meinung vertreten, dass die Namen von Top-Level-Domains mindestens zwei Zeichen lang sein müssen. Dafür habe ich keinen Nachweis gefunden, weshalb ich diese Regel ignoriere, obwohl es natürlich in Ordnung ist, sie im eigenen Code zu implementieren, weil zumindest im Augenblick alle offiziell registrierten Top-Level-Domains mindestens zwei Zeichen haben.

Die Länge von Top-Level-Domains auf höchstens drei Zeichen zu beschränken, ist dagegen mittlerweile definitiv falsch. Die Top-Level-Domains .info, .museum und .versicherung sind alle hochoffiziell registriert!

Eine weitere Einschränkung sollte jedoch beachtet werden. Jedes Label muss mit einem alphabetischen Zeichen beginnen und darf nicht mit einem Bindestrich enden. Um die neueren Änderungen für IDNs sinnvoll zu unterstützen, formulieren wir diese Einschränkung um: Ein Label darf nicht mit einem Bindestrich oder einer Dezimalziffer beginnen, und darf nicht mit einem Bindestrich enden.

for (let i = 0; i < labels.length; ++i) {
    var label = labels[i];
        if (!!label.match(/^[-0-9]/) || !!label.match(/-$/)) {
            throw new Error('malformed hostname');
        }
    }
}

Wer mag, darf den Code-Schnipsel gerne mit Array.filter() eleganter schreiben.

Unicode

Unicode in Domain-Namen ist keine Neuigkeit mehr. Allerdings handelt es es sich genaugenommen um eine clientseitige Erweiterung, und kein Feature des Domain-Namens-Systems. Wenn ein Label Unicode-Zeichen enthält, wird es vom Client (zum Beispiel dem Web-Browser) in einen String umgewandelt, der mit "xn--" beginnt, und dann mittels des sogenannten Punycode-Algorithmus in US-ASCII umgewandelt wird. Genaugenommen sind Hostnamen also noch immer strikt auf US-ASCII beschränkt. Unter praktischen Gesichtspunkten muss Unicode in Hostnamen jedoch jederzeit akzeptiert werden.

Genauer betrachtet können Hostnamen noch immer keine beliebigen Unicode-Zeichen enthalten. So fordert zum Beispiel das bereits erwähnte Dokument https://tools.ietf.org/id/draft-liman-tld-names-01.html, dass Top-Level-Domain-Namen nur (Unicode-)Buchstaben enthalten dürfen.

Außerdem existieren spezifische Regeln für alle Top-Level-Domains, die Unicode erlauben. Die meisten Registrys erlauben nur solche Unicode-Zeichen, die im Kontext der jeweiligen Domain sinnvoll sind.

Es ist leicht einzusehen, dass die strikte Überprüfung der Einhaltung all dieser Regeln einen enormen Arbeits- und Wartungsaufwand bedeutet. Weil dies aber in meinem Fall nicht geschäftskritisch ist, beschränke ich die Prüfung darauf, dass die Hostnamen keine verbotenen US-ASCII-Zeichen enthalten, lasse jedoch beliebige Unicode-Zeichen zu. Einmal mehr sei darauf hingewiesen, dass dies eine subjektive Entscheidung ist.

if (!!url.hostname.match(/[\x00-\x2c\x2f\x3a-\x60\x7b-\x7f]/)) {
    throw new Error('forbidden character in hostname');
}

Der Pattern-Match überprüft, dass der Hostname nur alphabetische Zeichen (a-z), Dezimalziffern (0-9), den Bindestrich (-) und den Punkt (.) oder Zeichen außerhalb des Bereichs von US-ASCII enthält.

Der Punkt . muss auch erlaubt werden, obschon er nicht Teil eines Labels sein darf. Weil der reguläre Ausdruck aus Effizienzgründen aber auf den kompletten Hostnamen angewendet wird, muss er ebenfalls akzeptiert werden.

Großbuchstaben A-Z brauchen nicht zugelassen zu werden, weil der Hostname bereits in kanonischer Form vorliegt, und deshalb kleingeschrieben ist. Dies gilt übrigens auch für URLs mit einer IPv6-Adresse als Hostnamen-Teil.

Unklar ist, ob auch überprüft werden sollte, ob der Hostname eine gültige UTF-8-Sequenz ist. Google Chrome konvertiert ungültiges UTF-8 nach IDN und versucht den URL zu öffnen; Firefox tut dies nicht. Es ist deshalb besser, diese Entscheidung dem jeweiligen Browser zu überlassen, und keine Checks auf ungültige Multi-Byte-Sequenzen in Hostnamen vorzunehmen. Wie immer kann man das aber auch anders sehen.

Unicode-Normalisierung

Wir wissen ja bereits, dass bei Hostnamen nicht zwischen Groß- und Kleinschreibung unterschieden wird, und Browser unterstützen das, indem sie den Hostnamen in einem URL automatisch in Kleinschreibung umwandeln. Daher sind die URLs http://LOCALHOST/ und http://localhost/ äquivalent.

Das ist aber leider nur die Spitze des Eisberges, denn Browser nehmen noch etliche weitere Normalisierungen vor. Diese Hostnamen sind allesamt äquivalent innerhalb von URLs:

  • LOCALHOST
  • 𝓵𝓸𝓬𝓪𝓵𝓱𝓸𝓼𝓽
  • localhost
  • ⓛⓞⓒⓐⓛⓗⓞⓢⓣ
  • 𝕝𝕠𝕔𝕒𝕝𝕙𝕠𝕤𝕥

Das gleiche gilt für diese IP-Adressen:

  • 127.0.0.1
  • ①②⑦.⓪.⓪.①
  • 127.0.0.1
  • 𝟙𝟚𝟟.𝟘.𝟘.𝟙

Der Unicode Text Converter zeigt noch etliche weitere Beispiele, wobei viele der lustigeren Umwandlungen glücklicherweise nicht in Browsern funktionieren.

Technisch gesehen verwenden Browser bei Hostnamen ein Unicode-Normalisierungs-Form. In JavaScript lässt sich das so abbilden:

normalized = '𝓵𝓸𝓬𝓪𝓵𝓱𝓸𝓼𝓽'.normalize('NFKC);

Folgt man allerdings der Empfehlung und benutzt das Interface URL zum Parsen von URLs passiert dies implizit, und der Hostname innerhalb von URLs wird automatisch normalisiert.

In Perl sieht es so aus:

use Unicode::Normalize;
$normalized = Unicode::Normalize::NFKC('𝓵𝓸𝓬𝓪𝓵𝓱𝓸𝓼𝓽');

Ich bin nicht 100 % sicher, ob Browser NFKC oder lediglich NFKD benutzen. Wer es besser weiß, darf gerne einen Kommentar hinterlassen.

Leider nimmt das URI-Paket von Perl nicht diese Form der Normalisierung vor, sondern interpretiert diese nicht-kanonischen Formen als Internationale Domain-Namen (IDN). Das folgende Beispiel verdeutlicht das:

use v5.10;

use URI;
my $uri = URI->new('http://𝓵𝓸𝓬𝓪𝓵𝓱𝓸𝓼𝓽/');
say $uri->host;
say $uri->canonical;

Die Ausgabe ist:

xn--taaaaaaaaa5gbbbbbbbb2vkb9h7ck7do0i3a51ldaddddddd
http://xn--taaaaaaaaa5gbbbbbbbb2vkb9h7ck7do0i3a51ldaddddddd/

Das Problem ist also bereits im Parser vorhanden. Wenn man die Methode host() aufruft, hat die Konvertierung bereits stattgefunden. Man müsste also Hostnamen, die mit xn-- beginnen wieder zurück nach Unicode umwandeln, die Normalisierung durchführen, und dann erneut URI->new() aufrufen.

Maximal-Länge

Gemäß RFC952 MUSS Host-Software Namen bis 63 Zeichen Länge akzeptieren, und SOLLTE damit bis 255 Zeichen Länge klarkommen. Es gibt also kein hartes Limit für die maximal zulässige Länge eines Hostnamens.

Als RFC952 verfasst wurde, entsprach ein Zeichen in einem Hostnamen einem Byte. Angesichts heutiger Unicode-Domain-Namen, ist es aber nicht klar, ob dieses Limit sich auf die Länge in Bytes, in Zeichen (unter praktischen Gesichtspunkten kann hier UTF-8 angenommen werden) oder die Länge des Hostnamens in Punycode kodiert bezieht.

In Ermangelung besserer Erkenntnisse habe ich auf eine Überprüfung der Länge verzichtet und verlasse mich auf die Konfiguration der maximalen Request-Größe des Webservers, um DOS-Attacken abzuwehren.

Sicherheits- und Datenschutzerwägungen

Wer von Benutzern stammende URLs veröffentlicht, muss einige Sicherheitsaspekte berücksichtigen..

Einschränkung von Schemas

Im obigen Code werden nur URLs mit den Schemas http und https erlaubt und zwar aus guten Gründen:

<a href="javascript:for (;;) alert('Ouch!!!')">Woodstock's homepage</a>

Das berüchtigte URL-Schema javascript: ist lediglich ein besonders einleuchtendes Beispiel. Weil für URL-Schemas beliebige Semantiken hinterlegt sein können, empfieht es sich, nur allgemein etablierte Schemas zuzulassen, die im Kontext der jeweiligen Applikation sinnvoll sind.

Private IP-Netzwerke und Link-local-Adressen

Die Adresse http://127.0.0.1:8080 als Benutzerhomepage in einem öffentlichen Forum zuzulassen ist alles andere als ein Schönheitsfehler, sondern vielmehr ein ernstes Sicherheitsproblem.

Einige Anwendungssoftware (zum Beispiel der populäre Media-Player VLC) kann über eine HTTP-Schnittstelle gesteuer werden. Man sollte sich daher zweimal überlegen, auf den Link zu User Karl-Heinz' Homepage zu klicken, wenn dieser http://127.0.0.1:8080/share?file=/etc/passwd&rec=me@my.com lautet.

Das ist aber lediglich die weithin sichtbare Spitze des Eisbergs. Heute gibt es in den meisten Haushalten etliche Geräte und Gimmicks, die eine HTTP-Schnittstelle zur Verfügung stellen, wie Router, Fernseher, Set-Top-Boxen, Spielekonsolen, und im IoT-Zeitalter natürlich auch noch attraktivere Ziele wie Türschlösser, Telefone oder sogar Autos. Die HTTP-APIs dieser Geräte sind nicht alle zwangsläufig state-of-the-art, was Sicherheitsstandards angeht, weil noch immer viele Firmen der Meinungen sind, dass sie ja "nur" in privaten Netzwerken, die nie von außen zugänglich sein sollten, erreichbar sind.

Werden von Benutzern zur Verfügung gestellte URLs vom Browser automatisch geladen, wie dies zum Beispiel bei src-Attribut von img-Elementen der Fall ist, wird die Angelegenheit noch brenzliger, weil andere Benutzer noch nicht einmal mehr aktiv auf einen Link klicken müssen, und die Adresse normalerweise auch nicht zu Gesicht bekommen.

Domain-Namen für spezielle Verwendung

Spätestens jetzt sollte es klar sein, dass die Zulassung von localhost genauso gefährlich wie die von 127.0.0.1 ist. Wenn ein Netzwerkgerät innerhalb einer Domain mit speziellem Zweck erreichbar ist, kann man praktisch sicher sein, dass die eigene Webapplikation dringend davon absehen sollte, einen Browser dazu zu bringen, Requests an solche Geräte zu schicken.

Am einleuchtendsten lässt sich dies anhand der Domain .local (siehe RFC6762) zeigen. Viele Netzwerkgeräte konfigurieren sich automatisch mit Hostnamen in der Domain .local und diese Namen sind aus naheliegenden Gründen meist einfach zu erraten. Ich schreibe diesen Text auf einem MacBook Pro von Apple, und es ist deshalb nicht schwer zu raten, welches Gerät unter dem Namen MacBook-Pro-3.local zu erreichen ist, oder? Einfach einmal das Kommando hostname in eine Shell eintippen und sich überraschen lassen. Und wer stolzer Besitzer des Druckers Acme Corp. InkRocket 42 ist, sollte sich nicht allzusehr wundern, wenn dessen Web-Interface unter Acme-InkRocket-42.local angesprochen werden kann.

Problemfall URL-Probing

Es mag verlockend erscheinen, sich den ganzen Stress mit dem Parsen und Validieren von URLs zu sparen, und stattdessen einfach GET-Requests an die übergebenen URLs zu schicken, und zu schauen, was zurückkommt.

Auf der Client-Seite, also im Web-Browser, ist das ein wenig kompliziert, aber gar keine so schlechte Idee. Wegen der Same-Origin-Policy können keine XmlHttp-Reqests verwendet werden. Stattdessen muss man img-Elemente in das DOM injizieren, und prüfen, ob das Bild geladen wurde. Damit hat man clientseitig keinen schlechten Schutz gegen Vertipper.

Wenn die Validierung geschäftskritisch ist, muss sie aber auch auf der Serverseite wiederholt werden, und das ist leider ziemlich problematisch, weil Angreifer dadurch wertvolle Informationen über das Server-Netzwerk gewinnen können. Setzt ein Angreifer die Bild-Adresse für den eigenen Avatar auf http://192.168.0.1:8080/favicon.ico und der Server akzeptiert dies nach Testen der Adresse, weiß der Angreifer bereits, dass hinter der Firewall ein Webserver auf dieser Adresse läuft.

Die Nichtzulassung privater IPs entschärft das Problem in gewissem Umfang. Andererseits, wenn die Web-Applikation im Internet unter www.lustige-katzenvideos.de verfügbar ist, wird ein Angreifer ein bisschen mit weiteren Ports auf dem selben Hostnamen oder anderen Namen innerhalb der Domain experimentieren, was durchaus von Erfolg gekrönt sein könnte. Im besten Fall erlangt ein Angreifer Zugriff auf die Server-Management-Konsole, die nur von bestimmten IP-Adressen aus erreichbar ist.

Quelltext

Der Quelltext des Validators in Typescript ist unter diesem Link verfügbar. Eine Portierung nach JavaScript ist nicht schwer: Einfach den class-Wrapper entfernen, und let und const durch var ersetzen.

Die (hoffentlich) äquivalente serverseitige Version in Perl ist unter diesem Link. abrufbar.

Der Code sollte nicht blind in eigene Projekte kopiert werden. Er entspricht den Anforderungen meines aktuellen Projektes; andere Projekte werden vermutlich Modifikationen erfordern.

Korrekturen und Verbesserungen sind natürlich willkommen, am einfachsten über einen Pull-Request oder die Kommentarfunktion weiter unten.

Leave a comment
Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.