docs: collect evidence for the two-hop header contract failure
Control experiment isolates two independent causes: the nginx 443 block still emits X-Forwarded-Proto http, and Traefik rewrites forwarded headers regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ae1f391598
commit
69d4502757
@@ -0,0 +1,34 @@
|
||||
수집 시각: 2026-09-03 15:01:30 KST
|
||||
대상: https://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [1] 호스트 nginx 가 주입하는 헤더 ===
|
||||
3: server 192.168.122.11:80;
|
||||
4: server 192.168.122.12:80;
|
||||
8: listen 80 default_server;
|
||||
14: listen 443 ssl default_server;
|
||||
26: proxy_set_header Host $host;
|
||||
27: proxy_set_header X-Forwarded-Host $host;
|
||||
28: proxy_set_header X-Forwarded-Proto http;
|
||||
29: proxy_set_header X-Forwarded-Port 80;
|
||||
30: proxy_set_header X-Forwarded-For $remote_addr;
|
||||
31: proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
=== [2] Traefik entryPoint 인자 (forwardedHeaders 부재 확인) ===
|
||||
["--entryPoints.metrics.address=:9100/tcp"
|
||||
"--entryPoints.traefik.address=:8080/tcp"
|
||||
"--entryPoints.web.address=:8000/tcp"
|
||||
"--entryPoints.websecure.address=:8443/tcp"
|
||||
"--metrics.prometheus.entrypoint=metrics"
|
||||
"--entryPoints.websecure.http.tls=true"
|
||||
→ forwardedHeaders.trustedIPs 인자가 없음 = 기본값(신뢰 안 함)
|
||||
|
||||
=== [3] Traefik 파드 수와 위치 ===
|
||||
traefik-59b7647586-ftwf8 10.42.0.8 kc-lab-1
|
||||
|
||||
=== [4] traefik Service externalTrafficPolicy ===
|
||||
Cluster
|
||||
→ Cluster = svclb 가 SNAT 하여 클라이언트 IP 소실
|
||||
|
||||
=== [5] 앱 파드의 스위치 상태 ===
|
||||
SERVER_PORT=8081
|
||||
SERVER_FORWARD_HEADERS_STRATEGY=none
|
||||
@@ -0,0 +1,87 @@
|
||||
수집 시각: 2026-09-03 15:02:27 KST
|
||||
|
||||
=== [A] 정상 경로 — 브라우저와 같은 요청 ===
|
||||
명령: curl -s https://app1.hyeonworks.com/api/echo
|
||||
x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.0.1
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 10.42.0.1
|
||||
x-forwarded-server traefik-59b7647586-ftwf8
|
||||
--- 앱이 해석한 값
|
||||
scheme http
|
||||
secure False
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 80
|
||||
remoteAddr 10.42.0.8
|
||||
localAddr 10.42.1.3
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [B] 대조 실험 1 — nginx 우회, 헤더 없이 Traefik 직접 ===
|
||||
명령: curl http://192.168.122.11/api/echo -H 'Host: app1.hyeonworks.com' (test-server 에서)
|
||||
x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.0.1
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 10.42.0.1
|
||||
x-forwarded-server traefik-59b7647586-ftwf8
|
||||
--- 앱이 해석한 값
|
||||
scheme http
|
||||
secure False
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 80
|
||||
remoteAddr 10.42.0.8
|
||||
localAddr 10.42.0.9
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [C] 대조 실험 2 — nginx 우회, 올바른 헤더를 명시해서 ===
|
||||
명령: 위와 동일 + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' -H 'X-Forwarded-For: 203.0.113.7'
|
||||
x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.0.1
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 10.42.0.1
|
||||
x-forwarded-server traefik-59b7647586-ftwf8
|
||||
--- 앱이 해석한 값
|
||||
scheme http
|
||||
secure False
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 80
|
||||
remoteAddr 10.42.0.8
|
||||
localAddr 10.42.1.3
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
★ [C] 에서 https/443/203.0.113.7 을 명시했음에도 http/80/10.42.0.1 이 도달했다.
|
||||
→ Traefik 이 들어온 X-Forwarded-* 를 신뢰하지 않고 재작성한다는 독립적 증거.
|
||||
|
||||
=== [D] 위조 테스트 — 클라이언트가 직접 헤더 주입 ===
|
||||
명령: curl https://app1.hyeonworks.com/api/echo -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
||||
x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.1.0
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 10.42.1.0
|
||||
x-forwarded-server traefik-59b7647586-ftwf8
|
||||
--- 앱이 해석한 값
|
||||
scheme http
|
||||
secure False
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 80
|
||||
remoteAddr 10.42.0.8
|
||||
localAddr 10.42.0.9
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
★ evil.example.com 과 1.2.3.4 가 도달하지 않았다 = 신뢰 경계는 작동.
|
||||
|
||||
=== [E] 파드 분배 8회 ===
|
||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||
|
||||
=== [F] HTTP → HTTPS 리다이렉트 ===
|
||||
status=301 location=https://app1.hyeonworks.com/api/echo
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,120 @@
|
||||
# 증거 — 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` 덮어쓰기로
|
||||
옮겨간다. 수정 후 재측정에서 **위조가 여전히 막히는지 반드시 확인**해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 재수집 방법
|
||||
|
||||
```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
|
||||
```
|
||||
@@ -183,6 +183,59 @@ requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
---
|
||||
|
||||
## 5-1. 대조 실험 — 원인이 둘임을 분리한다
|
||||
|
||||
측정값만으로는 "누가 값을 바꿨는지" 알 수 없다. nginx를 우회해 Traefik에
|
||||
직접 요청하여 원인을 분리했다.
|
||||
|
||||
```bash
|
||||
# test-server 에서, nginx 를 거치지 않고 노드의 Traefik 에 직접
|
||||
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'
|
||||
```
|
||||
|
||||
| | 보낸 값 | 도달한 값 |
|
||||
|---|---|---|
|
||||
| `X-Forwarded-Proto` | `https` | **`http`** |
|
||||
| `X-Forwarded-Port` | `443` | **`80`** |
|
||||
| `X-Forwarded-For` | `203.0.113.7` | **`10.42.0.1`** |
|
||||
|
||||
**올바른 헤더를 명시했는데도 전부 재작성됐다.** Traefik의 덮어쓰기가
|
||||
독립적으로 증명된다.
|
||||
|
||||
그리고 이 과정에서 **두 번째 원인**이 드러났다.
|
||||
|
||||
### 원인 A — nginx가 애초에 틀린 값을 보내고 있다
|
||||
|
||||
`/etc/nginx/sites-available/keycloak-lab`의 443 서버 블록:
|
||||
|
||||
```nginx
|
||||
listen 443 ssl default_server;
|
||||
...
|
||||
proxy_set_header X-Forwarded-Proto http; # ← https 여야 한다
|
||||
proxy_set_header X-Forwarded-Port 80; # ← 443 이어야 한다
|
||||
```
|
||||
|
||||
**TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.**
|
||||
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 이 두 줄을 함께 바꾸지 않아
|
||||
남은 값이다.
|
||||
|
||||
문법 오류가 아니므로 `nginx -t`도 통과하고 **아무 경고 없이 잘못된 값이
|
||||
전파된다.** 이런 종류의 실수는 측정 없이는 드러나지 않는다.
|
||||
|
||||
### 원인 B — Traefik이 올바른 값이 와도 덮어쓴다
|
||||
|
||||
위 대조 실험이 보여준 것이다. `forwardedHeaders.trustedIPs` 미설정.
|
||||
|
||||
**두 원인은 직렬로 걸려 있다. A만 고쳐도 B 때문에 해결되지 않는다.**
|
||||
|
||||
> 증거 원자료: `docs/evidence/two-hop-proxy-headers/`
|
||||
|
||||
---
|
||||
|
||||
## 6. 원인 — 독립된 스위치 세 개
|
||||
|
||||
이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다.
|
||||
|
||||
Reference in New Issue
Block a user