# TradingView Webhook 403 疑難排解：Cloudflare + Nginx 真實 IP 與四種情境實測

> Canonical: https://vxi.me/zh-tw/posts/tradingview-webhook-403-nginx-realip/
> Locale: zh-tw
> Published: 2026-10-20
> Updated: 2026-10-20
> Markdown: https://vxi.me/zh-tw/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 故障。
