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

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

{"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 を返すトークンとアプリ固有の認証順序
特定のパスだけ 404Nginx の location、proxy_pass、バックエンドのルート
アラートが断続的に失敗レート制限、古い待受プロセス、キュー、タイムアウト

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

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

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

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 の公式資料によれば、CF-Connecting-IP はクライアントのアドレスをオリジンへ伝えます。ただし、HTTP ヘッダーは身分証明ではありません。接続元が本当に信頼済みプロキシの場合にだけ、その値を採用できます。

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

  • TradingView Webhook の送信元 IP: 受信アプリの業務用許可リストに使います。TradingView 公式ヘルプには POST を送る4つの IPv4 アドレスが記載されています。変更の可能性があるため、利用前に再確認してください。
  • Cloudflare のプロキシ CIDR: Nginx の set_real_ip_from で信頼する中継元です。Cloudflare の公式 IPv4/IPv6 リストと照合します。Webhook 送信者の許可リストではありません。
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 とローカルの受信ポートに置き換える必要があります。そのまま本番に貼り付ける完全な設定ではありません。

# 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 と IPv6 の一覧を公開しています。内容を確認して各 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 モジュールの資料を参照してください。

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

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 などを見直します。ただし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 ヘッダーなし、正しい模擬トークン403IP not allowed
信頼入口、正しい IP ヘッダー、誤ったトークン401Invalid token
信頼入口、正しい IP とトークン202Accepted synthetic webhook
非信頼入口、偽造した CF-Connecting-IP、正しいトークン403IP not allowed

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

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

同じ 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 を実行します。

# 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

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

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 セッションで行います。

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 説明は、POST、使用可能なポート 80/443、受信側の3秒タイムアウトを定めています。入口設計と合わせて確認しておきましょう。

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

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