Im Rahmen eines Projekts bei Tramino habe ich mich genauer mit einem Modul für nginx beschäfigt. Dieses erlaubt es, Anfragen zu signieren um einen unberechtigten Zugriff zu verhindern, ohne dass der Client nochmal extra Zugangsdaten eingeben muss (wie bei HTTP Basic Auth).

Alle Konfigurations-Beispiele und Skripte sind auch über das Code-Repository für diesen Blogpost herunterladbar.

Warum über nginx?

Natürlich kann man sich so eine Funktionalität in seine eigene Anwendung integrieren, und hat dann die Möglichkeit Zugriff jederzeit und mit beliebiger Komplexität zu prüfen.

In diesem Fall jedoch geht um die Auslieferung von Bildern, welche teilweise direkt aus dem Dateisystem kommen. Hier ist das Ziel, dass die Dateien möglichst schnell ausgeliefert werden, und ein zusätzlichen Backend kann hier die Latenz ordentlich erhöhen und bei hoher Last zu Fehlern bei der Auslieferung führen.

nginx bietet für diesen Anwendungsfall bereits ein optnales Modul names secure_link an, welches genau dies abdeckt. In diesem Blogeintrag, welcher für mich eine Lerneinheit darstellt, will ich ein konkretes Beispiel für dieses Modul geben.

Überlegungen

Das secure_link-Modul ist recht flexibel, indem es mit Hilfe von Variablen erlaubt, verschiedene Szenarien zur Prüfung abzubilden.

Im einfachsten Fall wird lediglich ein Secret (was nur nginx sowie der Webdienst welcher URLs generiert weiß), sowie ein UNIX-Zeitstempel um die Lebensdauer der Links einzuschränken.

Dies deckt die meisten Anwendungsfälle bereits ab, die Dokumentation des Moduls liefert jedoch noch eine weitere Idee: Indem man die IP-Adresse oder den User-Agent mit einbindet, kann man Zugriffe noch effektiver einschränken und verhindert das Teilen von Links zwischen Benutzern.

Nachteil dieser Lösung ist jedoch, dass der Einsatz in einem CMS, welches ganze Seiten cachet, unmöglich ist, da dieses ja für jeden Client eigene URLs erzeugen muss.

Wir beschränken uns daher in diesem Tutorial auf die die einfacheren Lösungen.

Installation

Bevor wir beginnen können, brauchen wir einen nginx. Dieser kann auf Linux-Systemen mit dem jeweiligen Paketmanager (apt/apt-get / yum/dnf) installiert werden, auf macOS ist Homebrew empfehlenswert (brew install nginx).

Für diesen Test lassen wir nginx auch mit einer eigenen minimalen Instanz laufen, um nicht den bestehenden Webserver anpassen zu müssen.

Auf Ubuntu ist das Paket nginx-extras notwendig, andere Varianten wie nginx-full enthalten das Modul nicht.

Konfiguration

Das secure_link-Modul bietet insgesamt drei Direkten, für zwei unterschiedliche Modi wie das Mould verwendet werden kann:

Dies ist die einfachere Variante: Es werden vom Webdienst URLs in folgendem Format gebildet:

Code
/<Präfix>/<Hash>/<Link>
  • Der Präfix ist das "Unterverzeichnis" auf dem Webserver und dient der Abtrennung von Routen. In nginx wird dies über die location-Direktive erzeugt.
  • Der Hash ist die MD5-Hex-Summe von <Link><Secret>, wobei der <Secret> der Wert der Direktive secure_link_secret ist.
  • Der eigentliche Link ist der Pfad, welcher eigentlich aufgerufen werden soll.

Um dies besser zu veranschaulichen, nehme ich das Beispiel aus der Modul-Dokumentation und versehe es mit weiteren Kommentaren:

Bash
# Alle Anfragen die mit /p/ beginnen
# Beispiel: /p/5e814704a28d9bc1914ff19fa0c4a00a/link
location /p/ {
    # Unser Secret ist jetzt "secret", in Produktion sollte dies ein langes
    # zufälliges Passwort sein, welches mit dem Webdienst geteilt wird.
    secure_link_secret secret;

    # Falls die Prüfung nicht erfolgreich war, ist die Variable $secure_link leer.
    # In diesem Fall geben wir HTTP 403 (Forbidden) zurück.
    if ($secure_link = "") {
        return 403;
    }

    # Interne Weiterleitung auf den eigentlichen Inhalt.
    # Intern = User sieht diese Route nicht.
    rewrite ^ /secure/$secure_link;
}

# Alle Anfragen die mit /secure/ beginnen
location /secure/ {
    # Diese Route kann nur über interne Requests aufgerufen werden.
    # Dies verhindert primär, dass "clevere" User/Bots versuchen, URLs zu erraten.
    internal;

    # Dateien aus diesem Verzeichnis ausliefern.
    # Wir nutzen nicht die "root"-Direktive, da sonst nginx das /secure/
    # als Teil des Pfades im Dateisystem sieht.
    alias /home/cdn/data/secure-files/
}

Generierung der URLs

Zur Generierung der Links können die MD5-Funktionen verwendet werden, sie sie nahezu jede Programmiersprache anbietet. Als Beispiel die Generierung für Python:

Python
import hashlib

prefix = "https://cdn.example.org/p"
link = "link"
secret = "secret"

link_hash = hashlib.md5("{}{}".format(link, secret).encode()).hexdigest()
print("URL = {}/{}/{}".format(prefix, link_hash, link))

Weitere Beispiele für Bash + Perl im Code-Repository für diesen Blogpost (Dateien hashlink.*).

Wie bereits oben kurz beschrieben kann das secure_link URLs nicht nur mit statischen Signaturen (dem Hash), sondern auch dynamisch (Zeitstempel, Parameter, HTTP-Header) validieren.

Für diese Variante werden werden vom Webdienst URLs in folgendem Format gebildet:

Code
/<Präfix>/<Link>?md5=<Hash>&expires=<Expire>
  • Der Präfix ist das "Unterverzeichnis" auf dem Webserver und dient der Abtrennung von Routen. In nginx wird dies über die location-Direktive erzeugt. Im Gegensatz zur anderen Methode ist dieser Teil des Hashings.
  • Der eigentliche Link ist der Pfad, welcher eigentlich aufgerufen werden soll.
  • Der Hash ist die MD5-Hex-Summe von <Link><Secret>, wobei der <Secret> der Wert der Direktive secure_link_secret ist.
  • Expire ist der UNIx-Zeitstempel, bis wann der Link gültig ist. Dieser wird auch in den Hash gemixt, um Manipulationen zu verhindern.

Für diese Variante sind zwei Einstellungen zu konfigurieren, und es gibt nun drei Ausgänge (Signatur OK; Signatur falsch; Signatur abgelaufen).

Eine Warnung aus eigener Erfahrung: Zeit ist schwerer als man gerne denkt. Insbesondere bei UNIX-Zeitstempeln ist auf zwei Dinge zu achten: Ist dieser in UTC oder lokale Zeit, und ist dieser in Sekunden (Standard) oder Millisekunden (JavaScript)? Falls hier keine einheitliche Linie im System durchgesetzt wird, wird die Signierung an obskuren Stellen brechen und das Debugging aufwändig und lästig.

Und wieder ein kommentiertes Konfigurations-Beispiel:

Bash
# Alle Anfragen die mit /s/ beginnen
location /s/ {
    # String, aus welchem die Prüfsumme und die Ablaufzeit extrahiert werden.
    # Diese werden mit einem Komma getrennt.
    # $arg_<name> sind URL-Parameter.
    # In der Regel kann diese Einstellung genau so bleiben, außer man möchte
    # die Parameter umbenennen (etwa 'md5' zu 'hash').
    secure_link $arg_md5,$arg_expires;

    # Erzeugung des Hashes, welcher zur Validierung herangezogen wird.
    # Im Beispiel enthält dieser:
    # - $secure_link_expires: Den expires-Parameter, um Manipulationen zu verhindern
    # - $uri: Der Pfad des Ressource
    # - $remote_addr: IP-Adresse des Clients
    # - secret: Statisches Secret, wird mit dem Webservice geteilt
    secure_link_md5 "$secure_link_expires$uri$remote_addr secret";

    # Falls die Prüfung nicht erfolgreich war, ist die Variable $secure_link leer.
    # In diesem Fall geben wir HTTP 403 (Forbidden) zurück.
    if ($secure_link = "") {
        return 403;
    }

    # Falls der Wert der String "0" ist, war die Signatur zwar in Ordnung,
    # aber der Link ist bereits abgelaufen. Hier könnte es auch sinnvoll sein,
    # dem Besucher eine erklärende Fehlermeldung zu geben.
    # HTTP 410 (Gone) verstehen Normalsterbliche nicht ;)
    if ($secure_link = "0") {
        return 410;
    }

    # Anstatt des Redirects können wir auch direkt auf die Inhalte zeigen.
    alias /home/cdn/data/secure-files/;
}

Den secure_link_md5 habe ich 1:1 aus der nginx-Dokumentation übernommen, es sollte jedoch vor dem Einsatz genau geprüft werden, ob dies den Anforderungen der Anwendung enspricht. In unserem Fall wollen wir die IP-Adresse ($remote_addr) nicht, da Ressourcen nur zeitlich aber nicht pro Client abgesichert werden sollen.

Aufgrund des immer häufigeren Einsatz von Carrier-grade NAT, insbesondere in Mobilfunk und Kabelnetzen, ist die IP-Adresse eines Clients nicht unbedingt stabil zwischen Anfragen. In Fällen höherer Sicherheitsanforderungen sollte man die Frage stellen, ob eine andere Lösung nicht sinnvoller wäre, die Requests noch besser absichern kann.

Beispiele zur Generierung befinden sich im Code-Repository für diesen Blogpost (Dateien signlink.*).

Achtung Falle: nginx verwendet als Hash in der URL nicht den MD5 in hexadezimaler Schreibweise, sondern die base64-encodierte Variante der Binärversion mit Anpassungen.

Tipp: Während der Fehlersuche kann es hilfreich sein, den String zurückzugeben, welchen nginx zur Validierung verwendet. Dies hilft Annahmen bezüglich des Pfades oder anderer Variablen sicherzustellen.

Bash
# Alle Anfragen die mit /s/ beginnen
...
    # In diesem Fall geben wir HTTP 403 (Forbidden) zurück.
    # NUR FÜRS DEBUGGING
    if ($secure_link = "") {
        return 403 "$secure_link_expires$uri secret";
    }
...

Was nun nehmen?

Wie bei vielem: Hängt davon ab. Im konkreten Projekt für Tramino werden wir wahrscheinlich beide Varianten verwenden:

  • In Anwendungsfall A brauchen wir stabile (=ändern sich nicht) URLs, welche jedoch nur durch uns generierbar sein dürfen, da das Backend hinter den URLs viel Dynamik erlaubt.
  • In Anwendungsfall B brauchen wir zwingend eine Signierung mit ablaufenden URLs, da es sich um sensitive Daten handelt die nicht jeder abrufen darf.

Generell rate ich für die meisten Anwendungsfälle für die Generierung über signierte Parameter, da die Hashed-URL-Variante keine Möglichkeit anbietet, einen Ablauf der URLs zu definieren. Dies kann jedoch in manchen Anwendungsfällen in Ordnung sein, etwa wenn der Zugriff auf temporäre Dateien gewährt werden soll, welche nach dem Ablauf direkt vom Server gelöscht werden.

Da eine spätere Änderung von URLs häufig schwierig und arbeitsaufwändig ist, sollte diese Entscheidung bereits vor dem ersten Deployment einer Anwendung getroffen sein.

Abschluss

Durch das Schreiben dieses Blogposts habe ich viel über dieses Modul lernen, und dadurch auch das Konzept für unser Webseiten-Storage weiter verfeinern können.

Ich hoffe diese Informationen helfen auch meinen Lesern, die Auslieferung ihrer Dateien besser abzusichern.

Für Feedback: Meine E-Mail-Adresse ist unter "Über" :-)

Quellen und Referenzen