# 2홉 프록시 헤더 계약 — 측정·진단·적용 `docs/reverse-proxy-headers.md`의 계약은 **nginx 한 홉**을 가정하고 쓰였다. 실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로 그 계약이 그대로 성립하는지 측정했다. **성립하지 않는다.** --- ## 1. 왜 이것부터 재는가 Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원래 요청이 무엇이었나"** 에 의존한다. | 만들어지는 값 | 의존하는 정보 | |---|---| | 토큰의 `iss` 클레임 | 외부 스킴 + 호스트 | | OAuth2 `redirect_uri` | 외부 스킴 + 호스트 + 포트 | | 세션 쿠키의 `Secure` 속성 | 외부 스킴 | | brute-force 탐지·감사 로그 | 클라이언트 IP | 그런데 **TLS는 맨 앞 nginx가 끊는다.** 그 뒤로는 평문 HTTP가 흐르므로, 뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 **오직 `X-Forwarded-*` 헤더로만** 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부 틀어진다. Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 문제인지 구분할 수 없다.** 그래서 Keycloak 없이 이 계약만 먼저 떼어내 측정했다. --- ## 2. 구조 — 누가 어디에 사는가 ### 물리적 배치 ``` ┌─ test-server (호스트 OS · Arch · 베어메탈) ──────────────────┐ │ │ │ [스위치 1] nginx ← 호스트 OS 의 프로세스 │ │ /etc/nginx/sites-available/keycloak-lab │ │ = deploy/lab/host/nginx-keycloak-lab.conf │ │ │ │ ┌─ kc-lab-1 (VM) ─────────────┐ ┌─ kc-lab-2 (VM) ────────┐ │ │ │ svclb 파드 :80 │ │ svclb 파드 :80 │ │ │ │ ↓ │ │ └────────────────┼──┼─┐ │ │ [스위치 2] Traefik 파드 ◀──┼─┼────────────────────────┼──┼─┘ │ │ 클러스터 전체에 하나뿐 │ │ │ │ │ │ ↓ │ │ │ │ │ │ [스위치 3] 앱 파드 │ │ [스위치 3] 앱 파드 │ │ │ └─────────────────────────────┘ └────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ ``` | # | 무엇 | 사는 곳 | 설정 파일 | |---|---|---|---| | 1 | nginx | **호스트 OS의 프로세스** | `deploy/lab/host/nginx-keycloak-lab.conf` | | 2 | Traefik | **클러스터 안 파드 1개** | `HelmChartConfig` (kube-system) | | 3 | 앱 | **클러스터 안 파드 N개** | 각 앱의 매니페스트 `env` | ### Traefik은 노드마다 있지 않다 k3s 기본값이 **replica 1**이다. 대신 **svclb**(klipper-lb) DaemonSet이 각 노드의 80/443 hostPort를 열어두고, 받은 트래픽을 **그 하나의 Traefik 파드로** 전달한다. 측정에서 8회 요청의 `x-forwarded-server`가 모두 같은 파드 이름이었던 것이 그 증거다. 이 사실은 **노드 상실 실험에서 변수**가 된다 — Traefik이 있는 노드를 죽이면 다른 노드의 svclb도 보낼 곳을 잃는다. ### "홉"은 노드 수가 아니라 프록시 계층 수다 ``` 홉 1 홉 2 목적지 호스트 nginx ──▶ Traefik ──▶ 앱 파드 (HTTP 를 봄) (HTTP 를 봄) (HTTP 를 봄) ``` **svclb는 홉으로 세지 않는다.** iptables 수준의 전달이라 HTTP를 아예 보지 않기 때문이다. 다만 SNAT를 하므로 **IP는 바꾼다.** --- ## 3. 측정 장치 `backend`의 `/api/echo`가 **자신에게 실제로 도달한 것**을 그대로 돌려준다. ``` GET https://app1.hyeonworks.com/api/echo → { headers, remoteAddr, localAddr, scheme, secure, serverName, serverPort, requestUrl } ``` `scheme` · `secure` · `requestUrl`은 Keycloak이 `iss`와 redirect URL을 만들 때 쓰는 것과 **같은 종류의 값**이다. `localAddr`은 파드 IP이므로 어느 노드가 응답했는지 알려준다. 배포는 `deploy/lab/k8s/echo.yaml`, 실행은 `deploy/lab/scripts/measure-proxy-headers.sh`. --- ## 4. 요청 흐름 — 홉마다 헤더가 어떻게 변하는가 ``` ┌─ 1. 브라우저 ────────────────────────────────────────────────┐ │ GET /api/echo │ │ Host: app1.hyeonworks.com │ │ 전 구간 TLS 로 암호화 │ └──────────────────────────┬───────────────────────────────────┘ │ tailnet → 100.83.212.4:443 ┌─ 2. 호스트 nginx ────────▼───────────────────────────────────┐ │ ★ TLS 종료 — 이 지점부터 평문 HTTP │ │ │ │ 원래 요청 정보를 헤더로 바꿔 붙인다: │ │ Host app1.hyeonworks.com │ │ X-Forwarded-Host app1.hyeonworks.com │ │ X-Forwarded-Proto https ← 원래 스킴 │ │ X-Forwarded-Port 443 │ │ X-Forwarded-For <클라이언트 IP> ($remote_addr 로 덮어씀) │ │ X-Real-IP <클라이언트 IP> │ └──────────────────────────┬───────────────────────────────────┘ │ upstream 라운드로빈 │ 192.168.122.11:80 또는 .12:80 ┌─ 3. svclb (klipper-lb) ──▼───────────────────────────────────┐ │ 노드의 hostPort 80 에서 받아 iptables 로 전달 │ │ externalTrafficPolicy: Cluster → SNAT 발생 │ │ │ │ ★ 출발지 IP 가 노드의 flannel 게이트웨이로 바뀐다 │ │ → 클라이언트 IP 1차 소실 │ └──────────────────────────┬───────────────────────────────────┘ │ 10.42.0.8 (Traefik 파드) ┌─ 4. Traefik ─────────────▼───────────────────────────────────┐ │ Ingress 규칙 매칭: host app1.hyeonworks.com, path /api │ │ │ │ ★ forwardedHeaders.trustedIPs 미설정 │ │ → 들어온 X-Forwarded-* 를 신뢰하지 않고 │ │ 자기가 받은 연결을 기준으로 다시 쓴다 │ │ │ │ X-Forwarded-Proto https → http 자기가 받은 게 평문이므로 │ │ X-Forwarded-Port 443 → 80 │ │ X-Forwarded-For 실IP → 10.42.1.0 ← 2차 소실 │ │ X-Real-IP 실IP → 10.42.1.0 │ │ X-Forwarded-Host 유지 │ │ X-Forwarded-Server traefik-... 자기 이름 추가 │ └──────────────────────────┬───────────────────────────────────┘ │ Service → 파드 ┌─ 5. 애플리케이션 ────────▼───────────────────────────────────┐ │ Spring: forward-headers-strategy = none │ │ → forwarded 헤더를 해석하지 않고 TCP 연결 그대로 보고 │ │ │ │ scheme http │ │ secure false │ │ requestUrl http://app1.hyeonworks.com/api/echo │ └──────────────────────────────────────────────────────────────┘ ``` --- ## 5. 측정 결과 | 헤더 | nginx 가 보낸 값 | 앱에 도달한 값 | | |---|---|---|---| | `X-Forwarded-Proto` | `https` | **`http`** | 덮어써짐 | | `X-Forwarded-Port` | `443` | **`80`** | 덮어써짐 | | `X-Forwarded-For` | 클라이언트 IP | **`10.42.1.0`** | 소실 | | `X-Real-IP` | 클라이언트 IP | **`10.42.1.0`** | 소실 | | `X-Forwarded-Host` | `app1.hyeonworks.com` | `app1.hyeonworks.com` | 유지 | 앱이 최종적으로 보는 값: ``` scheme http secure false requestUrl http://app1.hyeonworks.com/api/echo ``` **위조 테스트** — 클라이언트가 `X-Forwarded-For: 1.2.3.4`, `X-Forwarded-Host: evil.example.com`을 직접 붙여 요청했으나 **앱에 흔적이 도달하지 않았다.** 다만 이는 nginx가 막아서가 아니라 **Traefik이 전부 덮어썼기 때문**이다. 신뢰 경계는 결과적으로 작동하지만, 그 대가로 정당한 값까지 함께 버려진다. **파드 분배** — 8회 요청이 두 파드(`10.42.0.9`, `10.42.1.3`)에 정확히 번갈아 도달했다. nginx upstream 라운드로빈과 Service 분배가 모두 작동한다. --- ## 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. 원인 — 독립된 스위치 세 개 이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다. **하나만 꺼져 있어도 정보가 끊긴다.** | # | 위치 | 스위치 | 현재 | 하는 일 | 꺼져 있으면 | |---|---|---|---|---|---| | 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더를 **만든다** | 헤더가 존재하지 않음 | | 2 | Traefik | `forwardedHeaders.trustedIPs` | **꺼짐** | 받은 헤더를 **전달할지 버릴지** | **버리고 자기 값으로 재작성** | | 3 | 앱 | `forward-headers-strategy` 등 | **꺼짐** | 도착한 헤더를 **읽어서 반영할지** | 헤더가 와 있어도 무시 | 지금은 2번에서 끊긴다. 2번을 고쳐도 3번을 켜지 않으면 앱은 여전히 원래 스킴을 모른다. ### 3번을 구체적으로 헤더는 **이미 앱에 도착해 있다.** `/api/echo` 출력에 `x-forwarded-proto: http`가 찍혔다. 도착은 했다. 그런데 앱이 `request.getScheme()`을 부르면 `http`가 나온다. **헤더를 읽지 않고 TCP 연결 자체를 보기 때문**이다. `forward-headers-strategy=native`를 켜면 Tomcat이 헤더를 읽어서 **요청 객체의 scheme·host·port·remoteAddr를 갈아끼운다.** 즉 3번은 **"도착한 헤더를 진짜로 믿고 내 요청 정보를 바꿔칠까"** 의 스위치다. ### 3번은 앱마다 하나씩이다 1번과 2번은 한 번 켜면 끝이지만 **3번은 새 앱을 올릴 때마다 따로 켜야 한다.** ``` [1] nginx 1개 고정 [2] Traefik 1개 고정 [3] 앱 N개 Keycloak · BFF · oauth2-proxy · backend API … ``` 그리고 **빠뜨려도 오류가 나지 않고 조용히 틀린 값으로 동작**한다. 이것이 이 계약을 문서로 고정해두어야 하는 이유다. ### 기본값이 "믿지 않음"인 것은 의도된 설계다 `X-Forwarded-*`는 **누구나 위조할 수 있는 평범한 HTTP 헤더**다. "누구로부터 온 것을 믿을지"를 명시하지 않으면 **신뢰하지 않는 쪽이 안전**하다. 프레임워크들이 하나같이 기본값을 꺼두는 이유다. ### 네 번째 요인 — `externalTrafficPolicy: Cluster` svclb가 트래픽을 SNAT하면서 클라이언트 IP가 Traefik에 도달하기 전에 이미 사라진다. 2번을 고치면 `X-Forwarded-For`에 담긴 nginx의 값은 살아나지만, **TCP 출발지 주소 자체는 복원되지 않는다.** --- ## 7. 앱 스위치를 켜는 방법 ### Spring Boot ```yaml server: forward-headers-strategy: native # none | native | framework ``` 환경변수는 `SERVER_FORWARD_HEADERS_STRATEGY=native`. | 값 | 구현 | 신뢰 IP 제한 | |---|---|---| | `none` (기본) | 무시 | — | | **`native`** | 서블릿 컨테이너 기능 (Tomcat `RemoteIpValve`) | **있음** | | `framework` | Spring `ForwardedHeaderFilter` | **없음 — 무조건 신뢰** | **`native`를 권하는 이유가 마지막 열이다.** Tomcat의 `RemoteIpValve`는 `internalProxies` 기본 정규식(`10.x`, `192.168.x`, `172.16~31.x`, `127.x`)에 해당하는 **출발지에서 온 요청만** 헤더를 반영한다. 파드 IP가 `10.42.x`라 기본값에 들어간다. `framework`는 그런 필터가 없어 **누가 보내든 믿는다.** **켜면 실제로 무슨 일이 일어나나** — 밸브가 요청 객체를 갈아끼운다. ``` X-Forwarded-For → request.getRemoteAddr() X-Forwarded-Proto → request.getScheme(), isSecure() X-Forwarded-Port → request.getServerPort() X-Forwarded-Host → request.getServerName() → 그 결과 getRequestURL() 이 외부 URL 로 재구성됨 ``` **애플리케이션 코드는 한 줄도 고치지 않는다.** 프레임워크가 요청 정보를 바꿔서 넘겨준다. ### Keycloak ``` KC_PROXY_HEADERS=xforwarded # xforwarded | forwarded ``` | 값 | 읽는 헤더 | |---|---| | `xforwarded` | `X-Forwarded-For`, `-Proto`, `-Host`, `-Port` (관례) | | `forwarded` | RFC 7239의 `Forwarded:` 단일 헤더 | | 미설정 | 무시 | **Keycloak은 방어가 두 겹이다.** | 설정 | 담당 | |---|---| | `KC_HOSTNAME=https://auth...` | 스킴·호스트를 **고정** — 헤더와 무관 | | `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 헤더에서 | 그래서 `iss`는 `KC_HOSTNAME`만으로도 살아난다. 하지만 brute-force 탐지와 감사 로그의 IP는 `KC_PROXY_HEADERS`가 있어야 맞는다. > 예전 `KC_PROXY=edge` 옵션은 Keycloak 24에서 deprecated 되고 > `KC_PROXY_HEADERS`로 대체됐다. 오래된 예제 참고 시 주의. ### oauth2-proxy ``` --reverse-proxy=true # 또는 OAUTH2_PROXY_REVERSE_PROXY=true ``` 신뢰 IP 제한 기능이 없어 **무조건 신뢰**한다. ### 뒤쪽에 nginx가 있는 경우 ```nginx set_real_ip_from 10.42.0.0/16; real_ip_header X-Forwarded-For; real_ip_recursive on; ``` 신뢰 IP 지정이 **필수**다. `set_real_ip_from` 없이는 동작하지 않는다. ### 공통 원리 어느 프레임워크든 결국 **두 가지를 정하는 일**이다. 1. **어떤 헤더 형식을 읽을지** — `X-Forwarded-*` vs RFC 7239 `Forwarded` 2. **누구로부터 온 것을 믿을지** — 신뢰 프록시 IP 목록 두 번째가 있는 구현이 안전하다. Spring `native`와 nginx `real_ip`는 있고, Spring `framework`와 oauth2-proxy는 없다. ### 켤 때 반드시 같이 봐야 하는 것 **앱에 프록시를 거치지 않고 직접 도달할 경로가 있으면 안 된다.** 헤더 신뢰를 켠 상태에서 공격자가 앱에 직접 요청하며 `X-Forwarded-Proto: https`를 붙이면, 앱은 그걸 믿고 **`Secure` 쿠키를 발급하거나 IP 기반 제한을 우회**당한다. 쿠버네티스에서는 Service ClusterIP로 파드에 직접 접근할 수 있으므로, **NetworkPolicy로 Traefik에서 오는 트래픽만 허용**하는 것이 정석이다. 이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다. **"헤더를 믿는다"는 결정과 "그 앞에 반드시 프록시가 있다"는 보장은 한 쌍이다.** 한쪽만 하면 구멍이 된다. --- ## 8. 영향 — 패턴별 | 패턴 | 의존도 | 무엇이 깨지나 | |---|---|---| | AP1 SPA direct | 낮음 | 브라우저가 Keycloak에 직접 감. Keycloak 자체만 필요 | | AP2 token mediator | 중간 | mediator의 redirect URI | | **AP3 BFF** | **높음** | **로그인 자체가 실패**, 세션 쿠키 `Secure` 유실 | | **AP4 edge auth** | **결정적** | **인증 결과가 헤더로 전달됨** | ### AP3(BFF)에서 왜 중요한가 **흔한 오해 — "BFF가 넘기는 헤더는 세션 쿠키뿐인데?"** 쿠키를 **전달하는 것**은 문제가 아니다. 프록시는 `Cookie:` 헤더를 그대로 통과시킨다. 문제는 **쿠키와 URL을 만들 때**이고, 그 재료가 `X-Forwarded-*`다. **(1) OAuth2 `redirect_uri` 생성 — 가장 먼저 터진다** BFF는 Spring Security의 `oauth2Login`을 쓴다. Keycloak으로 사용자를 보낼 때 `redirect_uri` 파라미터를 **현재 요청 URL로부터** 만든다. ``` redirect_uri = {scheme}://{serverName}:{serverPort}/login/oauth2/code/keycloak ↑ request.getScheme() 에서 온다 ``` `scheme=http`면 이렇게 나간다. ``` redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak ``` 그러면 두 가지 중 하나가 벌어진다. - Keycloak 클라이언트에 `https://...`만 등록돼 있으면 → **`invalid_redirect_uri` 오류로 로그인 거부** - 실수로 `http://...`도 등록해뒀다면 → 브라우저가 https 페이지에서 http로 리다이렉트 → **혼합 콘텐츠 차단 또는 세션 쿠키 유실** **세션 쿠키 문제보다 먼저, 로그인 자체가 안 된다.** **(2) 세션 쿠키의 `Secure` 속성** 서블릿 컨테이너는 `request.isSecure()`를 보고 `Set-Cookie`에 `Secure`를 붙일지 정한다. `isSecure()`가 `false`면 **`Secure` 없는 세션 쿠키**가 나간다. | 결과 | 내용 | |---|---| | 평문 전송 위험 | 그 쿠키는 http 요청에도 실려 나간다. 중간자가 세션을 탈취할 수 있다 | | **`SameSite=None` 사용 불가** | 브라우저는 `Secure` 없는 `SameSite=None` 쿠키를 **거부**한다 | 두 번째가 AP3의 학습 주제와 정면으로 부딪힌다. AP3는 `oauth2Login` 세션과 **CSRF·SameSite 방어**가 핵심인데, `Secure`가 없으면 `SameSite` 설계 선택지가 통째로 사라진다. **(3) 로그아웃 `post_logout_redirect_uri`** 같은 원리로 http가 박히고, Keycloak에 등록된 값과 불일치해 거부된다. **정리하면** — BFF에서 헤더 계약이 중요한 이유는 쿠키를 *전달*하기 때문이 아니라, **쿠키와 OAuth2 URL을 *생성*하는 재료이기 때문**이다. ### AP4(edge auth)에서 왜 결정적인가 AP4는 **헤더 신뢰가 패턴의 존재 이유 자체**다. oauth2-proxy는 인증을 끝내고 **결과를 헤더로 downstream에 넘긴다.** ``` X-Auth-Request-User X-Auth-Request-Email X-Auth-Request-Groups X-Auth-Request-Access-Token ``` downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단**한다. 토큰을 직접 검증하지 않는다. 그것이 AP4가 "기존 upstream을 수정하지 않고 경계에서 일괄 인증"할 수 있는 이유다. **그래서 여기서 헤더 신뢰가 무너지면 인증 우회가 된다.** ``` 공격자가 직접: X-Auth-Request-User: admin 프록시가 안 덮어쓰면 → downstream 은 admin 으로 인식 ``` 지금 측정한 `X-Forwarded-*` 문제와 **구조가 완전히 같다.** 헤더 이름과 의미만 다르다. | 헤더군 | 담는 정보 | 위조되면 | |---|---|---| | `X-Forwarded-*` | 원래 요청이 어땠나 | 쿠키 속성·URL이 틀어짐 | | `X-Auth-Request-*` | **누가 인증됐나** | **신원 위조 = 인증 우회** | 저장소의 `feature/keycloak-header-spoofing-defense` 브랜치 (manifest: `ap4 / locally-verified`)가 이 문제를 다룬다. **지금 확정하는 2홉 계약이 그 브랜치의 전제**다 — 1홉 가정으로 검증된 방어가 2홉에서도 유효한지 다시 확인해야 한다. --- ## 9. 해결 — 어느 파일의 무엇을 어떻게 고치는가 세 곳을 순서대로 고쳤다. **각 단계마다 측정하여 어느 스위치가 무엇을 담당하는지 데이터로 확인했다.** ### 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 kind: HelmChartConfig metadata: name: traefik namespace: kube-system spec: valuesContent: |- ports: web: forwardedHeaders: 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 ``` **`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를 없애고 실제 출발지(`192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik 파드가 없을 때 트래픽이 버려진다. ### C. 앱 — 도착한 헤더를 해석한다 | | | |---|---| | 저장소 파일 | `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. 단계별 측정 결과 각 스위치를 하나씩 켜면서 측정한 값이다. **어느 스위치가 무엇을 담당하는지가 그대로 드러난다.** | 측정 항목 | 최초 | 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 ``` | 항목 | 기대값 | |---|---| | `x-forwarded-proto` | `https` | | `x-forwarded-port` | `443` | | `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-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 ``` **주입한 값이 하나도 반영되지 않아야 한다.** 실측 결과 `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 -i --restart=Never --image=curlimages/curl -- \ curl -s http://echo:8081/api/echo \ -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4' ``` ``` serverName evil.example.com ← 위조 성공 remoteAddr 1.2.3.4 ← 위조 성공 requestUrl https://evil.example.com/api/echo ``` **두 신뢰 설정이 모두 "대역"을 믿기 때문**이다. | 계층 | 신뢰 범위 | 지정 방식 | |---|---|---| | Traefik `trustedIPs` | 파드 대역 전체 | IP 대역 | | 앱 Tomcat `internalProxies` | 사설 대역 전체 (기본 정규식) | IP 정규식 | IP로는 Traefik을 특정할 수 없다. **파드 IP가 재시작마다 바뀌기 때문**이다 (측정 중 실제로 `10.42.0.8` → `10.42.1.12`로, 노드까지 옮겨갔다). **해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.** | | | |---|---| | 저장소 파일 | `deploy/lab/k8s/echo-network-policy.yaml` | ```yaml ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system podSelector: matchLabels: app.kubernetes.io/name: traefik # ← IP 가 아니라 라벨 ports: - protocol: TCP port: 8081 ``` `namespaceSelector`와 `podSelector`를 **같은 리스트 항목**에 두면 AND로 결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로 주의한다. **kubelet probe를 위한 규칙이 별도로 필요하다.** readiness/liveness는 파드가 아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고 **파드가 재시작 루프에 빠진다.** ```yaml - from: - ipBlock: { cidr: 10.42.0.1/32 } # kc-lab-1 의 cni0 - ipBlock: { cidr: 10.42.1.1/32 } # kc-lab-2 의 cni0 ``` probe의 출발지는 **노드의 flannel 브리지(cni0)** 이고, 각 노드 `/24`의 첫 주소다. `/32`로 정확히 지정해야 한다 — `10.42.0.0/16`으로 넓히면 임의의 파드가 다시 들어와 정책이 무의미해진다. **적용 후 확인** ``` 정상 경로 scheme=https, remoteAddr=100.123.124.30 계속 동작 우회 시도 HTTP 000 / curl exit 7 연결 자체가 거부됨 파드 상태 1/1 Running, restarts=0 probe 정상 ``` **"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.** 앞의 것만 하면 이 구멍이 남는다. ## 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` | 최종 측정 + 위조 테스트 + 분배 | --- ## 11. 참고 - 1홉 계약 원본: `docs/reverse-proxy-headers.md` - 개념 상세: `docs/session-lab-concepts.md` - 패턴 비교: `docs/four-pattern-tradeoff-matrix.md` - 측정 배포: `deploy/lab/k8s/echo.yaml` - 측정 실행: `deploy/lab/scripts/measure-proxy-headers.sh`