Files
keycloak-pattern/docs/two-hop-proxy-header-contract.md
T

12 KiB

2홉 프록시 헤더 계약 — 측정과 진단

docs/reverse-proxy-headers.md의 계약은 nginx 한 홉을 가정하고 쓰였다. 실험대와 운영은 모두 nginx → Traefik 두 홉이므로, 그 계약이 그대로 성립하는지 측정했다. 성립하지 않는다.

왜 이것부터 재는가

Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 "원래 요청이 무엇이었나" 에 의존한다.

만들어지는 값 의존하는 정보
토큰의 iss 클레임 외부 스킴 + 호스트
로그인 redirect URL 외부 스킴 + 호스트
세션 쿠키의 Secure 속성 외부 스킴
brute-force 탐지·감사 로그 클라이언트 IP

그런데 TLS는 맨 앞 nginx가 끊는다. 그 뒤로는 평문 HTTP가 흐르므로, 뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 오직 X-Forwarded-* 헤더로만 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부 틀어진다.

Keycloak을 올린 뒤에 로그인이 깨지면 세션 문제인지 프록시 문제인지 구분할 수 없다. 그래서 Keycloak 없이 이 계약만 먼저 떼어내 측정했다.

측정 장치

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.

요청 흐름 — 홉마다 헤더가 어떻게 변하는가

┌─ 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           │
└──────────────────────────────────────────────────────────────┘

측정 결과

헤더 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 분배가 모두 작동한다.

Traefik은 1개다 (traefik-..., kc-lab-1). k3s 기본이 replica 1이므로 두 노드의 svclb가 같은 파드로 보낸다. 노드 상실 실험에서 변수가 된다.

원인 — 독립된 스위치 세 개

이 사슬에는 각각 따로 켜야 하는 스위치가 세 개 있다. 하나만 꺼져 있어도 정보가 끊긴다.

# 위치 스위치 현재 꺼져 있으면
1 nginx proxy_set_header X-Forwarded-* 켜짐 헤더가 아예 생성되지 않음
2 Traefik forwardedHeaders.trustedIPs 꺼짐 들어온 헤더를 버리고 재작성
3 forward-headers-strategy / KC_PROXY_HEADERS 꺼짐 헤더가 도착해도 무시

지금은 2번에서 끊긴다. 3번도 꺼져 있으므로, 2번을 고쳐도 3번을 켜지 않으면 앱은 여전히 원래 스킴을 모른다.

프레임워크가 기본적으로 forwarded 헤더를 믿지 않는 것은 의도된 설계다. 이 헤더들은 누구나 위조할 수 있는 평범한 HTTP 헤더이므로, "누구로부터 온 것을 믿을지"를 명시하지 않으면 신뢰하지 않는 쪽이 안전하다.

여기에 네 번째 요인이 겹친다.

externalTrafficPolicy: Cluster — svclb가 트래픽을 SNAT하면서 클라이언트 IP가 Traefik에 도달하기 전에 이미 사라진다. 2번을 고쳐도 X-Forwarded-For에 담긴 nginx의 값은 살아나지만, TCP 출발지 주소 자체는 복원되지 않는다.

영향

Keycloak의 iss는 이것만으로 깨지지 않는다. KC_HOSTNAME=https://auth.hyeonworks.com처럼 전체 URL을 주면 스킴이 고정되어 X-Forwarded-Proto와 무관하게 https로 발급된다.

실제로 남는 피해는 셋이다.

  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이 있다는 사실이 빠져 있다

해결

2번 스위치 — Traefik이 nginx의 헤더를 신뢰하게 한다

k3s의 Traefik은 HelmChart로 배포되므로 HelmChartConfig로 값을 덮어쓴다.

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    # 노드·호스트 대역

trustedIPs에 해당하는 출발지에서 온 요청은 기존 X-Forwarded-*를 신뢰하고 통과시킨다.

트레이드오프 — 파드 대역 전체를 신뢰하면 클러스터 안의 어떤 파드든 헤더를 위조할 수 있다. 실험대에서는 받아들일 만하지만 운영에서는 더 좁혀야 한다. 좁히려면 externalTrafficPolicy: Local로 바꿔 SNAT를 없애고 실제 출발지(호스트 nginx의 192.168.122.1)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik 파드가 없을 때 트래픽이 버려진다.

3번 스위치 — 앱이 헤더를 해석하게 한다

대상 설정
Spring Boot server.forward-headers-strategy=native
Keycloak KC_PROXY_HEADERS=xforwarded
oauth2-proxy --reverse-proxy=true

두 스위치는 성격이 같다. 프레임워크마다 이름만 다를 뿐, "앞단 프록시가 붙여준 헤더를 신뢰하겠다"는 동일한 옵트인이다.

검증

수정 후 아래가 모두 성립해야 계약이 복원된 것이다.

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
secure true
requestUrl https://app1.hyeonworks.com/api/echo

그리고 위조 테스트가 여전히 막혀야 한다.

curl -s https://app1.hyeonworks.com/api/echo -H 'X-Forwarded-Host: evil.example.com'

evil.example.com이 도달하면 신뢰 경계가 무너진 것이다. Traefik을 신뢰하도록 바꾼 뒤에도 nginx가 $remote_addr로 덮어쓰는 한 클라이언트 위조는 nginx 층에서 차단된다. 이 두 가지가 동시에 성립하는 것이 목표 상태다.

참고

  • 1홉 계약 원본: docs/reverse-proxy-headers.md
  • 개념 상세: docs/session-lab-concepts.md
  • 측정 배포: deploy/lab/k8s/echo.yaml
  • 측정 실행: deploy/lab/scripts/measure-proxy-headers.sh