# TradingView Webhook 403 排查：Cloudflare + Nginx 真实 IP 与四场景实测

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

# TradingView Webhook 403 排查：Cloudflare + Nginx 真实 IP 与四场景实测

有一次我维护的 TradingView Webhook 接收端返回 `403 Forbidden`。真正有价值的线索不是单独的 HTTP 状态，而是应用响应中的这句：

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

**先查应用日志确认拒绝发生在哪一层。如果是 IP 白名单拒绝，而且流量经过 Cloudflare 和 Nginx，优先检查 `$remote_addr` 是否仍是 Cloudflare 边缘地址；只允许可信 Cloudflare 网段使用 `set_real_ip_from`，再通过 `real_ip_header CF-Connecting-IP` 还原真实来源。** 不要一上来就改 token 或重启数据库。

这件事来自我在 **2026 年 6 月**留下的故障修复记录。后来又用隔离 VPS 的 Nginx 1.28.3（Ubuntu）跑了四组本机合成请求，确实分别得到 **403 / 401 / 202 / 403**。前者是历史生产排障，后者是可重复的代理信任边界实验，两种证据各有用途。

## 先区分 403、401、404：谁拒绝了 Webhook？

单看 403，可能是 Cloudflare WAF、Nginx 或接收应用。先检查响应体与应用日志是否对应得上。

那次记录里，应用先检查来源 IP 白名单，再检查 token。IP 不通过会返回 `403 / IP not allowed`；来源通过、token 错误时则返回 401。于是**在这个接收器中，401 反而说明请求越过了 IP 门禁**，可以把注意力转向 token。但换成另一种应用，鉴权顺序未必相同。

| 看到的现象 | 优先排查 |
| --- | --- |
| 应用响应 `403 / IP not allowed` | Nginx 转给后端的来源 IP、可信代理、业务白名单 |
| 应用响应 `401 / Invalid token` | token 入口与校验逻辑；结合应用实际的鉴权顺序判断 |
| 某个路径返回 404 | Nginx `location`、`proxy_pass` 和后端路由 |
| 批量警报偶尔失败 | 入口限流、旧监听进程、队列和超时 |

一个容易误判的细节：历史上曾用公网请求伪造头部，得到 `server: cloudflare` 的 403。**它只能证明边缘层挡住了请求，不能单凭这个结果认定 Nginx 自己挡住了伪造头。** 后面的隔离测试会把这件事单独验证。

## Cloudflare 边缘 IP 和 TradingView 出站 IP，不能混成一份白名单

链路其实很普通：

```text
TradingView POST → Cloudflare → Nginx
    → 来源 IP 白名单 → token 校验 → 接收业务
```

源站与 Cloudflare 建立 TCP 连接时，Nginx 先看到的是 Cloudflare 节点 IP；若没有正确还原真实来源，`X-Real-IP` 等头可能继续传递这个代理地址。应用拿它去匹配 TradingView 来源名单，结果就是 `IP not allowed`。

[Cloudflare 官方文档](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/)说明 `CF-Connecting-IP` 可以向源站提供客户端地址。但是**HTTP 请求头本身不是身份证**：只有连接方被证实是可信代理时，才可以采信这个头。

这时需要分清两种用途：

- **TradingView Webhook 来源 IP：** 应用层白名单。目前 [TradingView 官方说明](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/)列出四个发送 POST 的 IPv4 地址，见下方；发生变更时以官网为准。
- **Cloudflare 代理出口网段：** Nginx `set_real_ip_from` 的信任来源，应按 [Cloudflare 官方 IPv4/IPv6 列表](https://www.cloudflare.com/ips/)核对；它不是业务 Webhook 允许名单。

```text
TradingView 官方出站 IPv4（核查日期：2026-10-20）
52.89.214.238
34.212.75.30
54.218.53.128
52.32.178.7
```

把整个 Cloudflare 代理网段加进业务白名单，会让业务信任范围异常扩大；把上面四个 TradingView 地址写进 `set_real_ip_from`，又把“真实发送方”和“可信代理”两个角色搞反了。

## Nginx 修复：先建立信任，再还原地址

六月那次修复采取的方向是限制可信代理来源、启用 Real IP 地址还原，并把规范化的来源 IP 交给接收服务。下面的配置经过脱敏，仅表达真实的配置关系。`<...>` 占位符要换成自己的官方 CIDR 和本地端口，**不能直接当成完整生产配置粘贴执行**：

```nginx
# 按 Cloudflare 官方当前地址列表逐条填写所有需要的 IPv4/IPv6 CIDR
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>;
}
```

**Cloudflare 网段太多，怎么维护？** Cloudflare 分别提供 [IPv4 列表](https://www.cloudflare.com/ips-v4) 和 [IPv6 列表](https://www.cloudflare.com/ips-v6)。核对后可以把每条 CIDR 写成 `set_real_ip_from ...;`，集中放进一个 `cloudflare-realip.conf` 文件，然后用 `include /etc/nginx/conf.d/cloudflare-realip.conf;` 引入；这个路径只是示例，要按实际 Nginx 配置调整。专用文件与上面的内联写法是二选一，不需要重复设置。

自动更新也可行，但不要直接用下载结果覆盖可信代理名单后无条件 reload。至少应检查 HTTPS 来源、IPv4/IPv6 CIDR 格式、列表非空，保留旧版本，成功执行 `nginx -t` 后再受控重载；本文没有提供已验收的一键更新脚本。

`set_real_ip_from` 决定谁有资格声明真实地址；`real_ip_header` 指明从哪个请求头读取声明。只有连接来自可信代理时，Nginx 才会据此改写 `$remote_addr`。对于只含一个 IP 的 `CF-Connecting-IP`，额外配置 `real_ip_recursive on` 并非必需；使用多层代理链时应重新核对信任关系，而不是机械照抄。

这里清掉传给上游的原始 `CF-Connecting-IP`，避免应用绕过规范化逻辑直接读入外部头。但如果源站仍可被绕过 Cloudflare 直连，Real IP 配置并不会自动挡住这条路径。[Nginx 模块官方文档](https://nginx.org/en/docs/http/ngx_http_realip_module.html)说明了可信代理指令的含义。

当时的维修过程保留了 Nginx 配置备份、语法检测、重载和服务健康检查。只读检查指令是：

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

`nginx -t` 检查语法与引用文件；正式变更仍要保留回滚方式。

如果你怀疑公网可以**直接连到源站、绕开 Cloudflare**，可从 Cloudflare 以外的网络，只针对**自己拥有的测试域名和非敏感健康检查路径**做一次连通性检查（下方域名和 IP 均为示例，不能原样执行）：

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

若能直接拿到健康接口的 200，就要检查源站防火墙和 [Cloudflare Authenticated Origin Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/explanation/) 等入口限制；一次 403 也不足以证明所有路径都无法绕过代理。这里不要用真实 Webhook URL 做直连测试。

六月的运维记录还保留了**修复后的三项可核对观察**：服务 `/health` 返回 `healthy`，模拟未授权来源仍返回 `403 / IP not allowed`，模拟 TradingView 来源地址配合错误 token 返回 `401 / Invalid token`。它说明来源白名单和 token 门禁按预期分层工作；记录中没有附带真实 TradingView 告警“重新投递成功”的回执，因此文章不把这三项写成生产端到端成功。

### 只想先恢复 Webhook：按这三步排查

如果你正在处理线上 403，**不必先搭下面的三端口实验室**：先从响应体与应用日志确认是谁在拒绝；再核查 Nginx 是否具备 Real IP 模块、可信 Cloudflare CIDR 是否正确，以及实际转交给应用的来源 IP；完成受控配置变更后检查健康状态和鉴权分支，最后在 TradingView 警报日志中确认真实投递结果。

下面的代码留给想进一步验证 **“可信代理声明”与“非可信连接伪造头”为什么不一样** 的读者，尤其适合作为网关回归测试的基础。

## 进阶：四次 curl 验证可信与非可信代理

后续的隔离 SSH 实测使用 Nginx 1.28.3、回环网络和 Python 合成接收器，先判断来源 IP，再判断测试 token。以下结果仅适用于这个合成实验。

| 输入场景 | 实际状态 | 响应体 |
| --- | --- | --- |
| 非可信入口，不带真实来源头，合成 token 正确 | **403** | `IP not allowed` |
| 可信入口，真实来源头正确，合成 token 错误 | **401** | `Invalid token` |
| 可信入口，来源和合成 token 均正确 | **202** | `Accepted synthetic webhook` |
| 非可信入口，主动伪造来源头，合成 token 正确 | **403** | `IP not allowed` |

要复现最后一行，**必须使用一个不信任客户端连接来源的 Nginx 入口**。如果你把 `127.0.0.1` 当作可信代理，却仍从 `127.0.0.1` 向同一个入口伪造头，这不能证明 Nginx 抵御了不可信来源。

下面是从原有验证逻辑整理的本机示例，并用非特权用户在 Nginx 1.26.3 容器里复核过。它使用三个未占用的回环端口：18080（接收器）、18081（**非可信**入口）、18082（模拟可信代理）；为便于检查，这一版还会记录上游收到的三个代理请求头。

在**同一个 Bash 会话**中准备接收器与两个 Nginx 监听器：

```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 配置检查未通过：不要继续 curl，请检查配置并清理本次 LAB" >&2
fi
```

这里的 `198.51.100.77`、`192.0.2.0/24` 属于保留的文档地址，和实际 Cloudflare 或 TradingView 白名单无关。运行环境需要已有 Nginx（带 Real IP 模块）、Python 3、curl，且这三个端口未被其他服务占用。

确认上述 Nginx 配置检查成功后，**继续在同一个 Bash 会话**执行四条 `curl`：

```bash
# 1. 非可信入口 + 没有 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. 可信入口 + 错的测试 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. 可信入口 + 对的测试 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. 非可信入口 + 主动伪造同名头 → 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
```

还可以在接收器日志里核对上游实际收到的地址：

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

可信入口应出现 `real=198.51.100.77 forwarded=198.51.100.77 cf=absent`，非可信入口应出现 `real=127.0.0.1 forwarded=127.0.0.1 cf=absent`。这版补充测试会把 `X-Forwarded-For` 和清空 `CF-Connecting-IP` 一并核对；原来的 SSH 四场景主要检查了 `X-Real-IP` 与 HTTP 返回码。

测试结束后，**仍在原 Bash 会话内**执行清理：

```bash
if [[ -z "${LAB:-}" || -z "${LAB_PARENT:-}" || -z "${APP_PID:-}" || -z "${NGINX_PID:-}" ]]; then
    echo '找不到启动时的变量：请回原 Bash 会话核对临时进程与目录' >&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 路径不符合本次创建的前缀，跳过删除并提示人工检查' >&2
    fi
fi
```

`LAB_PARENT` 使用当前系统的 `TMPDIR`（不存在则回退 `/tmp`），不再假定 macOS 也把 `mktemp` 放在 `/tmp`。我实际测试了自定义 `TMPDIR` 路径及清理，但并未在 macOS 上重新运行 Nginx。

## 配完 Real IP，还要回到真实警报看结果

历史修复记录里，在 Nginx Real IP 配置校正后，还核对了应用鉴权分支及健康检查。排查时也发现两个旧服务单元曾竞争同一监听端口；这是另一个运维隐患，不该反过来被写成 Webhook 403 的根因。之前单独发生过的 404，则是代理路由配置的问题。

排障时我会按层次处理：**403 先查拒绝来源和真实 IP；401 按应用的鉴权顺序查 token；404 查路径；批量偶发失败查限流、监听进程与超时。** 实际修复后，再从 TradingView 警报日志检查真实投递状态。

[TradingView 的官方 Webhook 文档](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/)还说明它发送 POST、仅支持 80/443 端口、接收端处理超时为三秒；这些限制也值得和自己的入口链路一起检查。

**先确认应用看到的到底是谁，再判断它该不该放行。** 这比盲目放宽白名单更能定位 403，也更不容易引入新的入口风险。

如果还想核查“接口失败后告警有没有发、恢复后是否解除”，可以接着读我之前的 [Gatus + Docker Compose 503 告警与恢复实测](/posts/gatus-docker-compose-http-503-alert-recovery/)。那篇针对的是健康检查与通知链路，不是这次 Real IP 故障。
