TradingView Webhook 403 해결: Cloudflare·Nginx Real IP와 네 가지 재현 테스트

제가 운영하던 TradingView Webhook 수신기가 어느 날 403 Forbidden을 반환했습니다. 단순한 HTTP 상태 코드보다 중요한 단서는 애플리케이션 응답에 있었습니다.

{"detail":"IP not allowed"}

먼저 어느 계층에서 요청을 거부했는지 로그로 확인하세요. 원인이 IP 허용 목록이고 요청이 Cloudflare와 Nginx를 거친다면, $remote_addr가 여전히 Cloudflare 엣지 IP인지부터 점검합니다. set_real_ip_from에는 신뢰할 수 있는 Cloudflare CIDR만 넣고 real_ip_header CF-Connecting-IP로 원래 클라이언트 IP를 복원해야 합니다. 처음부터 토큰을 바꾸거나 데이터베이스를 재시작할 필요는 없습니다.

이 사례는 제가 2026년 6월에 기록한 실제 운영 장애입니다. 이후 격리된 VPS의 Nginx 1.28.3(Ubuntu)에서 네 가지 로컬 합성 요청을 실행해 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 목록을 혼동하면 안 됩니다.

  • TradingView Webhook 발신 IP: 애플리케이션 수준의 허용 목록에 사용합니다. TradingView 공식 도움말에는 POST를 보내는 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의 네 주소를 set_real_ip_from에 넣으면 실제 발신자와 신뢰 프록시의 역할을 뒤바꾼 셈입니다.

Nginx 수정: 신뢰할 연결을 정의한 뒤 실제 IP를 복원

6월의 해결 방향은 신뢰 프록시를 제한하고 Real IP 처리를 활성화해 정규화된 클라이언트 주소를 앱에 전달하는 것이었습니다. 다음은 민감한 설정값을 제거한 구성 예시입니다. <...> 자리에는 현재 공식 CIDR과 본인 서비스의 로컬 포트를 넣어야 하며, 그대로 실행 가능한 운영 설정은 아닙니다.

# Insert all current Cloudflare IPv4/IPv6 CIDRs from the official lists
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 파일은 둘 중 하나만 사용합니다.

자동 업데이트도 가능하지만 다운로드한 값을 곧바로 덮어쓰고 무조건 reload하면 위험합니다. 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 reload, 서비스 상태 확인을 진행했습니다. 읽기 중심 점검 명령은 다음과 같습니다.

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

nginx -t는 문법과 참조 파일을 검사할 뿐입니다. 운영 설정을 변경할 때는 롤백 방법도 준비해야 합니다.

인터넷에서 Cloudflare를 우회해 오리진에 직접 접속할 수 있는지 의심된다면, Cloudflare 바깥 네트워크에서 본인이 소유한 스테이징 도메인의 민감하지 않은 health 경로만 시험해 볼 수 있습니다. 아래 도메인과 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'

직접 연결한 health 경로에서 200이 나온다면 오리진 방화벽과 Cloudflare Authenticated Origin Pulls 등을 점검하세요. 한 번의 403만으로 모든 경로가 보호된다고 결론 내릴 수 없습니다. 실제 Webhook URL을 대상으로 직접 연결을 테스트하지 마세요.

6월 운영 기록에는 수정 후 세 가지 확인 결과도 남아 있습니다. /health는 healthy, 허용되지 않은 IP를 모사한 요청은 403 / IP not allowed, TradingView 발신 IP를 모사하고 틀린 토큰을 넣으면 401 / Invalid token였습니다. 이는 IP와 토큰 검사가 분리되어 동작함을 보여 주지만, 실제 TradingView 알림이 다시 성공적으로 전달됐다는 기록은 아닙니다.

일단 Webhook을 복구해야 한다면: 세 단계만 확인

실제 장애를 겪고 있다면 아래의 세 포트 실험을 먼저 설치할 필요는 없습니다. 응답과 로그로 거부 계층을 찾고, Nginx Real IP 모듈·신뢰 Cloudflare CIDR·앱에 전달되는 IP를 확인하세요. 승인된 수정 후 health 및 인증 경로를 점검하고, 마지막으로 TradingView 알림 로그에서 실제 전송 결과를 확인하면 됩니다.

아래 실험은 신뢰 프록시의 주장과 비신뢰 연결의 헤더 위조 차이를 이해하거나 회귀 테스트를 만들려는 독자를 위한 것입니다.

심화: 네 번의 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

네 번째 결과를 올바르게 검증하려면 위조 헤더를 보내는 연결 자체를 신뢰하지 않는 Nginx 입구가 필요합니다. 127.0.0.1을 신뢰하도록 해 놓고 같은 127.0.0.1에서 헤더를 위조해 보내면 비신뢰 연결 시험이 아닙니다.

다음은 실제 검증 로직을 바탕으로 정리한 로컬 예제이며, 별도의 비특권 Nginx 1.26.3 컨테이너에서도 확인했습니다. 18080(수신기), 18081(비신뢰 입구), 18082(신뢰 프록시 모사)의 빈 루프백 포트 세 개를 사용하고 업스트림에 도달한 프록시 헤더 세 가지를 기록합니다.

하나의 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 config test failed: inspect configuration and clean up this lab" >&2
fi

198.51.100.77과 192.0.2.0/24는 문서에 예약된 시험 주소이며 실제 Cloudflare·TradingView 허용 목록과 무관합니다. Real IP 모듈이 포함된 Nginx, Python 3, curl, 비어 있는 포트 세 개가 필요합니다.

Nginx 문법 검사가 성공했다면 같은 Bash 세션에서 네 가지 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 네 가지 테스트는 주로 X-Real-IP와 HTTP 응답을 검사했습니다.

정리 명령도 처음 사용한 Bash 세션에서 실행하세요.

if [[ -z "${LAB:-}" || -z "${LAB_PARENT:-}" || -z "${APP_PID:-}" || -z "${NGINX_PID:-}" ]]; then
    echo 'Startup variables missing: inspect the original Bash session for processes and files' >&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 수정 뒤의 인증 분기 및 health 검사 결과가 포함됩니다. 같은 수신 포트를 두고 경쟁하던 이전 서비스 유닛 두 개도 발견됐지만, 이는 403의 직접 원인이 아닌 별개의 운영 문제였습니다. 예전 404 장애도 프록시 라우팅 문제였습니다.

제가 쓰는 진단 순서는 403이면 거부 계층과 출발 IP, 401이면 앱 인증 순서에 따른 토큰, 404이면 라우팅, 간헐적 대량 실패면 제한·리스너·타임아웃입니다. 수정 후에는 반드시 TradingView 알림 로그에서 진짜 전달 결과를 확인합니다.

TradingView 공식 Webhook 설명에는 POST, 80/443 포트만 허용, 수신 측 처리 3초 제한이 명시되어 있습니다. 인그레스 설계와 함께 점검하세요.

애플리케이션이 실제로 누구의 IP를 보고 있는지 확인한 뒤, 허용 여부를 결정하세요. 허용 목록을 무작정 풀어 주는 것보다 안전합니다.

관련된 다른 실험으로, 장애 시 경고가 전송되고 복구 때 해제되는지 살펴본 Gatus + Docker Compose HTTP 503 경고 및 복구 테스트도 있습니다. 해당 글은 Real IP가 아니라 상태 모니터링을 다룹니다.