diff --git a/docs/two-hop-proxy-header-contract.md b/docs/two-hop-proxy-header-contract.md index c4248cf..e8977a4 100644 --- a/docs/two-hop-proxy-header-contract.md +++ b/docs/two-hop-proxy-header-contract.md @@ -1,10 +1,12 @@ -# 2홉 프록시 헤더 계약 — 측정과 진단 +# 2홉 프록시 헤더 계약 — 측정·진단·적용 `docs/reverse-proxy-headers.md`의 계약은 **nginx 한 홉**을 가정하고 쓰였다. -실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로, 그 계약이 그대로 +실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로 그 계약이 그대로 성립하는지 측정했다. **성립하지 않는다.** -## 왜 이것부터 재는가 +--- + +## 1. 왜 이것부터 재는가 Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원래 요청이 무엇이었나"** 에 의존한다. @@ -12,11 +14,11 @@ Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원 | 만들어지는 값 | 의존하는 정보 | |---|---| | 토큰의 `iss` 클레임 | 외부 스킴 + 호스트 | -| 로그인 redirect URL | 외부 스킴 + 호스트 | +| OAuth2 `redirect_uri` | 외부 스킴 + 호스트 + 포트 | | 세션 쿠키의 `Secure` 속성 | 외부 스킴 | | brute-force 탐지·감사 로그 | 클라이언트 IP | -그런데 TLS는 맨 앞 nginx가 끊는다. 그 뒤로는 **평문 HTTP**가 흐르므로, +그런데 **TLS는 맨 앞 nginx가 끊는다.** 그 뒤로는 평문 HTTP가 흐르므로, 뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 **오직 `X-Forwarded-*` 헤더로만** 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부 틀어진다. @@ -24,7 +26,60 @@ Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원 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`가 **자신에게 실제로 도달한 것**을 그대로 돌려준다. @@ -40,7 +95,9 @@ GET https://app1.hyeonworks.com/api/echo 배포는 `deploy/lab/k8s/echo.yaml`, 실행은 `deploy/lab/scripts/measure-proxy-headers.sh`. -## 요청 흐름 — 홉마다 헤더가 어떻게 변하는가 +--- + +## 4. 요청 흐름 — 홉마다 헤더가 어떻게 변하는가 ``` ┌─ 1. 브라우저 ────────────────────────────────────────────────┐ @@ -95,7 +152,9 @@ GET https://app1.hyeonworks.com/api/echo └──────────────────────────────────────────────────────────────┘ ``` -## 측정 결과 +--- + +## 5. 측정 결과 | 헤더 | nginx 가 보낸 값 | 앱에 도달한 값 | | |---|---|---|---| @@ -122,52 +181,271 @@ requestUrl http://app1.hyeonworks.com/api/echo **파드 분배** — 8회 요청이 두 파드(`10.42.0.9`, `10.42.1.3`)에 정확히 번갈아 도달했다. nginx upstream 라운드로빈과 Service 분배가 모두 작동한다. -**Traefik은 1개다** (`traefik-...`, kc-lab-1). k3s 기본이 replica 1이므로 -두 노드의 svclb가 같은 파드로 보낸다. 노드 상실 실험에서 변수가 된다. +--- -## 원인 — 독립된 스위치 세 개 +## 6. 원인 — 독립된 스위치 세 개 이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다. **하나만 꺼져 있어도 정보가 끊긴다.** -| # | 위치 | 스위치 | 현재 | 꺼져 있으면 | -|---|---|---|---|---| -| 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더가 아예 생성되지 않음 | -| 2 | **Traefik** | `forwardedHeaders.trustedIPs` | **꺼짐** | **들어온 헤더를 버리고 재작성** | -| 3 | 앱 | `forward-headers-strategy` / `KC_PROXY_HEADERS` | **꺼짐** | 헤더가 도착해도 무시 | +| # | 위치 | 스위치 | 현재 | 하는 일 | 꺼져 있으면 | +|---|---|---|---|---|---| +| 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더를 **만든다** | 헤더가 존재하지 않음 | +| 2 | Traefik | `forwardedHeaders.trustedIPs` | **꺼짐** | 받은 헤더를 **전달할지 버릴지** | **버리고 자기 값으로 재작성** | +| 3 | 앱 | `forward-headers-strategy` 등 | **꺼짐** | 도착한 헤더를 **읽어서 반영할지** | 헤더가 와 있어도 무시 | -지금은 2번에서 끊긴다. 3번도 꺼져 있으므로, 2번을 고쳐도 3번을 켜지 않으면 -앱은 여전히 원래 스킴을 모른다. +지금은 2번에서 끊긴다. 2번을 고쳐도 3번을 켜지 않으면 앱은 여전히 원래 +스킴을 모른다. -**프레임워크가 기본적으로 forwarded 헤더를 믿지 않는 것은 의도된 설계다.** -이 헤더들은 누구나 위조할 수 있는 평범한 HTTP 헤더이므로, **"누구로부터 온 -것을 믿을지"를 명시하지 않으면 신뢰하지 않는 쪽이 안전**하다. +### 3번을 구체적으로 -여기에 네 번째 요인이 겹친다. +헤더는 **이미 앱에 도착해 있다.** `/api/echo` 출력에 `x-forwarded-proto: http`가 +찍혔다. 도착은 했다. -**`externalTrafficPolicy: Cluster`** — svclb가 트래픽을 SNAT하면서 -클라이언트 IP가 Traefik에 도달하기 전에 이미 사라진다. 2번을 고쳐도 -`X-Forwarded-For`에 담긴 nginx의 값은 살아나지만, **TCP 출발지 주소 자체는 -복원되지 않는다.** +그런데 앱이 `request.getScheme()`을 부르면 `http`가 나온다. +**헤더를 읽지 않고 TCP 연결 자체를 보기 때문**이다. +`forward-headers-strategy=native`를 켜면 Tomcat이 헤더를 읽어서 +**요청 객체의 scheme·host·port·remoteAddr를 갈아끼운다.** -## 영향 +즉 3번은 **"도착한 헤더를 진짜로 믿고 내 요청 정보를 바꿔칠까"** 의 스위치다. -**Keycloak의 `iss`는 이것만으로 깨지지 않는다.** -`KC_HOSTNAME=https://auth.hyeonworks.com`처럼 **전체 URL을 주면 스킴이 -고정**되어 `X-Forwarded-Proto`와 무관하게 https로 발급된다. +### 3번은 앱마다 하나씩이다 -실제로 남는 피해는 셋이다. +1번과 2번은 한 번 켜면 끝이지만 **3번은 새 앱을 올릴 때마다 따로 켜야 한다.** -1. **클라이언트 IP 완전 소실** — brute-force 탐지, 감사 로그, IP 기반 접근 - 제한이 모두 무의미해진다. 모든 요청이 `10.42.x.0`에서 온 것으로 보인다 -2. **일반 애플리케이션은 그대로 깨진다** — BFF나 oauth2-proxy는 - `X-Forwarded-Proto`를 보고 쿠키의 `Secure` 부여 여부와 리다이렉트 URL을 - 정한다. `http`로 읽으면 **세션 쿠키에 `Secure`가 붙지 않는다** -3. **1홉 계약이 2홉에서 성립하지 않음이 확정** — `docs/reverse-proxy-headers.md`는 - "nginx가 헤더를 덮어쓰고 Keycloak이 `KC_PROXY_HEADERS`로 받는다"까지만 - 기술한다. 그 사이에 Traefik이 있다는 사실이 빠져 있다 +``` +[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. 해결 ### 2번 스위치 — Traefik이 nginx의 헤더를 신뢰하게 한다 @@ -194,11 +472,11 @@ spec: **트레이드오프** — 파드 대역 전체를 신뢰하면 **클러스터 안의 어떤 파드든 헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 더 좁혀야 -한다. 좁히려면 `externalTrafficPolicy: Local`로 바꿔 SNAT를 없애고 실제 +한다. 좁히려면 `externalTrafficPolicy: Local`로 SNAT를 없애고 실제 출발지(호스트 nginx의 `192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik 파드가 없을 때 트래픽이 버려진다. -### 3번 스위치 — 앱이 헤더를 해석하게 한다 +### 3번 스위치 — 앱마다 켠다 | 대상 | 설정 | |---|---| @@ -206,10 +484,12 @@ spec: | Keycloak | `KC_PROXY_HEADERS=xforwarded` | | oauth2-proxy | `--reverse-proxy=true` | -**두 스위치는 성격이 같다.** 프레임워크마다 이름만 다를 뿐, "앞단 프록시가 +**세 스위치는 성격이 같다.** 프레임워크마다 이름만 다를 뿐, "앞단 프록시가 붙여준 헤더를 신뢰하겠다"는 동일한 옵트인이다. -## 검증 +--- + +## 10. 검증 수정 후 아래가 모두 성립해야 계약이 복원된 것이다. @@ -234,11 +514,14 @@ curl -s https://app1.hyeonworks.com/api/echo -H 'X-Forwarded-Host: evil.example. `evil.example.com`이 도달하면 신뢰 경계가 무너진 것이다. Traefik을 신뢰하도록 바꾼 뒤에도 **nginx가 `$remote_addr`로 덮어쓰는 한** 클라이언트 위조는 nginx -층에서 차단된다. 이 두 가지가 동시에 성립하는 것이 목표 상태다. +층에서 차단된다. **이 두 가지가 동시에 성립하는 것이 목표 상태다.** -## 참고 +--- + +## 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`