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

> Canonical: https://vxi.me/de/posts/tradingview-webhook-403-nginx-realip/
> Locale: de-de
> Published: 2026-10-20
> Updated: 2026-10-20
> Markdown: https://vxi.me/de/posts/tradingview-webhook-403-nginx-realip.md

# 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:

```json
{"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.

| Beobachtung | Zuerst prüfen |
| --- | --- |
| Anwendung: `403 / IP not allowed` | Von Nginx übergebene Client-IP, vertrauenswürdige Proxys, Anwendungs-Freigabeliste |
| Anwendung: `401 / Invalid token` | Token und tatsächliche Reihenfolge der Authentifizierung |
| 404 nur für einen Pfad | Nginx `location`, `proxy_pass` und Backend-Routing |
| Vereinzelte Ausfälle bei vielen Alerts | Rate 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:

```text
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](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/) ü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](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/) 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](https://www.cloudflare.com/ips/). Das ist **nicht** die geschäftliche Webhook-Absenderliste.

```text
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.

```nginx
# 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](https://www.cloudflare.com/ips-v4) und eine [IPv6-Liste](https://www.cloudflare.com/ips-v6). 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](https://nginx.org/en/docs/http/ngx_http_realip_module.html) 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:

```bash
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:

```bash
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](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/explanation/). 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 Eingabe | Tatsächlicher HTTP-Status | Antwort des Empfängers |
| --- | --- | --- |
| Nicht vertrauenswürdiger Listener, kein Client-IP-Header, gültiger Test-Token | **403** | `IP not allowed` |
| Vertrauenswürdiger Listener, richtige IP, falscher Test-Token | **401** | `Invalid token` |
| Vertrauenswürdiger Listener, richtige IP und richtiger Token | **202** | `Accepted synthetic webhook` |
| Nicht vertrauenswürdiger Listener, gefälschter `CF-Connecting-IP`, richtiger Token | **403** | `IP 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**:

```bash
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:

```bash
# 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:

```bash
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**:

```bash
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](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/) 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](/posts/gatus-docker-compose-http-503-alert-recovery/) ein verwandtes Beispiel. Dort geht es um Health-Monitoring, nicht um Real IP.
