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

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


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. 해결

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

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


10. 검증

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

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 층에서 차단된다. 이 두 가지가 동시에 성립하는 것이 목표 상태다.


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