# 증거 — 2홉 프록시 헤더 계약 (수정 전 상태) `docs/two-hop-proxy-header-contract.md`의 진단을 뒷받침하는 원자료. **모두 수정 전 상태에서 수집**했으며, 수정 후 재수집하여 대조한다. 수집 시각: 2026-09-03 15:01~15:03 KST | 파일 | 내용 | |---|---| | `01-environment.txt` | 수정 전 세 계층의 설정 스냅샷 | | `02-measurements.txt` | 수정 전 측정 · 대조 실험 · 위조 테스트 · 분배 | | `stage-a-nginx-fixed.png` | A 단계 브라우저 화면 | | `stage-b-traefik-trusts.png` | B 단계 브라우저 화면 | | `stage-c-resolved.png` | C 단계 브라우저 화면 | | `04-after-fix.txt` | 수정 후 측정 · 위조 테스트 · 분배 | --- ## 확인된 문제는 둘이다 최초 진단은 "Traefik이 덮어쓴다" 하나였으나, 증거 수집 과정에서 **독립된 원인이 두 개**임이 드러났다. ### 문제 1 — nginx가 애초에 틀린 값을 보낸다 `01-environment.txt` ``` 26: proxy_set_header Host $host; 27: proxy_set_header X-Forwarded-Host $host; 28: proxy_set_header X-Forwarded-Proto http; ← https 여야 한다 29: proxy_set_header X-Forwarded-Port 80; ← 443 이어야 한다 30: proxy_set_header X-Forwarded-For $remote_addr; 31: proxy_set_header X-Real-IP $remote_addr; ``` `listen 443 ssl` 서버 블록 안인데 `X-Forwarded-Proto`가 `http`다. TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다. HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 **이 두 줄을 함께 바꾸지 않아 남은 값**이다. 설정 자체는 문법 오류가 없으므로 `nginx -t`도 통과하고, **아무 경고 없이 잘못된 값이 전파된다.** ### 문제 2 — Traefik이 올바른 값이 와도 덮어쓴다 `02-measurements.txt`의 **대조 실험 [C]** 가 이를 독립적으로 증명한다. nginx를 우회해 Traefik에 직접 요청하면서 올바른 헤더를 명시했다. ``` 보낸 것 : X-Forwarded-Proto: https X-Forwarded-Port: 443 X-Forwarded-For: 203.0.113.7 도달한 것: x-forwarded-proto http x-forwarded-port 80 x-forwarded-for 10.42.0.1 ``` **세 값 모두 재작성됐다.** Traefik entryPoint에 `forwardedHeaders.trustedIPs`가 설정되지 않아 들어온 헤더를 신뢰하지 않는다. `01-environment.txt`의 Traefik 인자 목록에 `forwardedHeaders` 관련 항목이 하나도 없는 것이 그 근거다. **문제 1만 고쳐서는 해결되지 않는다.** 두 원인이 직렬로 걸려 있다. --- ## 브라우저 증거 스크린샷은 모두 **브라우저가 `/api/echo` 응답을 렌더링한 실제 화면**이다. 앱이 정렬된 JSON을 내보내도록 `spring.jackson.serialization.indent-output`을 켜두었으므로 브라우저의 JSON 뷰어 설정과 무관하게 동일하게 읽힌다. 세 장은 **같은 요청을 세 가지 설정 상태에서** 찍은 것이다. | 파일 | 켜진 스위치 | 화면에서 확인할 것 | |---|---|---| | `stage-a-nginx-fixed.png` | nginx 만 | `x-forwarded-proto: http` — Traefik 이 덮어씀 | | `stage-b-traefik-trusts.png` | nginx + Traefik | **헤더는 `https`인데 `scheme: http`** | | `stage-c-resolved.png` | 셋 다 | `scheme: https`, `secure: true` | **`stage-b`가 가장 중요한 한 장이다.** `x-forwarded-proto: https`가 앱에 도착해 있는데도 `scheme: http`, `secure: false`, `requestUrl: http://...`다. **헤더가 도착하는 것과 앱이 그것을 읽는 것은 다른 문제**임을 한 화면이 보여준다. ## 정상으로 확인된 것 증거 수집에서 **문제가 아니라고 확인된 항목**도 함께 남긴다. | 항목 | 결과 | |---|---| | TLS 종료 | 정상. 실인증서, `isSecureContext=true` | | `X-Forwarded-Host` | 유지됨 — Traefik이 이것만은 덮어쓰지 않는다 | | 위조 차단 | 클라이언트가 넣은 `evil.example.com`, `1.2.3.4`가 앱에 도달하지 않음 | | 파드 분배 | 8회 요청이 두 파드에 정확히 번갈아 도달 | | HTTP 리다이렉트 | `301 → https://app1.hyeonworks.com/api/echo` | **위조가 차단되는 것은 nginx가 막아서가 아니라 Traefik이 전부 덮어쓰기 때문**이다. 문제 2를 고치면 이 방어가 nginx의 `$remote_addr` 덮어쓰기로 옮겨간다. 수정 후 재측정에서 **위조가 여전히 막히는지 반드시 확인**해야 한다. --- ## 재수집 방법 ```bash # 터미널 증거 ./deploy/lab/scripts/measure-proxy-headers.sh # 개별 확인 curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool # 대조 실험 (test-server 에서, nginx 우회) curl -s http://192.168.122.11/api/echo \ -H 'Host: app1.hyeonworks.com' \ -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' \ -H 'X-Forwarded-For: 203.0.113.7' | python3 -m json.tool ``` --- ## 수정 후 (2026-09-03 15:32 KST) 세 스위치를 순서대로 켜며 각 단계를 측정했다. 상세 절차는 `docs/two-hop-proxy-header-contract.md` 9~11절. | 파일 | 단계 | |---|---| | `stage-a-nginx-fixed.png` | A — nginx 만 고침 | | `stage-b-traefik-trusts.png` | B — Traefik `trustedIPs` 추가 | | `stage-c-resolved.png` | C — 앱 `strategy=native` | | `04-after-fix.txt` | 최종 측정 · 위조 테스트 · 분배 | 스크린샷은 브라우저가 `/api/echo` 응답을 렌더링한 **실제 화면**이다. ### 단계별 결과 | 항목 | 최초 | A | B | C | |---|---|---|---|---| | `x-forwarded-proto` | `http` | **`http`** | `https` | `https` | | `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` | | `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** | | `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** | **A 이후 아무 변화가 없는 것**이 Traefik 덮어쓰기의 증거이고, **B 이후 헤더는 살아났으나 앱 해석은 그대로인 것**이 2번과 3번 스위치가 다른 일을 한다는 증거다. ### 위조 차단 재확인 `04-after-fix.txt` [5]. 클라이언트가 `X-Forwarded-Proto: http`, `X-Forwarded-Host: evil.example.com`, `X-Forwarded-For: 1.2.3.4`를 주입했으나 **하나도 반영되지 않았다.** **방어 주체가 바뀌었다.** 수정 전에는 Traefik이 전부 덮어써서 막았고, 수정 후에는 nginx의 `$remote_addr`가 막는다. 그래서 nginx에서 `$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 안 된다. ### 겪은 함정 `kubectl rollout status`가 완료를 알려도 **helm-controller의 Job이 차트를 업그레이드하는 동안 구 Traefik 파드가 함께 살아 있다.** 이 시점에 측정하면 옛 파드가 응답해 "고쳤는데 안 바뀌었다"고 오해하게 된다. `x-forwarded-server` 값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다. --- ## 프록시 우회 차단 (2026-09-03 16:16 KST) `05-networkpolicy.txt` 헤더 신뢰를 켠 뒤 남아 있던 구멍을 실증하고 막았다. **차단 전** — 클러스터 안에서 Traefik을 우회해 앱에 직접 요청하면 `serverName: evil.example.com`, `remoteAddr: 1.2.3.4`로 **위조가 성립했다.** **적용한 것** | 파일 | 변경 | |---|---| | `traefik-forwarded-headers.yaml` | `192.168.122.0/24` 제거 (SNAT 때문에 도달 불가한 대역) | | `echo-network-policy.yaml` | Traefik 파드에서만 8081 허용 (라벨 기준) | **차단 후** ``` 정상 경로 scheme=https, remoteAddr=100.123.124.30 동작 우회 시도 HTTP 000 / curl exit 7 연결 거부 파드 상태 1/1 Running, restarts=0 probe 정상 ``` `exit 7`은 curl의 "Failed to connect"다. HTTP 403이 아니라 **TCP 연결 자체가 성립하지 않았다**는 뜻이다. `restarts=0`이 중요하다. NetworkPolicy에서 kubelet probe 경로를 빠뜨리면 probe가 실패해 파드가 재시작 루프에 빠진다. 노드의 cni0 주소 (`10.42.0.1`, `10.42.1.1`)를 `/32`로 허용해 이를 피했다.