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

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

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

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

```json
{"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` 반환 | 토큰과 애플리케이션의 실제 인증 순서 |
| 특정 경로만 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 목록을 혼동하면 안 됩니다.

- **TradingView Webhook 발신 IP:** 애플리케이션 수준의 허용 목록에 사용합니다. [TradingView 공식 도움말](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/)에는 POST를 보내는 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의 네 주소를 `set_real_ip_from`에 넣으면 실제 발신자와 신뢰 프록시의 역할을 뒤바꾼 셈입니다.

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

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

```nginx
# 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](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 파일은 둘 중 하나만 사용합니다.

자동 업데이트도 가능하지만 다운로드한 값을 곧바로 덮어쓰고 무조건 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 공식 문서](https://nginx.org/en/docs/http/ngx_http_realip_module.html)를 참고하세요.

당시에는 설정 파일 백업, 문법 검사, Nginx reload, 서비스 상태 확인을 진행했습니다. 읽기 중심 점검 명령은 다음과 같습니다.

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

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

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

직접 연결한 health 경로에서 200이 나온다면 오리진 방화벽과 [Cloudflare Authenticated Origin Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/explanation/) 등을 점검하세요. 한 번의 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 헤더 없음, 올바른 시험용 토큰 | **403** | `IP not allowed` |
| 신뢰 입구, 맞는 IP 헤더, 틀린 토큰 | **401** | `Invalid token` |
| 신뢰 입구, IP와 토큰 모두 맞음 | **202** | `Accepted synthetic webhook` |
| 비신뢰 입구, `CF-Connecting-IP` 위조, 올바른 토큰 | **403** | `IP not allowed` |

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

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

**하나의 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 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` 명령을 계속 실행합니다.

```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 네 가지 테스트는 주로 `X-Real-IP`와 HTTP 응답을 검사했습니다.

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

```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 설명](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가 아니라 상태 모니터링을 다룹니다.
