TradingView Webhook 403: Cloudflare, Nginx Real IP e quattro test riproducibili
TradingView Webhook 403: Cloudflare, Nginx Real IP e quattro test riproducibili
Un ricevitore Webhook di TradingView che gestisco ha iniziato a rispondere 403 Forbidden. L’indizio decisivo non era il solo codice HTTP, ma il messaggio restituito dall’applicazione:
{"detail":"IP not allowed"}
Per prima cosa individua il livello che sta rifiutando la richiesta. Se a bloccarla è la lista degli IP consentiti e il traffico passa per Cloudflare e Nginx, controlla se $remote_addr contiene ancora l’IP di un nodo edge di Cloudflare. Con set_real_ip_from considera affidabili solo i CIDR ufficiali dei proxy Cloudflare; usa poi real_ip_header CF-Connecting-IP per ripristinare l’indirizzo del client originale. Non cominciare cambiando i token o riavviando il database.
Il caso nasce da un incidente di produzione che ho documentato nel giugno 2026. In seguito, su un VPS isolato con Nginx 1.28.3 (Ubuntu), ho eseguito quattro richieste locali sintetiche osservando 403 / 401 / 202 / 403. La cronaca dell’incidente spiega la diagnosi reale; l’esperimento separato verifica il confine di fiducia del proxy. Sono due tipi di prova diversi.
Distinguere 403, 401 e 404: chi rifiuta il Webhook?
Un 403 può arrivare dal WAF di Cloudflare, da Nginx o dall’applicazione ricevente. Prima di modificare la configurazione confronta corpo della risposta, log applicativi e log dell’upstream.
Nel servizio di quell’incidente veniva controllata prima la lista IP e poi il token. Un IP non autorizzato produceva 403 / IP not allowed; un IP accettato con token errato produceva 401. Perciò in questa specifica implementazione, un 401 era la prova di aver superato il controllo IP. L’ordine dei controlli può essere diverso in altre applicazioni.
| Sintomo | Primo controllo |
|---|---|
L’app risponde 403 / IP not allowed | IP passata da Nginx, proxy affidabili, lista consentita dell’applicazione |
L’app risponde 401 / Invalid token | Token e reale sequenza dei controlli di autenticazione |
| Solo un percorso risponde 404 | location, proxy_pass di Nginx e routing del backend |
| Alcuni alert di una raffica falliscono | Rate limit, vecchi processi in ascolto, code e timeout |
Attenzione a un’osservazione ingannevole: in passato una richiesta da Internet con un header falsificato aveva ottenuto 403 con server: cloudflare. Questo dimostra che il blocco è avvenuto sull’edge, non che Nginx abbia rifiutato l’header contraffatto. Il laboratorio successivo isola proprio questa differenza.
Gli IP edge di Cloudflare non sono gli IP mittenti di TradingView
La catena è semplice:
TradingView POST → Cloudflare → Nginx
→ source-IP allowlist → token check → receiver
Quando Cloudflare apre la connessione TCP verso l’origin, Nginx vede inizialmente l’IP di un suo nodo. Se Real IP non viene ripristinato correttamente, un header come X-Real-IP può portare all’app quell’indirizzo del proxy. L’app lo confronta con gli IP di TradingView e restituisce IP not allowed.
La documentazione Cloudflare chiarisce che CF-Connecting-IP trasmette l’indirizzo del client all’origin. Tuttavia, un header HTTP non è una prova d’identità: il suo valore è affidabile solo quando la connessione proviene davvero da un proxy autorizzato.
Occorre separare due elenchi:
- IP mittenti Webhook di TradingView: appartengono alla allowlist dell’app. La guida ufficiale TradingView elenca quattro indirizzi IPv4 da cui partono le richieste POST; controlla sempre la versione aggiornata.
- CIDR dei proxy Cloudflare: indicano da quali intermediari Nginx può accettare
set_real_ip_from. Vanno ricavati dagli elenchi ufficiali IPv4/IPv6 di Cloudflare. Non sono la lista dei mittenti Webhook autorizzati.
TradingView outbound IPv4 (checked 2026-10-20)
52.89.214.238
34.212.75.30
54.218.53.128
52.32.178.7
Inserire tutti i CIDR Cloudflare nella allowlist applicativa allargherebbe eccessivamente i privilegi. Inserire invece i quattro IP di TradingView in set_real_ip_from significa confondere il mittente effettivo con il proxy fidato.
Correggere Nginx: prima stabilire la fiducia, poi recuperare l’IP
Nella riparazione di giugno ho limitato i proxy affidabili, attivato il modulo Real IP e inoltrato al ricevitore l’indirizzo normalizzato. Quello che segue è uno schema di configurazione anonimizzato, non il file originale di produzione. Sostituisci <...> con CIDR ufficiali attuali e la porta locale del tuo servizio: non è una configurazione pronta da incollare in produzione.
# 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>;
}
Troppi CIDR Cloudflare da gestire manualmente? Cloudflare pubblica separatamente gli indirizzi IPv4 e IPv6. Dopo averli verificati, puoi trasformare ogni voce in set_real_ip_from ...;, raccoglierle in cloudflare-realip.conf e includerlo con include /etc/nginx/conf.d/cloudflare-realip.conf;. Il percorso è solo un esempio. Scegli tra include e direttive inline, senza duplicarle.
Si può automatizzare l’aggiornamento, ma non conviene scaricare, sovrascrivere e ricaricare Nginx senza controlli. Verifica origine HTTPS, formato CIDR IPv4/IPv6 e lista non vuota; conserva il file precedente, supera nginx -t e applica il reload solo con una procedura controllata. Qui non viene presentato un aggiornamento automatico già collaudato.
set_real_ip_from decide quali peer possono dichiarare l’IP originale; real_ip_header indica quale header leggere. Nginx aggiorna $remote_addr soltanto per connessioni attendibili. Per CF-Connecting-IP con un singolo indirizzo non serve in genere real_ip_recursive on. Se ci sono più proxy in cascata occorre rivedere la catena di fiducia.
Eliminare CF-Connecting-IP originale quando Nginx inoltra la richiesta evita che l’app legga un valore esterno non normalizzato. Ma se l’origin è accessibile direttamente, aggirando Cloudflare, le regole Real IP non impediscono quell’accesso. Le istruzioni ufficiali di Nginx precisano il significato di queste direttive.
Durante l’intervento storico ho conservato una copia della configurazione, verificato la sintassi, ricaricato Nginx ed esaminato il controllo di salute. I comandi seguenti sono utili per la verifica:
nginx -V 2>&1 | grep -- '--with-http_realip_module'
nginx -t
nginx -t controlla sintassi e file inclusi; ogni modifica effettiva richiede comunque un piano di rollback.
Se temi che l’origin sia raggiungibile direttamente da Internet saltando Cloudflare, puoi provarlo da una rete esterna usando solo un endpoint health innocuo su un dominio di staging che possiedi. Dominio e IP qui sono valori di documentazione e vanno sostituiti:
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'
Se la connessione diretta risponde 200, rivedi il firewall dell’origin e controlli come Cloudflare Authenticated Origin Pulls. Un singolo 403, al contrario, non dimostra che tutte le rotte siano protette. Non effettuare queste prove sull’URL Webhook di produzione.
Le note di giugno conservano tre osservazioni dopo la correzione: /health rispondeva healthy; una fonte simulata non ammessa riceveva ancora 403 / IP not allowed; una fonte simulata di TradingView con token errato riceveva 401 / Invalid token. Questo dimostra la separazione tra i controlli, non che un vero alert di TradingView sia stato consegnato di nuovo: la prova di quella consegna non è registrata.
Vuoi ripristinare subito il Webhook? Parti da tre controlli
Durante un guasto reale non è necessario creare subito il laboratorio a tre porte. Individua il livello che rifiuta la richiesta dal corpo della risposta e dai log; controlla modulo Real IP, CIDR Cloudflare affidabili e indirizzo ricevuto dall’app; dopo una modifica controllata verifica health e autenticazione e infine la consegna effettiva nel registro degli alert di TradingView.
Il laboratorio seguente è destinato a chi vuole dimostrare la differenza tra la dichiarazione di un proxy affidabile e un header falsificato da una connessione non attendibile, oppure creare una prova di regressione.
Approfondimento: quattro curl con ingressi attendibili e non attendibili
L’esperimento SSH isolato successivo ha utilizzato Nginx 1.28.3, connessioni loopback e un ricevitore sintetico in Python. Controlla prima l’IP e poi un token esclusivamente di test. I risultati valgono per questa simulazione.
| Input sintetico | Stato HTTP osservato | Corpo della risposta |
|---|---|---|
| Ingresso non attendibile, header IP assente, token di test valido | 403 | IP not allowed |
| Ingresso attendibile, IP corretta, token di test errato | 401 | Invalid token |
| Ingresso attendibile, IP e token corretti | 202 | Accepted synthetic webhook |
Ingresso non attendibile, CF-Connecting-IP falsificato, token valido | 403 | IP not allowed |
Per dimostrare l’ultima riga, l’ingresso Nginx non deve considerare attendibile il peer che invia l’header falsificato. Se ti fidi di 127.0.0.1 e dallo stesso 127.0.0.1 invii un header contraffatto a quell’ingresso, non stai testando una connessione non attendibile.
Il seguente esempio locale si basa sulla logica già verificata; l’ho ricontrollato anche in un container isolato Nginx 1.26.3 come utente senza privilegi root. Usa tre porte loopback libere: 18080 (ricevitore), 18081 (ingresso non attendibile) e 18082 (simulazione del proxy attendibile). Inoltre registra i tre header inoltrati al backend.
Prepara tutto nella stessa sessione 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 e 192.0.2.0/24 sono indirizzi riservati alla documentazione, non vere allowlist Cloudflare o TradingView. Servono Nginx con modulo Real IP, Python 3, curl e tre porte libere.
Dopo che la verifica sintattica Nginx è passata, esegui i quattro curl nella stessa sessione 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
Puoi anche controllare negli output del ricevitore quali header siano realmente arrivati:
grep '^HEADER_CHECK ' "$LAB/receiver.log"
Per l’ingresso attendibile ci aspettiamo real=198.51.100.77 forwarded=198.51.100.77 cf=absent; per quello non attendibile real=127.0.0.1 forwarded=127.0.0.1 cf=absent. Questo controllo locale aggiuntivo verifica anche la normalizzazione di X-Forwarded-For e l’assenza dell’header CF-Connecting-IP. Il precedente test SSH sul VPS verificava soprattutto X-Real-IP e gli esiti HTTP.
Esegui la pulizia nella sessione Bash originaria:
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 usa TMPDIR quando presente, altrimenti /tmp, senza presumere che macOS crei sempre le cartelle temporanee sotto /tmp. Ho verificato la pulizia con un TMPDIR personalizzato, ma non ho ripetuto il test Nginx su macOS.
Dopo la correzione Real IP, controllare gli alert reali
Il registro storico descrive i controlli di salute e delle diverse ramificazioni di autenticazione dopo il fix. Ho notato anche due vecchie unità di servizio in competizione sulla stessa porta d’ascolto: un problema operativo distinto, non la causa del 403. Un precedente incidente 404 aveva invece origine in una configurazione di routing del reverse proxy.
Il mio ordine di diagnosi è: 403: livello che rifiuta e IP; 401: token secondo l’ordine dei controlli dell’app; 404: routing; fallimenti intermittenti: limiti, listener e timeout. Dopo la modifica va controllato nel registro degli alert TradingView se la consegna reale è tornata a funzionare.
La documentazione ufficiale di TradingView sui Webhook indica richieste POST, soltanto porte 80/443 e tre secondi di timeout per il ricevitore. Anche questi vincoli contano nella configurazione dell’ingresso.
Prima scopri quale IP vede davvero l’applicazione; solo allora decidi se autorizzarlo. È più efficace e sicuro che allargare la allowlist alla cieca.
Se vuoi invece verificare che un servizio non sano provochi un allarme e che la successiva ripresa lo disattivi, trovi un esempio correlato nel mio test Gatus + Docker Compose sugli alert HTTP 503 e il ripristino. È una prova di monitoraggio, separata da questo guasto Real IP.