diff --git a/docs/two-hop-proxy-header-contract.md b/docs/two-hop-proxy-header-contract.md new file mode 100644 index 0000000..c4248cf --- /dev/null +++ b/docs/two-hop-proxy-header-contract.md @@ -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`