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

34 KiB

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에 직접 요청하여 원인을 분리했다.

# 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 서버 블록:

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

server:
  forward-headers-strategy: native   # none | native | framework

환경변수는 SERVER_FORWARD_HEADERS_STRATEGY=native.

구현 신뢰 IP 제한
none (기본) 무시
native 서블릿 컨테이너 기능 (Tomcat RemoteIpValve) 있음
framework Spring ForwardedHeaderFilter 없음 — 무조건 신뢰

native를 권하는 이유가 마지막 열이다. Tomcat의 RemoteIpValveinternalProxies 기본 정규식(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 등 나머지를 헤더에서

그래서 issKC_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가 있는 경우

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-CookieSecure를 붙일지 정한다. isSecure()falseSecure 없는 세션 쿠키가 나간다.

결과 내용
평문 전송 위험 그 쿠키는 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 / 안에서 두 줄을 고친다.

         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 sslproxy_pass http://는 건드리지 않는다. 앞에서 HTTPS로 받고 뒤로 평문으로 보내는 것은 의도된 설계다. 고치는 것은 뒤로 보낼 때 붙이는 라벨뿐이다.

배포:

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로 차트 값을 덮어써야 한다.

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의 주소가 아니라 파드 네트워크 주소다.

적용:

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을 새로 돌려 차트를 업그레이드하므로, 그 사이 구 파드가 잠시 함께 살아 있다. 이 시점에 측정하면 옛 파드가 응답해 "고쳤는데 안 바뀌었다"고 오해하게 된다. 실제로 이 함정에 한 번 걸렸다.

파드 이름과 인자로 확인한다.

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
             - name: SERVER_FORWARD_HEADERS_STRATEGY
-              value: "none"
+              value: "native"
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. 검증

정상 동작

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

위조 차단 — 이쪽이 더 중요하다

헤더 신뢰를 켠 뒤에는 위조가 여전히 막히는지 반드시 확인해야 한다.

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로 접근할 수 있으므로 실제로 위조가 성립했다.

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.810.42.1.12로, 노드까지 옮겨갔다).

해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.

저장소 파일 deploy/lab/k8s/echo-network-policy.yaml
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              app.kubernetes.io/name: traefik   # ← IP 가 아니라 라벨
      ports:
        - protocol: TCP
          port: 8081

namespaceSelectorpodSelector같은 리스트 항목에 두면 AND로 결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로 주의한다.

kubelet probe를 위한 규칙이 별도로 필요하다. readiness/liveness는 파드가 아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고 파드가 재시작 루프에 빠진다.

    - 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