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 故障。