diff --git a/docs/evidence/two-hop-proxy-headers/04-after-fix.txt b/docs/evidence/two-hop-proxy-headers/04-after-fix.txt new file mode 100644 index 0000000..d299add --- /dev/null +++ b/docs/evidence/two-hop-proxy-headers/04-after-fix.txt @@ -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 diff --git a/docs/evidence/two-hop-proxy-headers/README.md b/docs/evidence/two-hop-proxy-headers/README.md index 294f666..922a084 100644 --- a/docs/evidence/two-hop-proxy-headers/README.md +++ b/docs/evidence/two-hop-proxy-headers/README.md @@ -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` +값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다. diff --git a/docs/evidence/two-hop-proxy-headers/stage-a-nginx-fixed.png b/docs/evidence/two-hop-proxy-headers/stage-a-nginx-fixed.png new file mode 100644 index 0000000..1dbe55b Binary files /dev/null and b/docs/evidence/two-hop-proxy-headers/stage-a-nginx-fixed.png differ diff --git a/docs/evidence/two-hop-proxy-headers/stage-b-traefik-trusts.png b/docs/evidence/two-hop-proxy-headers/stage-b-traefik-trusts.png new file mode 100644 index 0000000..80aa947 Binary files /dev/null and b/docs/evidence/two-hop-proxy-headers/stage-b-traefik-trusts.png differ diff --git a/docs/evidence/two-hop-proxy-headers/stage-c-resolved.png b/docs/evidence/two-hop-proxy-headers/stage-c-resolved.png new file mode 100644 index 0000000..90d27c8 Binary files /dev/null and b/docs/evidence/two-hop-proxy-headers/stage-c-resolved.png differ diff --git a/docs/two-hop-proxy-header-contract.md b/docs/two-hop-proxy-header-contract.md index e3c1f7f..662ed20 100644 --- a/docs/two-hop-proxy-header-contract.md +++ b/docs/two-hop-proxy-header-contract.md @@ -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` | 최종 측정 + 위조 테스트 + 분배 | ---