docs: measure and diagnose the two-hop proxy header contract

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-03 14:35:27 +09:00
co-authored by Claude Opus 5
parent a831792c5c
commit 844d6f1d33
+244
View File
@@ -0,0 +1,244 @@
# 2홉 프록시 헤더 계약 — 측정과 진단
`docs/reverse-proxy-headers.md`의 계약은 **nginx 한 홉**을 가정하고 쓰였다.
실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로, 그 계약이 그대로
성립하는지 측정했다. **성립하지 않는다.**
## 왜 이것부터 재는가
Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원래 요청이
무엇이었나"** 에 의존한다.
| 만들어지는 값 | 의존하는 정보 |
|---|---|
| 토큰의 `iss` 클레임 | 외부 스킴 + 호스트 |
| 로그인 redirect URL | 외부 스킴 + 호스트 |
| 세션 쿠키의 `Secure` 속성 | 외부 스킴 |
| brute-force 탐지·감사 로그 | 클라이언트 IP |
그런데 TLS는 맨 앞 nginx가 끊는다. 그 뒤로는 **평문 HTTP**가 흐르므로,
뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 **오직 `X-Forwarded-*`
헤더로만** 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부
틀어진다.
Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 문제인지 구분할
수 없다.** 그래서 Keycloak 없이 이 계약만 먼저 떼어내 측정했다.
## 측정 장치
`backend``/api/echo`가 **자신에게 실제로 도달한 것**을 그대로 돌려준다.
```
GET https://app1.hyeonworks.com/api/echo
→ { headers, remoteAddr, localAddr, scheme, secure, serverName, serverPort, requestUrl }
```
`scheme` · `secure` · `requestUrl`은 Keycloak이 `iss`와 redirect URL을 만들 때
쓰는 것과 **같은 종류의 값**이다. `localAddr`은 파드 IP이므로 어느 노드가
응답했는지 알려준다.
배포는 `deploy/lab/k8s/echo.yaml`, 실행은
`deploy/lab/scripts/measure-proxy-headers.sh`.
## 요청 흐름 — 홉마다 헤더가 어떻게 변하는가
```
┌─ 1. 브라우저 ────────────────────────────────────────────────┐
│ GET /api/echo │
│ Host: app1.hyeonworks.com │
│ 전 구간 TLS 로 암호화 │
└──────────────────────────┬───────────────────────────────────┘
│ tailnet → 100.83.212.4:443
┌─ 2. 호스트 nginx ────────▼───────────────────────────────────┐
│ ★ TLS 종료 — 이 지점부터 평문 HTTP │
│ │
│ 원래 요청 정보를 헤더로 바꿔 붙인다: │
│ Host app1.hyeonworks.com │
│ X-Forwarded-Host app1.hyeonworks.com │
│ X-Forwarded-Proto https ← 원래 스킴 │
│ X-Forwarded-Port 443 │
│ X-Forwarded-For <클라이언트 IP> ($remote_addr 로 덮어씀) │
│ X-Real-IP <클라이언트 IP> │
└──────────────────────────┬───────────────────────────────────┘
│ upstream 라운드로빈
│ 192.168.122.11:80 또는 .12:80
┌─ 3. svclb (klipper-lb) ──▼───────────────────────────────────┐
│ 노드의 hostPort 80 에서 받아 iptables 로 전달 │
│ externalTrafficPolicy: Cluster → SNAT 발생 │
│ │
│ ★ 출발지 IP 가 노드의 flannel 게이트웨이로 바뀐다 │
│ → 클라이언트 IP 1차 소실 │
└──────────────────────────┬───────────────────────────────────┘
│ 10.42.0.8 (Traefik 파드)
┌─ 4. Traefik ─────────────▼───────────────────────────────────┐
│ Ingress 규칙 매칭: host app1.hyeonworks.com, path /api │
│ │
│ ★ forwardedHeaders.trustedIPs 미설정 │
│ → 들어온 X-Forwarded-* 를 신뢰하지 않고 │
│ 자기가 받은 연결을 기준으로 다시 쓴다 │
│ │
│ X-Forwarded-Proto https → http 자기가 받은 게 평문이므로 │
│ X-Forwarded-Port 443 → 80 │
│ X-Forwarded-For 실IP → 10.42.1.0 ← 2차 소실 │
│ X-Real-IP 실IP → 10.42.1.0 │
│ X-Forwarded-Host 유지 │
│ X-Forwarded-Server traefik-... 자기 이름 추가 │
└──────────────────────────┬───────────────────────────────────┘
│ Service → 파드
┌─ 5. 애플리케이션 ────────▼───────────────────────────────────┐
│ Spring: forward-headers-strategy = none │
│ → forwarded 헤더를 해석하지 않고 TCP 연결 그대로 보고 │
│ │
│ scheme http │
│ secure false │
│ requestUrl http://app1.hyeonworks.com/api/echo │
└──────────────────────────────────────────────────────────────┘
```
## 측정 결과
| 헤더 | nginx 가 보낸 값 | 앱에 도달한 값 | |
|---|---|---|---|
| `X-Forwarded-Proto` | `https` | **`http`** | 덮어써짐 |
| `X-Forwarded-Port` | `443` | **`80`** | 덮어써짐 |
| `X-Forwarded-For` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
| `X-Real-IP` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
| `X-Forwarded-Host` | `app1.hyeonworks.com` | `app1.hyeonworks.com` | 유지 |
앱이 최종적으로 보는 값:
```
scheme http
secure false
requestUrl http://app1.hyeonworks.com/api/echo
```
**위조 테스트** — 클라이언트가 `X-Forwarded-For: 1.2.3.4`,
`X-Forwarded-Host: evil.example.com`을 직접 붙여 요청했으나 **앱에 흔적이
도달하지 않았다.** 다만 이는 nginx가 막아서가 아니라 **Traefik이 전부
덮어썼기 때문**이다. 신뢰 경계는 결과적으로 작동하지만, 그 대가로 정당한
값까지 함께 버려진다.
**파드 분배** — 8회 요청이 두 파드(`10.42.0.9`, `10.42.1.3`)에 정확히 번갈아
도달했다. nginx upstream 라운드로빈과 Service 분배가 모두 작동한다.
**Traefik은 1개다** (`traefik-...`, kc-lab-1). k3s 기본이 replica 1이므로
두 노드의 svclb가 같은 파드로 보낸다. 노드 상실 실험에서 변수가 된다.
## 원인 — 독립된 스위치 세 개
이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다.
**하나만 꺼져 있어도 정보가 끊긴다.**
| # | 위치 | 스위치 | 현재 | 꺼져 있으면 |
|---|---|---|---|---|
| 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더가 아예 생성되지 않음 |
| 2 | **Traefik** | `forwardedHeaders.trustedIPs` | **꺼짐** | **들어온 헤더를 버리고 재작성** |
| 3 | 앱 | `forward-headers-strategy` / `KC_PROXY_HEADERS` | **꺼짐** | 헤더가 도착해도 무시 |
지금은 2번에서 끊긴다. 3번도 꺼져 있으므로, 2번을 고쳐도 3번을 켜지 않으면
앱은 여전히 원래 스킴을 모른다.
**프레임워크가 기본적으로 forwarded 헤더를 믿지 않는 것은 의도된 설계다.**
이 헤더들은 누구나 위조할 수 있는 평범한 HTTP 헤더이므로, **"누구로부터 온
것을 믿을지"를 명시하지 않으면 신뢰하지 않는 쪽이 안전**하다.
여기에 네 번째 요인이 겹친다.
**`externalTrafficPolicy: Cluster`** — svclb가 트래픽을 SNAT하면서
클라이언트 IP가 Traefik에 도달하기 전에 이미 사라진다. 2번을 고쳐도
`X-Forwarded-For`에 담긴 nginx의 값은 살아나지만, **TCP 출발지 주소 자체는
복원되지 않는다.**
## 영향
**Keycloak의 `iss`는 이것만으로 깨지지 않는다.**
`KC_HOSTNAME=https://auth.hyeonworks.com`처럼 **전체 URL을 주면 스킴이
고정**되어 `X-Forwarded-Proto`와 무관하게 https로 발급된다.
실제로 남는 피해는 셋이다.
1. **클라이언트 IP 완전 소실** — brute-force 탐지, 감사 로그, IP 기반 접근
제한이 모두 무의미해진다. 모든 요청이 `10.42.x.0`에서 온 것으로 보인다
2. **일반 애플리케이션은 그대로 깨진다** — BFF나 oauth2-proxy는
`X-Forwarded-Proto`를 보고 쿠키의 `Secure` 부여 여부와 리다이렉트 URL을
정한다. `http`로 읽으면 **세션 쿠키에 `Secure`가 붙지 않는다**
3. **1홉 계약이 2홉에서 성립하지 않음이 확정**`docs/reverse-proxy-headers.md`
"nginx가 헤더를 덮어쓰고 Keycloak이 `KC_PROXY_HEADERS`로 받는다"까지만
기술한다. 그 사이에 Traefik이 있다는 사실이 빠져 있다
## 해결
### 2번 스위치 — Traefik이 nginx의 헤더를 신뢰하게 한다
k3s의 Traefik은 HelmChart로 배포되므로 `HelmChartConfig`로 값을 덮어쓴다.
```yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
web:
forwardedHeaders:
trustedIPs:
- 10.42.0.0/16 # 파드 대역 (svclb SNAT 출발지)
- 192.168.122.0/24 # 노드·호스트 대역
```
`trustedIPs`에 해당하는 출발지에서 온 요청은 **기존 `X-Forwarded-*`
신뢰하고 통과**시킨다.
**트레이드오프** — 파드 대역 전체를 신뢰하면 **클러스터 안의 어떤 파드든
헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 더 좁혀야
한다. 좁히려면 `externalTrafficPolicy: Local`로 바꿔 SNAT를 없애고 실제
출발지(호스트 nginx의 `192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면
해당 노드에 Traefik 파드가 없을 때 트래픽이 버려진다.
### 3번 스위치 — 앱이 헤더를 해석하게 한다
| 대상 | 설정 |
|---|---|
| Spring Boot | `server.forward-headers-strategy=native` |
| Keycloak | `KC_PROXY_HEADERS=xforwarded` |
| oauth2-proxy | `--reverse-proxy=true` |
**두 스위치는 성격이 같다.** 프레임워크마다 이름만 다를 뿐, "앞단 프록시가
붙여준 헤더를 신뢰하겠다"는 동일한 옵트인이다.
## 검증
수정 후 아래가 모두 성립해야 계약이 복원된 것이다.
```bash
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
```
| 항목 | 기대값 |
|---|---|
| `x-forwarded-proto` | `https` |
| `x-forwarded-port` | `443` |
| `x-forwarded-for` | 실제 클라이언트 IP |
| `scheme` (앱 스위치 켠 뒤) | `https` |
| `secure` | `true` |
| `requestUrl` | `https://app1.hyeonworks.com/api/echo` |
그리고 **위조 테스트가 여전히 막혀야 한다.**
```bash
curl -s https://app1.hyeonworks.com/api/echo -H 'X-Forwarded-Host: evil.example.com'
```
`evil.example.com`이 도달하면 신뢰 경계가 무너진 것이다. Traefik을 신뢰하도록
바꾼 뒤에도 **nginx가 `$remote_addr`로 덮어쓰는 한** 클라이언트 위조는 nginx
층에서 차단된다. 이 두 가지가 동시에 성립하는 것이 목표 상태다.
## 참고
- 1홉 계약 원본: `docs/reverse-proxy-headers.md`
- 개념 상세: `docs/session-lab-concepts.md`
- 측정 배포: `deploy/lab/k8s/echo.yaml`
- 측정 실행: `deploy/lab/scripts/measure-proxy-headers.sh`