# TradingView Webhook 403: Cloudflare, Nginx Real IP e quattro test riproducibili

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

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

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

```text
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](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/) 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](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/) 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](https://www.cloudflare.com/ips/). Non sono la lista dei mittenti Webhook autorizzati.

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

```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>;
}
```

**Troppi CIDR Cloudflare da gestire manualmente?** Cloudflare pubblica separatamente gli indirizzi [IPv4](https://www.cloudflare.com/ips-v4) e [IPv6](https://www.cloudflare.com/ips-v6). 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](https://nginx.org/en/docs/http/ngx_http_realip_module.html) 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:

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

```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'
```

Se la connessione diretta risponde 200, rivedi il firewall dell'origin e controlli come [Cloudflare Authenticated Origin Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/explanation/). 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**:

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

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

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

```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` 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](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/) 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](/posts/gatus-docker-compose-http-503-alert-recovery/). È una prova di monitoraggio, separata da questo guasto Real IP.
