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

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

{"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 allowedNginx 转给后端的来源 IP、可信代理、业务白名单
应用响应 401 / Invalid tokentoken 入口与校验逻辑;结合应用实际的鉴权顺序判断
某个路径返回 404Nginx location、proxy_pass 和后端路由
批量警报偶尔失败入口限流、旧监听进程、队列和超时

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

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

链路其实很普通:

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

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

Cloudflare 官方文档说明 CF-Connecting-IP 可以向源站提供客户端地址。但是HTTP 请求头本身不是身份证:只有连接方被证实是可信代理时,才可以采信这个头。

这时需要分清两种用途:

  • TradingView Webhook 来源 IP: 应用层白名单。目前 TradingView 官方说明列出四个发送 POST 的 IPv4 地址,见下方;发生变更时以官网为准。
  • Cloudflare 代理出口网段: Nginx set_real_ip_from 的信任来源,应按 Cloudflare 官方 IPv4/IPv6 列表核对;它不是业务 Webhook 允许名单。
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 和本地端口,不能直接当成完整生产配置粘贴执行:

# 按 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 列表 和 IPv6 列表。核对后可以把每条 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 模块官方文档说明了可信代理指令的含义。

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

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

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

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

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 等入口限制;一次 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 正确403IP not allowed
可信入口,真实来源头正确,合成 token 错误401Invalid token
可信入口,来源和合成 token 均正确202Accepted synthetic webhook
非可信入口,主动伪造来源头,合成 token 正确403IP not allowed

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

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

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

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:

# 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

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

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 会话内执行清理:

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 文档还说明它发送 POST、仅支持 80/443 端口、接收端处理超时为三秒;这些限制也值得和自己的入口链路一起检查。

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

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