TradingView Webhook 403 beheben: Cloudflare, Nginx Real IP und vier reproduzierte Fälle

Ein TradingView-Webhook-Empfänger, den ich betreue, antwortete eines Tages mit 403 Forbidden. Entscheidend war nicht der HTTP-Status allein, sondern die Fehlermeldung der Anwendung:

{"detail":"IP not allowed"}

Zuerst klären, welche Schicht die Anfrage ablehnt. Meldet die Anwendung eine nicht erlaubte Quell-IP und läuft der Verkehr über Cloudflare und Nginx, prüfen Sie, ob $remote_addr noch eine Cloudflare-Edge-Adresse enthält. Tragen Sie bei set_real_ip_from ausschließlich vertrauenswürdige Cloudflare-CIDRs ein und verwenden Sie real_ip_header CF-Connecting-IP zur Wiederherstellung der ursprünglichen Clientadresse. Ein neuer Token oder ein Datenbankneustart ist nicht der erste Schritt.

Diese Erkenntnis stammt aus einem realen Betriebsvorfall, den ich im Juni 2026 dokumentiert habe. Später führte ich auf einem isolierten VPS mit Nginx 1.28.3 (Ubuntu) vier synthetische localhost-Anfragen aus und erhielt tatsächlich 403 / 401 / 202 / 403. Das historische Protokoll beschreibt die Produktionsstörung; der isolierte Versuch prüft die Vertrauensgrenze. Beides ist getrennt zu bewerten.

403, 401 und 404 unterscheiden: Wer hat den Webhook abgewiesen?

Eine 403 kann von der Cloudflare-WAF, von Nginx oder von der Empfangsanwendung kommen. Vergleichen Sie den Antworttext zunächst mit den Anwendungs- und Upstream-Logs.

Im damaligen Empfänger wurde zuerst die IP-Freigabeliste und danach der Token geprüft. Eine nicht erlaubte IP ergab 403 / IP not allowed, eine erlaubte IP mit falschem Token dagegen 401. Bei genau diesem Empfänger ist 401 somit ein Hinweis, dass die IP-Prüfung bestanden wurde. Andere Anwendungen können in anderer Reihenfolge prüfen.

BeobachtungZuerst prüfen
Anwendung: 403 / IP not allowedVon Nginx übergebene Client-IP, vertrauenswürdige Proxys, Anwendungs-Freigabeliste
Anwendung: 401 / Invalid tokenToken und tatsächliche Reihenfolge der Authentifizierung
404 nur für einen PfadNginx location, proxy_pass und Backend-Routing
Vereinzelte Ausfälle bei vielen AlertsRate Limits, alte Listener-Prozesse, Warteschlangen, Timeouts

Eine irreführende Beobachtung: Ein früherer Test schickte einen gefälschten Header über das öffentliche Netz und erhielt 403 mit server: cloudflare. Das beweist lediglich, dass die Edge den Request abgefangen hat – nicht, dass Nginx den Header verworfen hat. Der spätere lokale Versuch trennt diese Fälle.

Cloudflare-Edge-IPs sind nicht die Absender-IPs von TradingView

Der typische Weg sieht so aus:

TradingView POST → Cloudflare → Nginx
    → source-IP allowlist → token check → receiver

Cloudflare baut eine TCP-Verbindung zum Origin auf. Deshalb sieht Nginx zunächst die IP eines Cloudflare-Knotens. Ohne korrekt konfigurierte Real-IP-Wiederherstellung gelangt womöglich genau diese Proxyadresse über X-Real-IP zur Anwendung. Ihre TradingView-Freigabeliste lehnt sie dann mit IP not allowed ab.

Laut Cloudflare-Dokumentation übermittelt CF-Connecting-IP die ursprüngliche Clientadresse an den Origin. Ein HTTP-Header ist aber kein Identitätsnachweis. Sein Wert darf nur dann vertraut werden, wenn auch die Verbindung tatsächlich von einem vertrauenswürdigen Proxy stammt.

Zwei IP-Listen haben unterschiedliche Aufgaben:

  • TradingView-Webhook-Absender: Sie gehören in die IP-Freigabeliste der Empfangsanwendung. Die offizielle TradingView-Hilfe nennt derzeit vier IPv4-Adressen für POST-Requests. Vor dem Einsatz die aktuelle Liste prüfen.
  • Cloudflare-Proxy-CIDRs: Sie bestimmen, welche Zwischenstationen Nginx unter set_real_ip_from vertraut. Verwenden Sie die offiziellen Cloudflare-IPv4/IPv6-Bereiche. Das ist nicht die geschäftliche Webhook-Absenderliste.
TradingView outbound IPv4 (checked 2026-10-20)
52.89.214.238
34.212.75.30
54.218.53.128
52.32.178.7

Alle Cloudflare-CIDRs in die Anwendungsfreigabeliste zu übernehmen, erweitert die Berechtigungen erheblich. Umgekehrt verwechselt man mit den vier TradingView-Adressen in set_real_ip_from den tatsächlichen Absender mit einem vertrauenswürdigen Proxy.

Nginx richtig konfigurieren: erst Vertrauen festlegen, dann die IP wiederherstellen

Bei der Reparatur im Juni wurden die vertrauenswürdigen Proxyquellen eingegrenzt, Real IP aktiviert und die normalisierte Adresse an den Empfänger weitergereicht. Das folgende Beispiel zeigt das bereinigte Konfigurationsmuster, nicht die ursprüngliche Produktionsdatei. Ersetzen Sie <...> durch aktuelle offizielle CIDRs und Ihren lokalen Anwendungsport. Es ist keine fertig einsetzbare Produktivkonfiguration.

# Insert all current Cloudflare IPv4/IPv6 CIDRs from the official lists
set_real_ip_from <CLOUDFLARE_IPV4_CIDR>;
set_real_ip_from <CLOUDFLARE_IPV6_CIDR>;
real_ip_header CF-Connecting-IP;

location /webhook {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header CF-Connecting-IP "";
    proxy_pass http://127.0.0.1:<APP_PORT>;
}

Zu viele Cloudflare-CIDRs für die Hauptkonfiguration? Cloudflare veröffentlicht eine IPv4-Liste und eine IPv6-Liste. Nach Prüfung können Sie jede Zeile in set_real_ip_from ...; umwandeln, in cloudflare-realip.conf sammeln und über include /etc/nginx/conf.d/cloudflare-realip.conf; einbinden. Dieser Pfad ist nur ein Beispiel. Entweder die include-Datei oder Inline-Regeln nutzen, nicht doppelt.

Automatisches Aktualisieren ist möglich, aber niemals ungeprüft herunterladen, überschreiben und Nginx neu laden. Prüfen Sie HTTPS-Quelle, IPv4/IPv6-CIDR-Syntax und eine nicht leere Liste, bewahren Sie den bisherigen Stand auf und führen Sie nginx -t vor einem kontrollierten Reload aus. Ein getestetes Ein-Klick-Update-Skript ist nicht Bestandteil dieses Artikels.

set_real_ip_from legt fest, welche Verbindungsquellen eine Client-IP angeben dürfen; real_ip_header benennt den auszuwertenden Header. Nur bei einer vertrauenswürdigen Verbindung ändert Nginx $remote_addr. Bei CF-Connecting-IP mit einer einzelnen IP ist real_ip_recursive on normalerweise nicht nötig. Mehrere hintereinandergeschaltete Proxys müssen gesondert bewertet werden.

Der ursprüngliche CF-Connecting-IP wird vor der Weitergabe an die Anwendung entfernt, damit diese keinen unnormalisierten externen Wert erneut ausliest. Das schützt allerdings nicht vor einer direkten Verbindung zum Origin unter Umgehung von Cloudflare. Die Nginx-Real-IP-Dokumentation erklärt die einzelnen Direktiven.

Beim damaligen Eingriff wurden Konfigurationen gesichert, die Syntax geprüft, Nginx neu geladen und Health-Checks untersucht. Für lesende Prüfungen eignen sich:

nginx -V 2>&1 | grep -- '--with-http_realip_module'
nginx -t

nginx -t prüft Syntax und eingebundene Dateien; planen Sie für Produktivänderungen trotzdem einen Rollback ein.

Ist der Origin möglicherweise ohne Cloudflare direkt aus dem Internet erreichbar, lässt sich von einem Netz außerhalb Cloudflares aus ausschließlich ein harmloser Health-Endpunkt einer eigenen Staging-Domain prüfen. Domain und IP im Beispiel sind Platzhalter und müssen ersetzt werden:

curl --resolve 'staging.example.com:443:203.0.113.10' \
  --connect-timeout 3 --max-time 8 -sS -D - -o /dev/null \
  'https://staging.example.com/health'

Antwortet der Origin auf diese Direktverbindung mit 200, kontrollieren Sie Firewall und Mechanismen wie Cloudflare Authenticated Origin Pulls. Eine einzelne 403 beweist dagegen nicht, dass alle Pfade geschützt sind. Die echte Webhook-URL sollte dafür nicht verwendet werden.

Die Notizen vom Juni enthalten auch drei Prüfungen nach der Änderung: /health antwortete mit healthy, eine simulierte nicht erlaubte Quelle weiterhin mit 403 / IP not allowed, eine simulierte TradingView-Quelle mit falschem Token mit 401 / Invalid token. Das bestätigt die Trennung von IP- und Token-Prüfung. Ein Beleg für eine erneut erfolgreich zugestellte echte TradingView-Meldung liegt dort nicht vor.

Webhook schnell wiederherstellen: drei Schritte statt des Testlabors

Im akuten Störungsfall müssen Sie das nachfolgende Labor mit drei Ports nicht zuerst aufbauen. Bestimmen Sie anhand von Antwort und Logs die abweisende Schicht; prüfen Sie Real-IP-Modul, vertrauenswürdige Cloudflare-CIDRs und die an die Anwendung weitergeleitete Adresse; prüfen Sie nach kontrollierten Änderungen Health- und Authentifizierungszweige und schließlich die tatsächliche Zustellung im TradingView-Alert-Log.

Das folgende Labor richtet sich an alle, die den Unterschied zwischen einer Aussage eines vertrauenswürdigen Proxys und einem gefälschten Header über eine nicht vertrauenswürdige Verbindung nachvollziehen oder als Regressionstest festhalten möchten.

Vertiefung: vier curl-Aufrufe mit vertrauenswürdigem und nicht vertrauenswürdigem Listener

Ein späterer isolierter SSH-Versuch nutzte Nginx 1.28.3, localhost-Verbindungen und einen synthetischen Python-Empfänger. Er prüft erst die IP, anschließend einen reinen Test-Token. Die folgenden Ergebnisse gelten für diese Simulation.

Synthetische EingabeTatsächlicher HTTP-StatusAntwort des Empfängers
Nicht vertrauenswürdiger Listener, kein Client-IP-Header, gültiger Test-Token403IP not allowed
Vertrauenswürdiger Listener, richtige IP, falscher Test-Token401Invalid token
Vertrauenswürdiger Listener, richtige IP und richtiger Token202Accepted synthetic webhook
Nicht vertrauenswürdiger Listener, gefälschter CF-Connecting-IP, richtiger Token403IP not allowed

Für die vierte Zeile muss der Listener dem sendenden Peer misstrauen. Wer 127.0.0.1 vertraut und von 127.0.0.1 einen gefälschten Header an denselben Listener schickt, testet gerade keine nicht vertrauenswürdige Verbindung.

Das Beispiel basiert auf der verifizierten Versuchsanordnung; ich habe sie außerdem in einem isolierten Nginx-1.26.3-Container ohne Rootrechte geprüft. Benötigt werden drei freie localhost-Ports: 18080 (Empfänger), 18081 (nicht vertrauenswürdiger Listener) und 18082 (Simulation des vertrauenswürdigen Proxys). Die Anwendung protokolliert die drei wichtigen weitergereichten Header.

Starten Sie die Komponenten in derselben Bash-Sitzung:

LAB_PARENT="${TMPDIR:-/tmp}"
LAB_PARENT="${LAB_PARENT%/}"
[[ -n "$LAB_PARENT" ]] || LAB_PARENT="/tmp"
LAB="$(mktemp -d "${LAB_PARENT}/tv-webhook-lab.XXXXXXXX")"
cat > "$LAB/receiver.py" <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        source = self.headers.get("X-Real-IP", "")
        forwarded = self.headers.get("X-Forwarded-For", "")
        raw_cf = self.headers.get("CF-Connecting-IP")
        print(f"HEADER_CHECK real={source} forwarded={forwarded} cf={'absent' if raw_cf is None else 'present'}", flush=True)
        token = self.headers.get("X-Synthetic-Webhook-Token", "")
        if forwarded != source or raw_cf is not None:
            code, body = 500, b"Proxy header contract mismatch"
        elif source != "198.51.100.77":
            code, body = 403, b"IP not allowed"
        elif self.path != "/webhook":
            code, body = 404, b"Not Found"
        elif token != "test-only-valid":
            code, body = 401, b"Invalid token"
        else:
            code, body = 202, b"Accepted synthetic webhook"
        self.send_response(code)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *args):
        pass
HTTPServer(("127.0.0.1", 18080), Handler).serve_forever()
PY

cat > "$LAB/nginx.conf" <<NGINX
worker_processes 1;
pid $LAB/nginx.pid;
error_log $LAB/nginx-error.log notice;
events { worker_connections 64; }
http {
    client_body_temp_path $LAB/body;
    proxy_temp_path $LAB/proxy;
    fastcgi_temp_path $LAB/fastcgi;
    uwsgi_temp_path $LAB/uwsgi;
    scgi_temp_path $LAB/scgi;
    access_log off;
    server {
        listen 127.0.0.1:18081;
        real_ip_header CF-Connecting-IP;
        set_real_ip_from 192.0.2.0/24;
        location /webhook {
            proxy_set_header X-Real-IP \$remote_addr;
            proxy_set_header X-Forwarded-For \$remote_addr;
            proxy_set_header CF-Connecting-IP "";
            proxy_pass http://127.0.0.1:18080;
        }
    }
    server {
        listen 127.0.0.1:18082;
        real_ip_header CF-Connecting-IP;
        set_real_ip_from 127.0.0.1/32;
        location /webhook {
            proxy_set_header X-Real-IP \$remote_addr;
            proxy_set_header X-Forwarded-For \$remote_addr;
            proxy_set_header CF-Connecting-IP "";
            proxy_pass http://127.0.0.1:18080;
        }
    }
}
NGINX

python3 -u "$LAB/receiver.py" >"$LAB/receiver.log" 2>&1 &
APP_PID=$!
if nginx -t -p "$LAB/" -c "$LAB/nginx.conf"; then
    nginx -p "$LAB/" -c "$LAB/nginx.conf" -g 'daemon off;' >"$LAB/nginx.log" 2>&1 &
    NGINX_PID=$!
    sleep 3
else
    echo "Nginx config test failed: inspect configuration and clean up this lab" >&2
fi

198.51.100.77 und 192.0.2.0/24 sind reservierte Dokumentationsadressen, keine echten TradingView- oder Cloudflare-Freigaben. Erforderlich sind Nginx mit Real-IP-Modul, Python 3, curl und drei freie Ports.

Nach erfolgreicher Nginx-Syntaxprüfung führen Sie die vier curl-Befehle in derselben Bash-Sitzung aus:

# 1. Untrusted listener, no CF-Connecting-IP -> 403
curl --noproxy '*' -sS -i -X POST \
  -H 'X-Synthetic-Webhook-Token: test-only-valid' \
  http://127.0.0.1:18081/webhook

# 2. Trusted listener, wrong test token -> 401
curl --noproxy '*' -sS -i -X POST \
  -H 'CF-Connecting-IP: 198.51.100.77' \
  -H 'X-Synthetic-Webhook-Token: test-only-wrong' \
  http://127.0.0.1:18082/webhook

# 3. Trusted listener, correct test token -> 202
curl --noproxy '*' -sS -i -X POST \
  -H 'CF-Connecting-IP: 198.51.100.77' \
  -H 'X-Synthetic-Webhook-Token: test-only-valid' \
  http://127.0.0.1:18082/webhook

# 4. Untrusted listener, spoofed header -> 403
curl --noproxy '*' -sS -i -X POST \
  -H 'CF-Connecting-IP: 198.51.100.77' \
  -H 'X-Synthetic-Webhook-Token: test-only-valid' \
  http://127.0.0.1:18081/webhook

Zusätzlich können Sie die beim Empfänger eingegangenen Header im Log untersuchen:

grep '^HEADER_CHECK ' "$LAB/receiver.log"

Für den vertrauenswürdigen Listener erwartet man real=198.51.100.77 forwarded=198.51.100.77 cf=absent, für den nicht vertrauenswürdigen real=127.0.0.1 forwarded=127.0.0.1 cf=absent. Die zusätzliche lokale Prüfung deckt auch X-Forwarded-For und das Entfernen von CF-Connecting-IP ab. Der ursprüngliche VPS-SSH-Test prüfte hauptsächlich X-Real-IP und die HTTP-Antworten.

Beenden Sie die Versuchskomponenten in derselben ursprünglichen Bash-Sitzung:

if [[ -z "${LAB:-}" || -z "${LAB_PARENT:-}" || -z "${APP_PID:-}" || -z "${NGINX_PID:-}" ]]; then
    echo 'Startup variables missing: inspect the original Bash session' >&2
else
    kill "$NGINX_PID" "$APP_PID" 2>/dev/null || true
    wait "$NGINX_PID" "$APP_PID" 2>/dev/null || true
    if [[ "$LAB" == "$LAB_PARENT"/tv-webhook-lab.* && -d "$LAB" && ! -L "$LAB" ]]; then
        rm -rf -- "$LAB"
    else
        echo 'LAB path prefix mismatch: skipping deletion; inspect manually' >&2
    fi
fi

LAB_PARENT verwendet TMPDIR oder ersatzweise /tmp. Es wird nicht angenommen, dass auch macOS temporäre Verzeichnisse unter /tmp erstellt. Die Bereinigung wurde mit benutzerdefiniertem TMPDIR geprüft, nicht mit einer erneuten Nginx-Ausführung auf macOS.

Nach der Real-IP-Reparatur die tatsächlichen Alerts kontrollieren

Das historische Protokoll enthält Authentifizierungs- und Health-Prüfungen nach der Real-IP-Korrektur. Daneben wurden zwei ältere Service-Units entdeckt, die denselben Port beanspruchten – ein separates Betriebsproblem, nicht die Ursache der 403. Eine frühere 404-Störung beruhte ebenfalls auf einer anderen Fehlkonfiguration des Reverse-Proxys.

Meine Reihenfolge: 403 – ablehnende Schicht und Client-IP; 401 – Token entsprechend der Authentifizierungsreihenfolge; 404 – Routing; vereinzelte Ausfälle bei vielen Meldungen – Limits, Listener und Timeouts. Nach der Reparatur die tatsächliche Zustellung im TradingView-Alert-Log prüfen.

Die offizielle TradingView-Webhook-Dokumentation beschreibt POST-Anfragen, ausschließlich Ports 80/443 und einen Timeout von drei Sekunden für den Empfänger. Diese Grenzen gehören zur Eingangskonfiguration.

Erst feststellen, wessen IP die Anwendung tatsächlich sieht, dann über die Freigabe entscheiden. Das ist zuverlässiger und sicherer, als pauschal die IP-Liste zu erweitern.

Wer außerdem prüfen möchte, ob fehlerhafte Endpunkte einen Alarm auslösen und wie dieser nach der Erholung aufgehoben wird, findet im separaten Gatus + Docker Compose HTTP-503-Alarm- und Recovery-Test ein verwandtes Beispiel. Dort geht es um Health-Monitoring, nicht um Real IP.