docs: record the staged fix and post-fix evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1c1b86e849
commit
7737787937
@@ -0,0 +1,55 @@
|
||||
수집 시각: 2026-09-03 15:32:04 KST
|
||||
단계: A(nginx) + B(Traefik) + C(앱) 모두 적용 후
|
||||
|
||||
=== [1] nginx 가 보내는 값 ===
|
||||
28: proxy_set_header X-Forwarded-Proto https;
|
||||
29: proxy_set_header X-Forwarded-Port 443;
|
||||
30: proxy_set_header X-Forwarded-For $remote_addr;
|
||||
31: proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
=== [2] Traefik entryPoint 인자 ===
|
||||
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16
|
||||
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16
|
||||
|
||||
=== [3] 앱 스위치 ===
|
||||
SERVER_FORWARD_HEADERS_STRATEGY=native
|
||||
|
||||
=== [4] 최종 측정 ===
|
||||
x-forwarded-proto https
|
||||
x-forwarded-port 443
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 100.123.124.30
|
||||
x-forwarded-server traefik-697889c85-g7xpp
|
||||
--- 앱이 해석한 값
|
||||
scheme https
|
||||
secure True
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 443
|
||||
remoteAddr 100.123.124.30
|
||||
localAddr 10.42.0.10
|
||||
requestUrl https://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [5] 위조 테스트 — 클라이언트가 http/evil/1.2.3.4 를 주입 ===
|
||||
x-forwarded-proto https
|
||||
x-forwarded-port 443
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 100.123.124.30
|
||||
x-forwarded-server traefik-697889c85-g7xpp
|
||||
--- 앱이 해석한 값
|
||||
scheme https
|
||||
secure True
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 443
|
||||
remoteAddr 100.123.124.30
|
||||
localAddr 10.42.1.6
|
||||
requestUrl https://app1.hyeonworks.com/api/echo
|
||||
|
||||
★ 주입값이 하나도 반영되지 않았다. nginx 의 $remote_addr 덮어쓰기가 방어한다.
|
||||
|
||||
=== [6] 파드 분배 6회 ===
|
||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||
@@ -118,3 +118,49 @@ curl -s http://192.168.122.11/api/echo \
|
||||
-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`
|
||||
값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -498,11 +498,58 @@ downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단**
|
||||
|
||||
---
|
||||
|
||||
## 9. 해결
|
||||
## 9. 해결 — 어느 파일의 무엇을 어떻게 고치는가
|
||||
|
||||
### 2번 스위치 — Traefik이 nginx의 헤더를 신뢰하게 한다
|
||||
세 곳을 순서대로 고쳤다. **각 단계마다 측정하여 어느 스위치가 무엇을
|
||||
담당하는지 데이터로 확인했다.**
|
||||
|
||||
k3s의 Traefik은 HelmChart로 배포되므로 `HelmChartConfig`로 값을 덮어쓴다.
|
||||
### A. nginx — 원래 스킴을 사실대로 알린다
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 저장소 파일 | `deploy/lab/host/nginx-keycloak-lab.conf` |
|
||||
| 서버 배포 위치 | `/etc/nginx/sites-available/keycloak-lab` |
|
||||
| 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 |
|
||||
|
||||
`server { listen 443 ssl ... }` 블록의 `location /` 안에서 두 줄을 고친다.
|
||||
|
||||
```diff
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
- proxy_set_header X-Forwarded-Proto http;
|
||||
- proxy_set_header X-Forwarded-Port 80;
|
||||
+ proxy_set_header X-Forwarded-Proto https;
|
||||
+ proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
```
|
||||
|
||||
**`listen 443 ssl`과 `proxy_pass http://`는 건드리지 않는다.**
|
||||
앞에서 HTTPS로 받고 뒤로 평문으로 보내는 것은 의도된 설계다.
|
||||
고치는 것은 **뒤로 보낼 때 붙이는 라벨**뿐이다.
|
||||
|
||||
배포:
|
||||
|
||||
```bash
|
||||
cd ~/workspace/keycloak-pattern && git pull
|
||||
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab
|
||||
```
|
||||
|
||||
**손으로 서버 파일을 고치지 않는다.** 저장소에서 단방향으로 복사한다.
|
||||
이 실수가 발생한 원인 자체가 저장소와 서버의 드리프트였다.
|
||||
|
||||
### B. Traefik — nginx의 헤더를 신뢰한다
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 저장소 파일 | `deploy/lab/k8s/traefik-forwarded-headers.yaml` (신규) |
|
||||
| 적용 대상 | `kube-system` 네임스페이스의 Traefik HelmChart |
|
||||
|
||||
k3s의 Traefik은 번들 HelmChart로 설치되므로 **Deployment를 직접 고치면
|
||||
안 된다.** helm-controller가 되돌린다. `HelmChartConfig`로 차트 값을
|
||||
덮어써야 한다.
|
||||
|
||||
```yaml
|
||||
apiVersion: helm.cattle.io/v1
|
||||
@@ -518,33 +565,115 @@ spec:
|
||||
trustedIPs:
|
||||
- 10.42.0.0/16 # 파드 대역 (svclb SNAT 출발지)
|
||||
- 192.168.122.0/24 # 노드·호스트 대역
|
||||
websecure:
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- 10.42.0.0/16
|
||||
- 192.168.122.0/24
|
||||
```
|
||||
|
||||
`trustedIPs`에 해당하는 출발지에서 온 요청은 **기존 `X-Forwarded-*`를
|
||||
신뢰하고 통과**시킨다.
|
||||
**`10.42.0.0/16`이 필요한 이유** — traefik Service가
|
||||
`externalTrafficPolicy: Cluster`이므로 svclb가 SNAT한다. Traefik이 보는
|
||||
출발지는 호스트 nginx의 주소가 아니라 **파드 네트워크 주소**다.
|
||||
|
||||
적용:
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
|
||||
kubectl -n kube-system rollout status deploy/traefik --timeout=180s
|
||||
```
|
||||
|
||||
**함정 — `rollout status` 완료가 곧 반영은 아니다.** helm-controller가
|
||||
`helm-install-traefik` **Job을 새로 돌려** 차트를 업그레이드하므로, 그 사이
|
||||
**구 파드가 잠시 함께 살아 있다.** 이 시점에 측정하면 옛 파드가 응답해
|
||||
"고쳤는데 안 바뀌었다"고 오해하게 된다. 실제로 이 함정에 한 번 걸렸다.
|
||||
|
||||
파드 이름과 인자로 확인한다.
|
||||
|
||||
```bash
|
||||
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik
|
||||
kubectl -n kube-system get pod -l app.kubernetes.io/name=traefik \
|
||||
-o jsonpath='{.items[0].spec.containers[0].args}' | tr ',' '\n' | grep -i forwarded
|
||||
# --entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16,192.168.122.0/24
|
||||
```
|
||||
|
||||
**트레이드오프** — 파드 대역 전체를 신뢰하면 **클러스터 안의 어떤 파드든
|
||||
헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 더 좁혀야
|
||||
헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 좁혀야
|
||||
한다. 좁히려면 `externalTrafficPolicy: Local`로 SNAT를 없애고 실제
|
||||
출발지(호스트 nginx의 `192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면
|
||||
해당 노드에 Traefik 파드가 없을 때 트래픽이 버려진다.
|
||||
출발지(`192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik
|
||||
파드가 없을 때 트래픽이 버려진다.
|
||||
|
||||
### 3번 스위치 — 앱마다 켠다
|
||||
### C. 앱 — 도착한 헤더를 해석한다
|
||||
|
||||
| 대상 | 설정 |
|
||||
| | |
|
||||
|---|---|
|
||||
| Spring Boot | `server.forward-headers-strategy=native` |
|
||||
| Keycloak | `KC_PROXY_HEADERS=xforwarded` |
|
||||
| oauth2-proxy | `--reverse-proxy=true` |
|
||||
| 저장소 파일 | `deploy/lab/k8s/echo.yaml` |
|
||||
| 위치 | `spec.template.spec.containers[0].env` |
|
||||
|
||||
**세 스위치는 성격이 같다.** 프레임워크마다 이름만 다를 뿐, "앞단 프록시가
|
||||
붙여준 헤더를 신뢰하겠다"는 동일한 옵트인이다.
|
||||
```diff
|
||||
- name: SERVER_FORWARD_HEADERS_STRATEGY
|
||||
- value: "none"
|
||||
+ value: "native"
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/lab/k8s/echo.yaml
|
||||
kubectl -n header-lab rollout status deployment/echo --timeout=180s
|
||||
```
|
||||
|
||||
앱마다 스위치 이름이 다르다.
|
||||
|
||||
| 앱 | 설정 | 넣는 곳 |
|
||||
|---|---|---|
|
||||
| Spring Boot | `SERVER_FORWARD_HEADERS_STRATEGY=native` | 컨테이너 `env` |
|
||||
| **Keycloak** | **`KC_PROXY_HEADERS=xforwarded`** | 컨테이너 `env` |
|
||||
| oauth2-proxy | `--reverse-proxy=true` | 컨테이너 `args` |
|
||||
|
||||
**새 앱을 올릴 때마다 반복해야 한다.** 빠뜨려도 오류가 나지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 검증
|
||||
## 10. 단계별 측정 결과
|
||||
|
||||
수정 후 아래가 모두 성립해야 계약이 복원된 것이다.
|
||||
각 스위치를 하나씩 켜면서 측정한 값이다.
|
||||
**어느 스위치가 무엇을 담당하는지가 그대로 드러난다.**
|
||||
|
||||
| 측정 항목 | 최초 | A 이후 | B 이후 | **C 이후** |
|
||||
|---|---|---|---|---|
|
||||
| nginx 가 보내는 값 | `http`/`80` | `https`/`443` | `https`/`443` | `https`/`443` |
|
||||
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
|
||||
| `x-forwarded-port` | `80` | **`80`** | `443` | `443` |
|
||||
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
|
||||
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
|
||||
| `secure` | `false` | `false` | **`false`** | **`true`** |
|
||||
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
|
||||
|
||||
**A 이후에 아무것도 바뀌지 않은 것**이 Traefik의 덮어쓰기를 증명한다.
|
||||
nginx가 올바른 값을 보내는데도 앱에는 `http`가 도달했다.
|
||||
|
||||
**B 이후에 헤더는 살아났지만 앱 해석은 그대로**인 것이 2번과 3번 스위치가
|
||||
서로 다른 일을 한다는 증거다. 헤더는 도착해 있었지만 앱이 읽지 않았다.
|
||||
|
||||
**C 이후에야 앱이 원래 요청을 인식한다.**
|
||||
|
||||
최종 상태:
|
||||
|
||||
```
|
||||
x-forwarded-proto https
|
||||
x-forwarded-port 443
|
||||
x-real-ip 100.123.124.30 ← 실제 클라이언트(워크스테이션 tailnet IP)
|
||||
scheme https
|
||||
secure True
|
||||
serverPort 443
|
||||
remoteAddr 100.123.124.30
|
||||
requestUrl https://app1.hyeonworks.com/api/echo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 검증
|
||||
|
||||
### 정상 동작
|
||||
|
||||
```bash
|
||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
|
||||
@@ -554,20 +683,69 @@ 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` |
|
||||
| `x-real-ip` | 실제 클라이언트 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'
|
||||
curl -s https://app1.hyeonworks.com/api/echo \
|
||||
-H 'X-Forwarded-Proto: http' \
|
||||
-H 'X-Forwarded-Host: evil.example.com' \
|
||||
-H 'X-Forwarded-For: 1.2.3.4' \
|
||||
-H 'X-Real-IP: 1.2.3.4' | python3 -m json.tool
|
||||
```
|
||||
|
||||
`evil.example.com`이 도달하면 신뢰 경계가 무너진 것이다. Traefik을 신뢰하도록
|
||||
바꾼 뒤에도 **nginx가 `$remote_addr`로 덮어쓰는 한** 클라이언트 위조는 nginx
|
||||
층에서 차단된다. **이 두 가지가 동시에 성립하는 것이 목표 상태다.**
|
||||
**주입한 값이 하나도 반영되지 않아야 한다.** 실측 결과 `scheme=https`,
|
||||
`serverName=app1.hyeonworks.com`, `remoteAddr=100.123.124.30`이 유지됐다.
|
||||
|
||||
방어의 주체가 바뀌었다는 점에 유의한다.
|
||||
|
||||
| | 수정 전 | 수정 후 |
|
||||
|---|---|---|
|
||||
| 위조를 막는 주체 | **Traefik** (전부 덮어씀) | **nginx** (`$remote_addr`로 덮어씀) |
|
||||
| 대가 | 정당한 값도 함께 버려짐 | 없음 |
|
||||
|
||||
**따라서 nginx의 `$remote_addr` 사용은 선택이 아니라 필수다.**
|
||||
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 클라이언트가 넣은 값이
|
||||
사슬 앞부분에 남아 신뢰 경계가 무너진다.
|
||||
|
||||
### 잔여 위험 — 프록시 우회 경로
|
||||
|
||||
앱이 헤더를 신뢰하게 됐으므로, **Traefik을 거치지 않고 파드에 직접 도달할 수
|
||||
있으면 위조가 가능하다.** 클러스터 안에서는 Service ClusterIP로 접근할 수
|
||||
있다.
|
||||
|
||||
```bash
|
||||
# 클러스터 내부에서 (위조 가능함을 확인)
|
||||
kubectl -n header-lab run t --rm -it --image=curlimages/curl --restart=Never -- \
|
||||
curl -s http://echo:8081/api/echo -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-For: 1.2.3.4'
|
||||
```
|
||||
|
||||
**NetworkPolicy로 Traefik에서 오는 트래픽만 허용하는 것이 정석이다.**
|
||||
이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다.
|
||||
아직 적용하지 않았으며, `feature/keycloak-header-spoofing-defense`에서
|
||||
다룰 항목이다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 증거
|
||||
|
||||
`docs/evidence/two-hop-proxy-headers/`
|
||||
|
||||
| 파일 | 내용 |
|
||||
|---|---|
|
||||
| `01-environment.txt` | 수정 전 세 계층 설정 |
|
||||
| `02-measurements.txt` | 수정 전 측정 + 대조 실험 |
|
||||
| `03-browser-https-vs-app-http.png` | 브라우저와 앱의 인식 차이 |
|
||||
| `stage-a-nginx-fixed.png` | A 이후 — 여전히 `http` |
|
||||
| `stage-b-traefik-trusts.png` | B 이후 — 헤더는 살아났으나 앱 해석은 `http` |
|
||||
| `stage-c-resolved.png` | C 이후 — 전 구간 `https` |
|
||||
| `04-after-fix.txt` | 최종 측정 + 위조 테스트 + 분배 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user