CodeAuditAgent
Alle Artikel
  • Sicherheit
  • SSRF
  • Node.js

SSRF-Abwehr für Webhooks, Link-Vorschauen und URL-Fetcher

Wie SSRF Webhooks, Link-Vorschauen und Importer zum Weg zu Cloud-Metadaten und internen Diensten macht und welche Abwehr hält, mit Node.js-Beispiel.

· 7 Min. Lesezeit · Lina Source LLC

Jede Funktion, die eine URL von einem Benutzer entgegennimmt und sie von Ihrem Server abruft, ist eine potenzielle Server-Side Request Forgery (SSRF, CWE-918). Webhooks, Link-Vorschauen, Avatare per URL, RSS- und Kalenderimporte, PDF-Renderer und „Von URL importieren“-Schaltflächen haben alle dieselbe Form: Ihr Server sendet eine Anfrage an ein Ziel, das jemand anderes ausgewählt hat.

Das Problem ist der Standort Ihres Servers. Er erreicht Dinge, die der Angreifer nicht erreicht: den Cloud-Metadatendienst, interne Admin-Oberflächen, Datenbanken ohne Passwort in einem privaten Netzwerk und Dienste auf localhost. SSRF macht Ihren Server zum Proxy des Angreifers in dieses Netzwerk. Gerade in kleinen Teams schleicht sich die Lücke leicht ein, weil die auslösende Funktion harmlos aussieht: ein URL-Feld auf einer Einstellungsseite, eine Vorschaukarte in einem Chat, ein Helper, der ein Profilbild herunterlädt. Nichts davon sieht nach Netzwerksicherheitscode aus, deshalb wird es selten als solcher geprüft.

Worauf ein Angreifer abzielt

  • Cloud-Metadaten-Endpunkte, allen voran 169.254.169.254 bei AWS, GCP und Azure, die Instanzdetails und in manchen Konfigurationen temporäre Zugangsdaten für die Rolle der Maschine zurückgeben können.
  • An localhost gebundene Dienste wie Admin-Oberflächen, Debug-Server und Metrik-Endpunkte, die nur lokale Aufrufer erwarten.
  • Interne Dienste in privaten Adressbereichen (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), die keine Authentifizierung haben, weil sie nie von außen erreichbar sein sollten.
  • Port-Scans und Service-Discovery, bei denen Antwortzeiten oder Fehlermeldungen genutzt werden, um das interne Netzwerk zu kartieren.
  • Nicht-HTTP-Protokolle, sofern die Fetch-Bibliothek Schemata wie file:, gopher: oder ftp: unterstützt.

Selbst wenn die Antwort nicht an den Angreifer zurückgeht, kann eine blinde SSRF zustandsändernde Anfragen an interne Endpunkte auslösen. Webhooks sind oft blind, und das macht sie nicht sicher. Viele interne Dienste akzeptieren einfache GET-Anfragen, die Zustand ändern, etwa Cache-Purges oder Admin-Aktionen hinter einer URL, gerade weil sie davon ausgehen, dass niemand von außen sie erreichen kann.

Warum einfache Prüfungen scheitern

Der erste Impuls ist, die URL zu parsen und Hostnamen wie localhost oder Strings abzulehnen, die mit 10. oder 192.168. beginnen. Das scheitert aus mehreren Gründen.

  • Hostnamen werden zu IPs aufgelöst. Ein Angreifer registriert eine Domain, deren A-Record auf 127.0.0.1 oder 169.254.169.254 zeigt, und eine Hostnamenprüfung sieht nichts Auffälliges.
  • IP-Adressen haben viele Schreibweisen: dezimal (2130706433), hexadezimal, Kurzformen wie 127.1 und IPv4-gemappte IPv6-Adressen wie ::ffff:127.0.0.1. String-Vergleiche übersehen sie.
  • DNS-Rebinding: Die Domain wird bei der Validierung zu einer öffentlichen IP aufgelöst und einen Moment später, wenn der HTTP-Client die Verbindung aufbaut, zu einer privaten. Validierung und Verbindungsaufbau sind zwei getrennte Lookups.
  • Redirects: Die validierte URL liefert einen 302 auf http://169.254.169.254/, und der HTTP-Client folgt ihm, ohne Sie zu fragen.

Der gemeinsame Nenner: Die Validierung prüft etwas anderes als die Adresse, mit der sich der Socket tatsächlich verbindet. Die Lösung ist, die aufgelöste IP beim Verbindungsaufbau zu prüfen, bei jeder Verbindung, auch nach Redirects.

Abwehrmaßnahmen, die stärkste zuerst

Ziele per Allowlist festlegen, wo möglich

Muss die Funktion nur mit einer bekannten Menge von Hosts kommunizieren, etwa bei einer Integration mit einer Handvoll Anbietern, setzen Sie diese Hostnamen auf eine Allowlist und lehnen alles andere ab. Das ist die stärkste Maßnahme und am einfachsten nachzuvollziehen. Vergleichen Sie den geparsten Hostnamen exakt, nicht mit startsWith- oder endsWith-Prüfungen, die ähnlich aussehende Hosts wie api.example.com.attacker.net oder evilexample.com durchlassen. Alles Folgende gilt für Funktionen, die beliebige öffentliche URLs akzeptieren müssen.

Schema und Port einschränken

Akzeptieren Sie nur https: (und http: nur, wenn es sein muss). Lehnen Sie Zugangsdaten in der URL ab und beschränken Sie Ports auf 443 und 80, sofern es keinen klaren Bedarf für andere gibt. Parsen Sie mit einem Standard-URL-Parser, niemals mit einem Regex; der WHATWG-Parser normalisiert zudem ungewöhnliche IP-Schreibweisen, was den späteren Prüfungen hilft.

Auflösen und die IP beim Verbindungsaufbau prüfen

Blockieren Sie Loopback-, private, Link-Local-, Carrier-Grade-NAT- und unspezifizierte Adressbereiche sowie IPv6-Unique-Local- und -Link-Local-Bereiche. Entscheidend ist, die Prüfung innerhalb des DNS-Lookups anzuwenden, den der HTTP-Client verwendet, sodass die validierte Adresse auch die verbundene Adresse ist. Das schließt die Lücke für DNS-Rebinding. Die Node-Module http und https akzeptieren genau dafür eine eigene lookup-Funktion.

Die Blockliste unten deckt die Bereiche ab, die für die meisten Deployments relevant sind. Ergänzen Sie öffentliche Adressbereiche Ihrer eigenen Infrastruktur, denn ein Load Balancer oder eine interne API mit öffentlicher IP kann Anfragen aus Ihrem Netzwerk trotzdem vertrauen. Lehnen Sie einen Hostnamen ab, wenn irgendeine seiner aufgelösten Adressen blockiert ist, nicht nur die erste, denn der Client kann sie in beliebiger Reihenfolge ausprobieren.

// safe-lookup.js
import dns from "node:dns";
import net from "node:net";

const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4-gemappt
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");

export function isBlockedIp(address, family) {
  return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}

// Validiert jede aufgelöste Adresse, bevor sich der Socket verbindet
export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err);
    const denied =
      addresses.length === 0 ||
      addresses.some((a) => isBlockedIp(a.address, a.family));
    if (denied) {
      return callback(new Error("Destination not allowed: " + hostname));
    }
    if (options.all) return callback(null, addresses);
    callback(null, addresses[0].address, addresses[0].family);
  });
}

Ein Detail wird leicht übersehen: Enthält die URL ein IP-Literal, verbindet sich Node direkt, ohne lookup überhaupt aufzurufen. Die Request-Funktion muss IP-Literale daher selbst prüfen, bevor sie die Anfrage weitergibt.

import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";

export function postWebhook(rawUrl, payload) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("Only https is allowed");
  if (url.port && url.port !== "443") throw new Error("Port not allowed");
  if (url.username || url.password) throw new Error("Credentials not allowed");

  const host = url.hostname.replace(/^\[|\]$/g, "");
  const family = net.isIP(host);
  if (family !== 0 && isBlockedIp(host, family)) {
    throw new Error("Destination not allowed");
  }

  return new Promise((resolve, reject) => {
    const req = https.request(
      url,
      {
        method: "POST",
        lookup: safeLookup,
        timeout: 5000,
        headers: { "content-type": "application/json" },
      },
      (res) => {
        // node:https folgt nie Redirects; ein 3xx wird als Fehlschlag gewertet
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

Wenn Sie in Node fetch verwenden, das auf undici aufbaut, gilt dieselbe Idee über einen eigenen Dispatcher: Erstellen Sie einen undici-Agent mit der Option connect.lookup und übergeben Sie ihn als dispatcher. Das Prinzip bleibt gleich: Die Prüfung sitzt dort, wo die Verbindung aufgebaut wird. Leitet Ihre Umgebung ausgehenden Traffic über einen HTTP-Proxy, findet der Lookup für den Zielhost auf dem Proxy statt; der Proxy selbst muss dann dieselben Regeln durchsetzen.

Redirects deaktivieren oder jeden Hop neu validieren

Folgen Sie bei Webhooks überhaupt keinen Redirects; ein Empfänger, der umleitet, ist falsch konfiguriert, und ein deutlicher Fehlschlag signalisiert dem Kunden, seinen Endpunkt zu korrigieren. Bei Link-Vorschauen und Importern, wo Redirects normal sind, folgen Sie ihnen manuell mit einem kleinen Limit und schicken jeden Hop durch dieselben Schema-, Port- und IP-Prüfungen. Setzen Sie bei fetch redirect: "manual" und verarbeiten Sie den Location-Header selbst.

Begrenzen, was eine erfolgreiche Anfrage bewirken kann

  • Setzen Sie Timeouts für Verbindungsaufbau und Gesamtdauer und begrenzen Sie die Antwortgröße, damit der Fetcher nicht genutzt werden kann, um Verbindungen offen zu halten oder große Dateien abzuziehen.
  • Geben Sie keine rohen Antworten oder detaillierten Fehlermeldungen an den Benutzer zurück. Liefern Sie bei Link-Vorschauen nur den extrahierten Titel, die Beschreibung und die Bild-URL.
  • Entfernen Sie Ihre eigenen Authentifizierungs-Header und Cookies; der Fetcher sollte niemals interne Zugangsdaten an vom Benutzer angegebene Hosts senden.

Über einen Egress-Proxy leiten

Am robustesten ist ein Aufbau, der die Richtlinie aus dem Anwendungscode herauslöst. Leiten Sie ausgehende, benutzergesteuerte Abrufe über einen dedizierten Egress-Proxy oder führen Sie sie in einem isolierten Worker in einem Netzwerksegment aus, das schlicht keine Route zu internen Diensten oder zum Metadaten-Endpunkt hat. Dann wird ein Fehler in der URL-Validierung nicht zum Sicherheitsvorfall, weil das Netzwerk selbst die Verbindung verweigert. Open-Source-Forward-Proxys können Zielregeln für Sie durchsetzen, und ein separater Worker isoliert außerdem langsame oder feindselige Antworten von Ihrem Haupt-Webprozess.

Den Metadatendienst härten

Erzwingen Sie auf AWS IMDSv2, das ein per PUT-Anfrage bezogenes Session-Token erfordert, und belassen Sie das Hop-Limit bei 1, damit Container ihn nicht über den Host erreichen können. GCP und Azure verlangen bei Metadatenaufrufen einen bestimmten Request-Header. Das erhöht die Hürde für einfache GET-basierte SSRF erheblich, ist aber eine zweite Schicht und kein Ersatz für das Blockieren von Link-Local-Adressen. Geben Sie außerdem der Instanz- oder Dienstrolle nur die Berechtigungen, die die Anwendung braucht, damit über den Metadatendienst gestohlene Zugangsdaten möglichst wenig freischalten.

Ihre Abwehr testen

  • Probieren Sie http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ und http://169.254.169.254/latest/meta-data/ aus und bestätigen Sie, dass jede davon abgelehnt wird.
  • Lassen Sie einen Hostnamen, den Sie kontrollieren, auf 127.0.0.1 zeigen und bestätigen Sie, dass die lookup-Prüfung ihn ablehnt. Geben Sie ihm dann zwei A-Records, einen öffentlichen und einen privaten, und bestätigen Sie, dass er weiterhin abgelehnt wird.
  • Liefern Sie von einem öffentlichen Host einen 302-Redirect auf eine private Adresse aus und bestätigen Sie, dass ihm nicht gefolgt wird.
  • Probieren Sie URLs mit file:, ftp: und gopher: sowie URLs mit Zugangsdaten oder ungewöhnlichen Ports aus.

Suchen Sie im Code-Review nach jeder Stelle, an der eine Request-URL aus Eingaben gebaut wird: fetch, axios, got, requests, http.Get, page.goto-Aufrufe von Headless-Browsern und Bildverarbeitungsbibliotheken, die URLs akzeptieren. CodeAuditAgent verfolgt diesen Datenfluss in öffentlichen Repositories und eingefügten Code-Snippets und meldet SSRF als CWE-918 mit der zitierten Aufrufstelle, einem Exploit-Szenario und einem Patch.