Files
keycloak-pattern/docs/evidence/two-hop-proxy-headers/README.md

7.9 KiB

증거 — 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-Protohttp다. 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 덮어쓰기로 옮겨간다. 수정 후 재측정에서 위조가 여전히 막히는지 반드시 확인해야 한다.


재수집 방법

# 터미널 증거
./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로 허용해 이를 피했다.