# TradingView Webhook の 403 を調べる：Cloudflare + Nginx の Real IP と4つの再現テスト

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

# TradingView Webhook の 403 を調べる：Cloudflare + Nginx の Real IP と4つの再現テスト

私が運用していた TradingView Webhook の受信口で、あるとき `403 Forbidden` が返るようになりました。手掛かりになったのはステータスコードそのものではなく、アプリケーションのレスポンスでした。

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

**最初に、どのレイヤーが拒否したかをログで確認します。** アプリの送信元 IP 許可リストが原因で、Cloudflare と Nginx を経由しているなら、`$remote_addr` が Cloudflare のエッジ IP のままになっていないか確認してください。`set_real_ip_from` に信頼できる Cloudflare の CIDR だけを指定し、`real_ip_header CF-Connecting-IP` で元のアドレスを復元します。いきなりトークンやデータベースを変更する必要はありません。

これは**2026年6月**に残した実際の障害対応記録です。その後、隔離 VPS 上の Nginx 1.28.3（Ubuntu）でローカルの模擬リクエストを4種類実行し、**403 / 401 / 202 / 403** を観測しました。過去の本番障害の記録と、後日の信頼境界の再現実験は、別々の証拠です。

## 403・401・404：まず拒否したのは誰か

403 は Cloudflare WAF、Nginx、受信アプリのどこからでも返り得ます。レスポンス本文とアプリログ、上流のアクセス記録を照合します。

今回の受信アプリでは、まず送信元 IP の許可リスト、次にトークンをチェックしていました。許可されない IP は `403 / IP not allowed`、許可された IP でトークンが間違っていれば 401 です。つまり**この実装に限っては、401 は IP チェックを通過した手掛かり**になります。ただし、認証の順番はアプリによって異なります。

| 現象 | 最初に調べる場所 |
| --- | --- |
| アプリが `403 / IP not allowed` を返す | Nginx が渡した送信元 IP、信頼するプロキシ、アプリの許可リスト |
| アプリが `401 / Invalid token` を返す | トークンとアプリ固有の認証順序 |
| 特定のパスだけ 404 | Nginx の `location`、`proxy_pass`、バックエンドのルート |
| アラートが断続的に失敗 | レート制限、古い待受プロセス、キュー、タイムアウト |

誤解しやすい点があります。過去に公開経路から偽造ヘッダーを付けて送ったリクエストは、`server: cloudflare` の 403 を受けました。**これはエッジ側の拒否を示すだけで、Nginx が偽造ヘッダーを防いだ証拠ではありません。** この違いは後半で別に検証します。

## Cloudflare のエッジ IP と TradingView の送信元 IP は別物

経路は次のようになります。

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

Cloudflare がオリジンに TCP 接続すると、Nginx が最初に見るのは Cloudflare ノードの IP です。Real IP が正しく復元されないと、`X-Real-IP` などを通してそのプロキシ IP がアプリまで渡され、TradingView の送信元許可リストと一致せず `IP not allowed` になります。

[Cloudflare の公式資料](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/)によれば、`CF-Connecting-IP` はクライアントのアドレスをオリジンへ伝えます。ただし、**HTTP ヘッダーは身分証明ではありません**。接続元が本当に信頼済みプロキシの場合にだけ、その値を採用できます。

ここでは IP リストを2種類に分けます。

- **TradingView Webhook の送信元 IP：** 受信アプリの業務用許可リストに使います。[TradingView 公式ヘルプ](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/)には POST を送る4つの IPv4 アドレスが記載されています。変更の可能性があるため、利用前に再確認してください。
- **Cloudflare のプロキシ CIDR：** Nginx の `set_real_ip_from` で信頼する中継元です。[Cloudflare の公式 IPv4/IPv6 リスト](https://www.cloudflare.com/ips/)と照合します。Webhook 送信者の許可リストではありません。

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

Cloudflare の全 CIDR をアプリの許可リストに入れると範囲が広がりすぎます。逆に TradingView の4アドレスを `set_real_ip_from` に書くのは、「送信者」と「信頼する中継者」の取り違えです。

## Nginx の修正：まず信頼元を限定し、次にアドレスを復元する

6月の対応では、信頼するプロキシを制限し、Real IP を有効にして、正規化した送信元を受信アプリへ渡しました。以下は機密情報を除いた**構成例**です。`<...>` を実際の公式 CIDR とローカルの受信ポートに置き換える必要があります。そのまま本番に貼り付ける完全な設定ではありません。

```nginx
# Use every current IPv4/IPv6 CIDR from Cloudflare's official list
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 の CIDR が多くて管理しにくい場合は？** 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;` で読み込めます。パスは例示です。インライン設定と include ファイルはどちらか一方にします。

自動更新も可能ですが、取得した内容で無条件に上書き・リロードしてはいけません。HTTPS の取得元、IPv4/IPv6 CIDR の書式、空でないことを検証し、旧版を保持して `nginx -t` が通った後に管理された手順で反映します。ここで検証済みの自動更新スクリプトは提供していません。

`set_real_ip_from` は元の IP を主張できる接続元、`real_ip_header` は読み取るヘッダーを定義します。Nginx が `$remote_addr` を書き換えるのは信頼済み接続の場合だけです。単一 IP を持つ `CF-Connecting-IP` では `real_ip_recursive on` は通常不要です。多段プロキシでは別途信頼関係を確認してください。

上流に渡す元の `CF-Connecting-IP` を空にするのは、アプリが未正規化の外部ヘッダーを直接使わないためです。しかし Cloudflare を迂回してオリジンへ直結できる構成では、Real IP だけで直結を防げません。[Nginx Real IP モジュールの資料](https://nginx.org/en/docs/http/ngx_http_realip_module.html)を参照してください。

当時は設定のバックアップ、構文チェック、リロード、ヘルスチェックを行いました。読み取り中心の確認コマンドは次のとおりです。

```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/) などを見直します。ただし1回の 403 は、すべてのパスが保護されている証明にはなりません。本番の Webhook URL にテストを送らないでください。

6月の記録には**変更後の3つの確認**もあります。`/health` は `healthy`、模擬の非許可 IP は `403 / IP not allowed`、模擬の TradingView 送信元 IP に誤ったトークンを付けると `401 / Invalid token` でした。IP とトークンの判定が分かれていることは示せますが、TradingView から実際にアラートが再配信されたという記録はありません。

### まず Webhook を復旧したい人のための3ステップ

障害対応中なら、以下の3ポート実験を先に作る必要はありません。レスポンスとログから拒否したレイヤーを特定し、Nginx の Real IP モジュール・Cloudflare CIDR・アプリに渡るアドレスを確認し、管理された変更の後にヘルスチェックと認証分岐を調べます。最後に TradingView のアラートログで実際の配信を確認します。

続く実験は、**信頼済みプロキシの宣言**と**非信頼接続の偽造ヘッダー**の違いを理解し、回帰テストに落とし込みたい場合向けです。

## 応用：4つの curl で信頼／非信頼の入口を比較する

後日の隔離 SSH 実験では Nginx 1.28.3、ループバック接続、Python の模擬受信アプリを使い、送信元 IP の後にテストトークンを判定しました。結果はこの模擬環境についてのものです。

| 入力条件 | HTTP 結果 | 受信アプリの本文 |
| --- | --- | --- |
| 非信頼入口、IP ヘッダーなし、正しい模擬トークン | **403** | `IP not allowed` |
| 信頼入口、正しい IP ヘッダー、誤ったトークン | **401** | `Invalid token` |
| 信頼入口、正しい IP とトークン | **202** | `Accepted synthetic webhook` |
| 非信頼入口、偽造した `CF-Connecting-IP`、正しいトークン | **403** | `IP not allowed` |

4行目を再現するには、**偽造ヘッダーを送る相手を信頼しない Nginx 入口**が必要です。`127.0.0.1` を信頼する入口に、同じ `127.0.0.1` から偽造ヘッダーを送っても、非信頼接続のテストにはなりません。

以下は検証済みの実験をもとにしたローカル版で、非特権ユーザーの Nginx 1.26.3 コンテナでも再確認しました。18080（受信アプリ）、18081（**非信頼**入口）、18082（信頼するプロキシの模擬入口）の3つの空きループバックポートを使い、上流が受け取る3つのヘッダーも記録します。

**同じ 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 configuration test failed: inspect and clean up the lab" >&2
fi
```

`198.51.100.77` と `192.0.2.0/24` はドキュメント用に予約されたアドレスで、実際の許可リストとは関係ありません。Real IP 対応 Nginx、Python 3、curl、3つの空きポートが必要です。

Nginx の構文検証が成功したら、**同じ Bash セッションで**次の4つの `curl` を実行します。

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

受信アプリのログで、実際に受け取ったヘッダーも確認できます。

```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` の削除も含みます。元の VPS SSH 4ケースは主に `X-Real-IP` と HTTP 応答を確認したものです。

終了時の片付けも**同じ Bash セッションで**行います。

```bash
if [[ -z "${LAB:-}" || -z "${LAB_PARENT:-}" || -z "${APP_PID:-}" || -z "${NGINX_PID:-}" ]]; then
    echo 'Startup variables missing: use the original Bash session to inspect the lab' >&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` は `TMPDIR` を使い、未設定なら `/tmp` に戻します。macOS でも一律に `/tmp` へ作成されるとは想定しません。カスタム `TMPDIR` での削除は確認しましたが、macOS 上で Nginx を実行したわけではありません。

## Real IP の修正後は、実際のアラートを確認する

歴史的な対応記録では、Real IP の修正後に認証分岐とヘルスチェックを確認しています。また、2つの古いサービスユニットが同じ待受ポートを競合していたことも判明しましたが、これは403の根本原因とは別です。以前の404障害も、別のリバースプロキシのルーティング問題でした。

切り分け方は、**403なら拒否レイヤーと送信元 IP、401ならアプリの認証順序に沿ってトークン、404ならルーティング、バースト中の断続的失敗なら制限・待受プロセス・タイムアウト**です。修正後は TradingView のアラートログで本当の配信結果を確認します。

[TradingView の公式 Webhook 説明](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/)は、POST、使用可能なポート 80/443、受信側の3秒タイムアウトを定めています。入口設計と合わせて確認しておきましょう。

**アプリから見える IP が誰のものか分かってから、許可するか判断する。** むやみに許可リストを広げるより、確実な調査につながります。

別の問題ですが、障害時に通知が出て復旧後に解除されるかを確認したい場合は、[Gatus + Docker Compose の HTTP 503 通知・復旧実験](/posts/gatus-docker-compose-http-503-alert-recovery/)も参考になります。そちらは Real IP ではなくヘルス監視の検証です。
