The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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의 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가 있는 경우
set_real_ip_from 10.42.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
신뢰 IP 지정이 필수다. set_real_ip_from 없이는 동작하지 않는다.
공통 원리
어느 프레임워크든 결국 두 가지를 정하는 일이다.
- 어떤 헤더 형식을 읽을지 —
X-Forwarded-*vs RFC 7239Forwarded - 누구로부터 온 것을 믿을지 — 신뢰 프록시 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 / 안에서 두 줄을 고친다.
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로 받고 뒤로 평문으로 보내는 것은 의도된 설계다.
고치는 것은 뒤로 보낼 때 붙이는 라벨뿐이다.
배포:
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.8 → 10.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
namespaceSelector와 podSelector를 같은 리스트 항목에 두면 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