# Error 403 en Webhooks de TradingView: IP real con Cloudflare y Nginx, cuatro pruebas

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

# Error 403 en Webhooks de TradingView: IP real con Cloudflare y Nginx, cuatro pruebas

Un receptor de Webhooks de TradingView que mantengo empezó a devolver `403 Forbidden`. La pista decisiva no fue el código HTTP por sí solo, sino este mensaje de la aplicación:

```json
{"detail":"IP not allowed"}
```

**Lo primero es identificar qué capa está rechazando la petición.** Si falla la lista de IP permitidas y el tráfico pasa por Cloudflare y Nginx, comprueba si `$remote_addr` sigue mostrando una IP del nodo de borde de Cloudflare. Limita `set_real_ip_from` a los rangos CIDR de los proxies de Cloudflare en los que confías y utiliza `real_ip_header CF-Connecting-IP` para recuperar la dirección de origen. No empieces cambiando tokens o reiniciando la base de datos.

La explicación viene de una incidencia real que documenté en **junio de 2026**. Más tarde probé cuatro peticiones sintéticas a localhost en un VPS aislado con Nginx 1.28.3 (Ubuntu): obtuve **403 / 401 / 202 / 403**. El registro histórico describe el problema de producción; las pruebas aisladas comprueban el límite de confianza entre proxies. Son pruebas diferentes y no deben confundirse.

## Distinguir 403, 401 y 404: ¿quién rechazó el Webhook?

Un 403 puede venir del WAF de Cloudflare, de Nginx o de la propia aplicación. Antes de cambiar nada, compara el cuerpo de respuesta con los registros del servicio y del proxy.

En aquella implementación, el receptor comprobaba **primero la IP y después el token**. Si la IP no estaba permitida respondía `403 / IP not allowed`; si pasaba esa barrera pero el token era incorrecto, respondía 401. Por eso **en este receptor concreto, un 401 era una pista de que ya se había superado la comprobación de IP**. No todas las aplicaciones hacen estas verificaciones en el mismo orden.

| Síntoma | Qué revisar primero |
| --- | --- |
| La aplicación devuelve `403 / IP not allowed` | IP de origen que reenvía Nginx, proxies de confianza y lista permitida |
| La aplicación devuelve `401 / Invalid token` | Token y orden real de las comprobaciones de autenticación |
| Un endpoint concreto devuelve 404 | `location` y `proxy_pass` de Nginx, ruta en el backend |
| Algunas alertas de una ráfaga fallan | Límites de tasa, procesos antiguos escuchando, colas y tiempos de espera |

Hay un falso positivo fácil: en una prueba anterior se falsificó una cabecera desde Internet y se recibió 403 con `server: cloudflare`. **Eso solo demuestra que Cloudflare bloqueó la petición en el borde, no que Nginx rechazara la cabecera falsa.** Lo comprobaremos por separado en el laboratorio.

## La IP del borde de Cloudflare no es la IP de envío de TradingView

La ruta habitual es sencilla:

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

Al establecer Cloudflare la conexión TCP con el servidor de origen, Nginx ve inicialmente la IP de un nodo de Cloudflare. Si no recupera correctamente la IP real, puede pasar esa dirección del proxy al backend mediante `X-Real-IP`. Al compararla con la lista de remitentes de TradingView, la aplicación termina respondiendo `IP not allowed`.

[La documentación de Cloudflare](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/) explica que `CF-Connecting-IP` transmite la dirección del cliente al origen. Pero **una cabecera HTTP no es una prueba de identidad**: solo debes aceptar ese dato cuando la conexión proceda realmente de un proxy autorizado.

Son dos listas distintas:

- **IP emisoras del Webhook de TradingView:** se usan en la lista de la aplicación. La [página oficial de TradingView](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/) enumera cuatro IPv4 que envían solicitudes POST; comprueba la lista vigente antes de configurar reglas.
- **CIDR de los proxies de Cloudflare:** son las fuentes que Nginx acepta en `set_real_ip_from`. Consulta la [lista oficial de IPv4/IPv6 de Cloudflare](https://www.cloudflare.com/ips/). No equivalen a remitentes autorizados para tu negocio.

```text
TradingView outbound IPv4 (checked 2026-10-20)
52.89.214.238
34.212.75.30
54.218.53.128
52.32.178.7
```

Añadir todos los CIDR de Cloudflare a la lista de IP de la **aplicación** amplía demasiado el acceso. Y poner las cuatro IP de **TradingView** en `set_real_ip_from` confunde al remitente final con el intermediario de confianza.

## Configurar Nginx: establecer la confianza antes de restaurar la IP

La reparación de junio consistió en limitar los proxies de confianza, activar Real IP y enviar al receptor la dirección normalizada. La siguiente configuración está **despersonalizada** y muestra la relación entre directivas, no el archivo original de producción. Sustituye `<...>` por los rangos oficiales vigentes y el puerto local de tu receptor. No es una configuración de producción lista para pegar.

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

**¿Demasiados CIDR de Cloudflare para mantenerlos a mano?** Cloudflare publica una [lista IPv4](https://www.cloudflare.com/ips-v4) y otra [IPv6](https://www.cloudflare.com/ips-v6). Tras revisarlas, puedes transformar cada CIDR en `set_real_ip_from ...;`, guardarlas en `cloudflare-realip.conf` e incluirlo con `include /etc/nginx/conf.d/cloudflare-realip.conf;`. Esa ruta es solo orientativa. Elige entre archivo incluido y declaraciones directas, sin duplicar ambas.

Automatizar las actualizaciones es posible, pero no conviene descargar, sobrescribir y recargar Nginx sin controles. Valida la fuente HTTPS, la sintaxis CIDR de IPv4 e IPv6 y que la lista no esté vacía; conserva la versión anterior, ejecuta `nginx -t` y recarga solo mediante un cambio controlado. Aquí no se presenta un actualizador automático ya probado.

`set_real_ip_from` indica qué conexiones pueden declarar una dirección cliente; `real_ip_header` especifica la cabecera que se consulta. Nginx modifica `$remote_addr` solo para conexiones de confianza. Con `CF-Connecting-IP` de una sola IP no suele hacer falta `real_ip_recursive on`. Una cadena de varios proxies necesita un análisis propio.

Eliminar la cabecera `CF-Connecting-IP` original al reenviar la petición evita que la aplicación lea un valor exterior sin normalizar. Pero esto no impide que alguien se conecte directamente al origen saltándose Cloudflare. Consulta las condiciones exactas en la [documentación de Real IP de Nginx](https://nginx.org/en/docs/http/ngx_http_realip_module.html).

Durante la incidencia original conservé una copia de la configuración, verifiqué la sintaxis, recargué Nginx y revisé la salud del servicio. Estas comprobaciones son de lectura:

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

`nginx -t` comprueba la sintaxis y los archivos referenciados. Los cambios reales deben conservar un procedimiento de reversión.

Si sospechas que se puede **acceder directamente al origen desde Internet sin pasar por Cloudflare**, puedes probar desde otra red **solo una ruta de salud sin datos sensibles de un dominio de pruebas bajo tu control**. El dominio y la IP siguientes son ejemplos documentales, no valores que debas ejecutar tal cual:

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

Si la comprobación de salud responde 200 por la conexión directa, revisa el cortafuegos del origen y opciones como [Cloudflare Authenticated Origin Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/explanation/). En cambio, una sola respuesta 403 no demuestra que todas las rutas estén protegidas. No pruebes con una URL de Webhook real.

Las notas de junio conservan **tres observaciones posteriores al cambio**: `/health` devolvió `healthy`; una IP simulada no autorizada seguía devolviendo `403 / IP not allowed`; una IP simulada de TradingView con token erróneo recibía `401 / Invalid token`. Confirman que se separaron las comprobaciones, pero no son una prueba de que una alerta real de TradingView volviera a entregarse: no existe tal recibo en el registro.

### ¿Necesitas recuperar el Webhook cuanto antes? Tres comprobaciones

En plena incidencia, **no tienes que montar primero el laboratorio de tres puertos**. Determina la capa que rechaza la solicitud mediante respuesta y registros, comprueba el módulo Real IP, los CIDR de Cloudflare confiables y la IP enviada a la aplicación; tras un cambio autorizado verifica salud y autenticación y finalmente revisa la entrega en el registro de alertas de TradingView.

El laboratorio que sigue está pensado para quienes quieren entender por qué una **afirmación de un proxy confiable** no equivale a **una cabecera falsificada desde una conexión no confiable**, o automatizarlo como prueba de regresión.

## Avanzado: cuatro comandos curl en entradas confiables y no confiables

La prueba SSH aislada posterior utilizó Nginx 1.28.3, la red de loopback y un receptor Python sintético. Comprobaba primero la IP y luego un token de prueba. Los resultados corresponden exclusivamente a ese entorno.

| Entrada sintética | HTTP observado | Cuerpo de respuesta |
| --- | --- | --- |
| Entrada no confiable, sin cabecera de IP, token de prueba válido | **403** | `IP not allowed` |
| Entrada confiable, IP correcta, token de prueba incorrecto | **401** | `Invalid token` |
| Entrada confiable, IP y token correctos | **202** | `Accepted synthetic webhook` |
| Entrada no confiable, `CF-Connecting-IP` falsificado, token válido | **403** | `IP not allowed` |

Para demostrar la cuarta fila, **la entrada de Nginx no debe confiar en quien envía la cabecera falsa**. Si confías en `127.0.0.1` y falsificas una cabecera desde `127.0.0.1` contra esa misma entrada, no estás simulando una conexión no confiable.

Este ejemplo local sigue la lógica de las pruebas originales; también lo comprobé en otro contenedor aislado con Nginx 1.26.3 y sin privilegios root. Usa tres puertos loopback disponibles: 18080 (receptor), 18081 (entrada **no confiable**) y 18082 (simulación del proxy confiable). Además, registra las tres cabeceras que llegan al backend.

Prepara el entorno **dentro de la misma sesión de 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` y `192.0.2.0/24` son direcciones reservadas para documentación, no IP reales de Cloudflare o TradingView. Necesitas Nginx con Real IP, Python 3, curl y los tres puertos libres.

Cuando pase la comprobación de configuración de Nginx, ejecuta los cuatro `curl` **en esa misma sesión de 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
```

También puedes consultar qué cabeceras recibió realmente el backend:

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

Para las solicitudes confiables debe aparecer `real=198.51.100.77 forwarded=198.51.100.77 cf=absent` y para las no confiables `real=127.0.0.1 forwarded=127.0.0.1 cf=absent`. Esta prueba local adicional comprueba `X-Forwarded-For` normalizado y la eliminación de `CF-Connecting-IP`. La prueba original del VPS se centraba sobre todo en `X-Real-IP` y las respuestas HTTP.

Limpia los procesos temporales **desde la misma sesión de Bash en la que arrancaron**:

```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` cuando existe y recurre a `/tmp` en caso contrario. No presupone que macOS cree sus directorios temporales bajo `/tmp`. Probé la limpieza con una ruta `TMPDIR` personalizada, pero no ejecuté de nuevo Nginx en macOS.

## Tras la corrección, comprobar las alertas reales

El registro histórico documenta las comprobaciones de autenticación y salud después de la corrección de Real IP. También se descubrieron dos unidades antiguas del servicio que competían por el mismo puerto de escucha: otro problema operativo, no el origen del 403. Un incidente anterior de 404 fue un error distinto de enrutamiento del proxy.

Mi orden es: **403, identificar capa e IP; 401, comprobar el token según el orden de autenticación; 404, revisar las rutas; fallos intermitentes de ráfagas, revisar límites, listeners y timeouts.** Tras la reparación hay que consultar el registro de alertas de TradingView para confirmar la entrega real.

La [documentación oficial de Webhooks de TradingView](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/) indica peticiones POST, únicamente puertos 80/443 y un límite de tres segundos para el receptor. Conviene tenerlo en cuenta al diseñar la entrada.

**Primero identifica de quién es la IP que ve la aplicación; después decide si merece acceso.** Es más útil y más seguro que relajar la lista indiscriminadamente.

Para otra cuestión relacionada —verificar si una caída de un endpoint envía una alarma y su recuperación la desactiva— puedes leer mi [prueba de alertas y recuperación HTTP 503 con Gatus + Docker Compose](/posts/gatus-docker-compose-http-503-alert-recovery/). Aquella prueba trata la monitorización, no el fallo de Real IP.
