증거 — 2홉 프록시 헤더 계약 (수정 전 상태)
docs/two-hop-proxy-header-contract.md의 진단을 뒷받침하는 원자료.
모두 수정 전 상태에서 수집했으며, 수정 후 재수집하여 대조한다.
수집 시각: 2026-09-03 15:01~15:03 KST
| 파일 | 내용 |
|---|---|
01-environment.txt |
세 계층의 설정 스냅샷 |
02-measurements.txt |
정상 경로·대조 실험·위조 테스트·분배·리다이렉트 |
03-browser-https-vs-app-http.png |
브라우저와 앱의 인식 차이 (Playwright) |
확인된 문제는 둘이다
최초 진단은 "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만 고쳐서는 해결되지 않는다. 두 원인이 직렬로 걸려 있다.
브라우저 증거
03-browser-https-vs-app-http.png
같은 요청을 두 관점에서 나란히 찍었다.
| 브라우저 | 앱(파드) |
|---|---|
location.href = https://app1.hyeonworks.com/api/echo |
requestUrl = http://app1.hyeonworks.com/api/echo |
protocol = https: |
scheme = http |
isSecureContext = true |
secure = false |
브라우저는 TLS로 정상 접속했는데 앱은 평문 HTTP 요청으로 인식한다.
이 상태에서 세션 쿠키에는 Secure가 붙지 않고, OAuth2 redirect_uri는
http://로 생성된다.
Playwright로 페이지에 접속한 뒤 location 값과 응답 JSON을 한 화면에
렌더링해 촬영했다. 스크린샷은 뷰포트만 담기고 주소창은 담기지 않으므로,
브라우저 측 값을 페이지 안으로 끌어와 대조가 한 장에 들어오게 했다.
정상으로 확인된 것
증거 수집에서 문제가 아니라고 확인된 항목도 함께 남긴다.
| 항목 | 결과 |
|---|---|
| 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
값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다.