Files
keycloak-pattern/docs/guides/experiments/b4-edge-authorization-scope.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

42 KiB
Raw Blame History

B-4 재현 가이드 — 신원 헤더를 위조해 보내고 그대로 도착하는 것을 본다

해설 문서: docs/experiment-b4-edge-authorization-scope.md · 증거 원문: docs/evidence/b4-edge-authorization/ · ③ 반영 시점: 후속 문서 §3 · 03-b4-role-propagation.txt

이 가이드가 끝나면

당신 터미널에서 이것들을 직접 본다.

보게 되는 것 어디서
동명 헤더 두 개가 둘 다 도착하는 것 curl -H … -H … · echo 응답
값 안의 쉼표를 구분자와 구별할 수 없는 같은 자리
8KB 에서 400, 16KB 에서 응답 자체가 없는 크기 훑기
인증 없이 보낸 위조 신원이 그대로 도착하는 것 같은 자리
JWT 를 요구하는 경로는 401 인 것 대조군
IdP 에서 값을 바꿔도 12회 요청 동안 옛 값인 것 브라우저 콘솔 (쿠키가 HttpOnly 라 curl 로 못 한다)

전제

  • 03-nginx · 04-tls · 05-keycloak 가 끝나 있다.
  • app1.hyeonworks.com경로에 따라 둘로 갈린다. / 는 BFF, /apiheader-lab 네임스페이스의 echo 앱이다. 이 실험은 /api/echo 만 쓴다 — 도착한 헤더를 그대로 되돌려주는 앱이다.
  • 4절부터는 app2.hyeonworks.com 을 Grafana 에서 잠시 빌린다. 인증서가 auth · app1 · app2 만 덮으므로 네 번째 이름을 만들 수 없다. 끝나면 되돌린다5-4.
  • 4절은 브라우저가 필요하다. oauth2-proxy 세션 쿠키가 HttpOnlycurl 로 로그인 상태를 재현할 수 없다. 이유는 4-1.
  • kubectlkc-lab-1 에서 sudo 로 친다. 5-1 의 nginx 설정만 랩 호스트(test-server) 에서 한다 — 다른 기계다.
  • 앞의 curl 은 어디서 쳐도 된다. 밖에서 치는 편이 「공격자 관점」에 가깝다.

주의 — 앞부분은 안전하고 뒷부분은 상태를 바꾼다

무엇을 하나 되돌릴 것
1~3 요청만 보낸다. 클러스터 상태가 안 바뀐다 없음
4 Grafana 에서 app2 를 빌리고 IdP 의 사용자 속성을 바꾼다 Ingress · email 값 · 세션
5 nginx 설정을 바꾼다 (호스트) 설정 파일

전 구간 약 30분. 1~3 만 하고 멈춰도 이 실험의 결론 대부분이 나온다.

표시 규약

표시
실측 2026-09-04 14:23 KST(①②④) 및 07:5107:53 UTC(③) 실행 기록의 출력 원문
형태 값이 매번 달라지는 출력. 모양만 보이고 IP·시각은 당신 것과 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다

증거 파일의 ['admin', 'editor'] 같은 표기는 스크립트가 정리한 것이다. curl 로 직접 보면 같은 값이 JSON 배열 ["admin","editor"] 로 온다 — echo 앱이 헤더 이름마다 값의 목록을 돌려주기 때문이다. 이 가이드는 둘을 구별해 표시한다.


0. 왜 이 실험을 하는가

Edge(oauth2-proxy·nginx)가 인증을 끝내고 신원을 헤더로 뒤에 넘기는 구조가 있다. X-Auth-Request-User, X-Auth-Request-Roles 같은 것들이다. 뒤쪽 애플리케이션은 그 헤더를 읽어 사용자를 안다.

그러면 그 헤더는 무엇이 보증하는가.

예측
Q4 가 「확인한 사실」로 적어둔 것 "Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"
B-4 가 재는 것 진짜 그런가. 그리고 upstream 은 무엇을 검증하는가

이 실험은 네 가지를 따로 잰다.

   ① 여러 값을 어떻게 넣는가      쉼표? 헤더를 여러 개?     → 구별할 수 있나
   ② 커지면 어떻게 되는가         잘리나? 거부되나?
   ③ IdP 에서 바꾸면 언제 반영되나
   ④ 위조하면 통하는가            ★ 여기가 권한의 문제다

개념 — 왜 ④ 가 「인증 우회」가 아니라 「권한 상승」인가.

헤더가 누구인지만 말하면 위조는 인증 우회다. 그런데 헤더가 무엇을 할 수 있는지(role)까지 말하면, 위조는 권한 상승이 된다. 로그인한 일반 사용자가 자기 요청에 X-Auth-Request-Roles: admin 을 한 줄 더 붙이는 것으로 끝난다.

그래서 이 구조는 세 곳이 동시에 성립해야만 안전하다.

   ① 외부 → upstream 직접 경로 차단   (NetworkPolicy)
   ② edge 에서 동명 헤더 덮어쓰기      (proxy_set_header)
   ③ upstream 에서 내부 credential 검증 (공통 경계)

하나라도 빠지면 나머지 둘이 무의미하다. 이 실험은 ② 가 빠져 있다는 것을 재고, 그 결과로 ④ 가 성립한다는 것을 재고, ③ 이 한 곳에만 있다는 것을 확인한다.


1. 기준선 — 위조하기 전에

시험군만 재는 측정은 측정이 아니다. 「위조 헤더가 도착했다」고 말하려면 아무것도 안 붙였을 때 무엇이 도착하는지를 먼저 봐야 한다.

경로 확인 → echo 응답 통째로 보기 → 대조군(아무것도 안 붙임) → nginx 가 지금 뭘 설정하나

1-1. 어느 이름이 어디로 가는가

확인

sudo kubectl get ingress -A

형태

NAMESPACE      NAME           CLASS     HOSTS                   ADDRESS   PORTS  AGE
header-lab     echo           traefik   app1.hyeonworks.com               80     5d
keycloak-lab   bff            traefik   app1.hyeonworks.com               80     3d
keycloak-lab   keycloak       traefik   auth.hyeonworks.com               80     6d
observability  grafana        traefik   app2.hyeonworks.com               80     6d

어디를 봐야 하는가app1두 줄이다. 같은 호스트에 Ingress 가 둘이고, 경로로 갈린다.

확인 — 어느 경로가 어디로 가는지 눈으로 본다

sudo kubectl -n header-lab describe ingress echo | grep -A5 Rules

형태

Rules:
  Host                 Path  Backends
  ----                 ----  --------
  app1.hyeonworks.com  
                       /api   echo:8081 (10.42.0.61:8081,10.42.1.72:8081)

이 결과가 의미하는 것https://app1.hyeonworks.com/api/echoBFF 가 아니라 echo 앱으로 간다. 이 실험이 재는 것은 BFF 가 아니라 헤더를 그대로 받아 쓰는 upstream 이므로 이쪽이 맞다.

kubectl get endpoints 는 쓰지 않는다. v1.33 부터 deprecated 라 경고가 뜬다. 위처럼 describe ingress / describe svc 를 보거나 get endpointslice -l kubernetes.io/service-name=echo 를 본다.

1-2. echo 응답을 한 번 통째로 본다

나중에 걸러 보려면 먼저 통째로 봐야 한다. 어떤 키가 있는지 알아야 무엇으로 거를지 정할 수 있다.

확인

curl -s https://app1.hyeonworks.com/api/echo

형태 — 한 줄 JSON 이 통째로 나온다

{"headers":{"host":["app1.hyeonworks.com"],"x-forwarded-host":["app1.hyeonworks.com"],
"x-forwarded-proto":["https"],"x-forwarded-port":["443"],"x-forwarded-for":["..."],
"x-real-ip":["..."],"user-agent":["curl/8.5.0"],"accept":["*/*"]},
"remoteAddr":"...","localAddr":"10.42.1.72","scheme":"https","secure":true,
"serverName":"app1.hyeonworks.com","serverPort":443,
"requestUrl":"https://app1.hyeonworks.com/api/echo"}

어디를 봐야 하는가

  • headers 의 값이 전부 배열이다. HTTP 가 같은 이름의 헤더를 여러 번 허용하기 때문이고, 2-1 의 결과를 읽을 수 있는 이유가 이것이다
  • x-forwarded-protohttps — nginx 가 proxy_set_header설정한 헤더다. 1-4 에서 이 목록을 확인한다
  • scheme / secure / serverName — Keycloak 이 iss 클레임과 리다이렉트를 만들 때 쓰는 값들이다. 2홉 실험이 이 세 개를 봤다

jq 는 이 실험대에 깔려 있지 않다. 걸러 볼 때는 grep -o 를 쓴다.

확인미검증

curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-forwarded-proto":\[[^]]*\]'

형태

"x-forwarded-proto":["https"]

tr ',' '\n' | grep 은 여기서 쓰면 안 된다. 값 배열이 ["admin","editor"] 처럼 쉼표를 품고 있어서 배열이 두 줄로 잘린다. 첫 줄만 보고 「하나만 도착했다」로 읽게 된다 — 이 실험이 가장 조심해야 할 오독이다. grep -o '…\[[^]]*\]' 는 대괄호 안을 통째로 뽑는다.

1-3. 대조군 — 아무것도 안 붙였을 때

확인미검증

curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request[^]]*\]'

어디를 봐야 하는가아무것도 안 나와야 한다. x-auth-request-* 는 edge 가 붙이는 헤더인데, app1 앞에는 oauth2-proxy 가 없으므로 지금은 없다.

이 결과가 의미하는 것이 자리가 비어 있다는 것이 대조군이다. 2절에서 여기에 값이 나타나면 그건 내가 보낸 것이 도착한 것이다. 이 확인을 건너뛰면 「원래 있던 것」과 「내가 넣은 것」이 구별되지 않는다.

1-4. nginx 가 지금 무엇을 설정하고 있나

확인랩 호스트(test-server) 에서

sudo grep proxy_set_header /etc/nginx/sites-available/keycloak-lab

형태03-nginx 가 세운 설정 그대로다

		proxy_set_header Host              $host;
		proxy_set_header X-Forwarded-Host  $host;
		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;

어디를 봐야 하는가X-Auth-Request-* 가 목록에 없다.

이 결과가 의미하는 것 — 그리고 그것이 2절의 결과를 전부 설명한다.

개념 — nginx 의 헤더 처리는 조건부다.

proxy_set_header X-Forwarded-Proto https;   # 설정한 것 → 덮어쓴다
# X-Auth-Request-Roles 설정 없음            # 안 한 것  → 그대로 흘려보낸다

nginx 는 자기가 proxy_set_header 로 설정한 헤더만 덮어쓴다. 설정하지 않은 헤더는 손대지 않고 통과시킨다. 「nginx 가 덮어쓴다」는 명제는 조건부이며, 그 조건이 빠지면 틀린 문장이 된다.

Q4 가 「확인한 사실」로 적어둔 "Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"조건이 빠져 있어 어긋난다. 2-1 이 그것을 재는 자리다.

sudo 가 아무 결과도 안 주면 실패한 것이다. 랩 호스트의 sudo 는 비밀번호를 요구한다(sudo -n -lsudo: a password is required). D-4 후속 작업이 이 사실을 늦게 발견해서 시간을 버렸다. 빈 출력을 「설정이 없다」로 읽지 말고 비밀번호를 넣어 다시 친다.


2. 주입 — 헤더를 위조해서 보낸다

이 절은 클러스터 상태를 바꾸지 않는다. 요청을 보낼 뿐이다. 그래서 되돌릴 것이 없다 — 그리고 그 사실 자체가 이 실험의 무게다. 아무것도 설치하지 않고 아무 권한도 없이, curl 한 줄로 여기까지 된다.

2-1. 동명 헤더 두 개 — 덮어쓰는가, 합치는가, 통과시키는가

하기

curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
  https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'

미검증 — 원래 실행은 스크립트가 응답을 정리했다. 위는 같은 값을 grep 으로 뽑는 형태다.

실측01-header-handling.txt

(b) 동명 헤더 두 개
    보냄:   X-Auth-Request-Roles: admin
            X-Auth-Request-Roles: editor
    도착:   ['admin', 'editor']              ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다

어디를 봐야 하는가값이 두 개다. curl 로 직접 보면 "x-auth-request-roles":["admin","editor"] 로 보인다.

이 결과가 의미하는 것 — 셋 중 어느 것도 아니다.

가설 도착했을 모양 실제
덮어쓴다 ["editor"] 하나
합친다 ["admin, editor"] 한 문자열
통과시킨다 ["admin","editor"]

Edge 가 X-Auth-Request-Roles: viewer 를 붙여도, 공격자가 같은 헤더를 admin 으로 함께 보내면 둘 다 upstream 에 도착한다.

   edge 가 붙인 것:     X-Auth-Request-Roles: viewer
   공격자가 보낸 것:     X-Auth-Request-Roles: admin
   upstream 이 받는 것:  ["viewer","admin"]  또는  ["admin","viewer"]
                        └─ 프레임워크가 "첫 번째"를 고르면 순서가 권한을 정한다

Spring 의 request.getHeader() 는 첫 번째를 돌려준다. 그 순서는 프록시가 정한다. 애플리케이션 코드 어디에도 이 결정이 안 적혀 있다.

2-2. 값 안의 쉼표 — 구분자와 구별할 수 있는가

하기

curl -s -H 'X-Auth-Request-Roles: admin,editor,viewer' \
  https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
curl -s -H 'X-Auth-Request-Roles: role-with,comma' \
  https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'

실측01-header-handling.txt

(a) 쉼표 구분 한 개 헤더
    보냄:   X-Auth-Request-Roles: admin,editor,viewer
    도착:   ['admin,editor,viewer']          ← 문자열 하나 그대로
...
(c) 값 안에 구분자가 들어간 경우
    보냄:   X-Auth-Request-Roles: role-with,comma
    도착:   ['role-with,comma']              ← (a) 와 구별 불가

어디를 봐야 하는가(a) 와 (c) 가 도착 시점에 똑같이 생겼다. 둘 다 값이 하나인 배열이고, 그 안에 쉼표가 있다.

이 결과가 의미하는 것

   "admin,editor,viewer"   쉼표로 자르면 → [admin, editor, viewer]   맞다
   "role-with,comma"       쉼표로 자르면 → [role-with, comma]        ★ 틀렸다

role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다. Keycloak 의 role 이름은 임의 문자열이므로 막을 수 있는 것이 아니다 — 애플리케이션이 「쉼표 쓰지 마세요」라고 정할 수 있는 자리가 아니다.

대안
동명 헤더 여러 개 HTTP 가 허용하고 실제로 도착한다. 다만 위조와 구별이 안 된다(2-1)
Base64 로 감싼 JSON 배열 구분자 문제가 사라진다. 대신 크기가 커진다(2-3)
헤더를 안 쓰고 JWT 를 넘긴다 서명이 있어 위조도 구분자도 해결된다 → BFF 구조

2-3. 크기를 키운다 — 자르나, 거부하나

먼저 한 번은 읽는 형태로 본다. 무엇이 돌아오는지 봐야 뒤의 숫자를 읽을 수 있다.

하기미검증. 원래 실행은 python3 -c "print('r'*$n)" 로 값을 만들었다. 파이썬 없이 만든다

V=$(head -c 8000 /dev/zero | tr '\0' 'r'); echo "만든 길이 ${#V}"
curl -i -s -H "X-Auth-Request-Roles: $V" https://app1.hyeonworks.com/api/echo | head -20

어디를 봐야 하는가 — 상태줄과 본문. 8000 에서는 Tomcat 의 HTML 오류 페이지가 온다. JSON 이 아니라 HTML 이라는 것 자체가 「애플리케이션까지 갔는데 파싱 전에 잘렸다」는 신호다.

이제 여러 크기를 비교한다. 비교가 목적이니 여기서는 코드만 뽑는 형태가 맞다.

하기미검증

for n in 1000 4000 8000 16000 32000; do
  V=$(head -c "$n" /dev/zero | tr '\0' 'r')
  curl -s -o /dev/null -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \
    https://app1.hyeonworks.com/api/echo
done

실측01-header-handling.txt

=== Q4 ② 헤더 크기 상한 ===
  보낸 길이   1000 → HTTP 200, 도착 길이 1000
  보낸 길이   4000 → HTTP 200, 도착 길이 4000
  보낸 길이   8000 → HTTP 400  (Tomcat 의 HTML 오류 페이지)
  보낸 길이  16000 → HTTP 000  (응답을 못 받음 = 연결이 끊김)
  보낸 길이  32000 → HTTP 000

  → 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.

어디를 봐야 하는가000400 이 다른 것이다.

curl 이 찍는 값
400 응답을 받았다. 서버가 거부했다
000 응답 자체를 못 받았다. 연결이 끊겼거나 아예 안 열렸다

이 결과가 의미하는 것자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이다.

크기 누가 거부하나 클라이언트가 보는 것
~8KB Tomcat (maxHttpHeaderSize 기본 8KB) 400 + HTML 오류 페이지
~16KB 이상 nginx (large_client_header_buffers) 응답 없음 / 연결 끊김

두 실패가 전혀 다르게 보인다. 앞의 것은 애플리케이션 오류처럼, 뒤의 것은 네트워크 장애처럼 보인다. 원인은 같은데 진단이 갈린다 — 앞의 것은 앱 로그를 뒤지게 하고 뒤의 것은 방화벽을 뒤지게 한다.

   role 이 늘어난다  →  헤더가 커진다  →  8KB 를 넘는 순간 전면 400

점진적으로 나빠지지 않고 절벽에서 떨어진다. 그리고 그 절벽은 사용자마다 다르다 — role 이 많은 사용자만 깨진다. 테스트 계정으로는 영원히 안 보인다.

2-4. 신원 자체를 위조한다

하기

curl -s \
  -H 'X-Auth-Request-User: administrator' \
  -H 'X-Auth-Request-Email: admin@example.com' \
  -H 'X-Auth-Request-Roles: realm-admin,superuser' \
  https://app1.hyeonworks.com/api/echo

실측01-header-handling.txt

=== Q4 ④ upstream 이 검증하는가 ===
  아무 인증 없이 보냄:
    x-auth-request-user        ['administrator']
    x-auth-request-email       ['admin@example.com']
    x-auth-request-roles       ['realm-admin,superuser']
    remoteAddr                 100.123.124.30
  → 그대로 도착. 검증 없음.

어디를 봐야 하는가1-3 에서 비어 있던 자리에 값이 들어와 있다. 그리고 remoteAddr내 주소다 — 숨지도 않았다.

이 결과가 의미하는 것로그인하지 않았다. 쿠키도 토큰도 없다. 헤더 세 줄이 전부다. upstream 은 그것을 그대로 받는다.


3. 주입이 실제로 「통한 것」인지 확인한다

「도착했다」와 「통했다」는 다르다. 도착해도 아무도 안 읽으면 무해하다. 그래서 읽는 쪽이 검증을 하는지를 대조군으로 확인한다.

3-1. JWT 를 요구하는 경로는 어떻게 되나

하기

for p in /api/echo /api/me /api/protected; do
  curl -s -o /dev/null -w "$p  %{http_code}\n" \
    -H 'X-Auth-Request-User: administrator' \
    -H 'X-Auth-Request-Roles: realm-admin' \
    "https://app1.hyeonworks.com$p"
done

실측01-header-handling.txt

  대조 — JWT 를 요구하는 경로:
    /api/echo        HTTP 200   (permitAll)
    /api/me          HTTP 401
    /api/protected   HTTP 401

어디를 봐야 하는가같은 위조 헤더인데 결과가 갈린다.

이 결과가 의미하는 것 — 위조 헤더는 /api/echo 를 열어 준 것이 아니다. 거기는 원래 permitAll 이라 열려 있었다. /api/me401 이다 — 헤더로는 인증이 안 된다.

실측 — 같은 파일의 SecurityConfig 발췌

  backend SecurityConfig:
    .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll()
    .anyRequest().authenticated()
    .oauth2ResourceServer(oauth2 -> oauth2.jwt(...))

3-2. 그래서 무엇이 다른가

   JWT 경로   →  서명이 있다   →  검증할 대상이 있다   →  위조가 안 된다
   헤더 경로  →  서명이 없다   →  검증할 대상이 없다   →  ★ 위조를 구별할 방법이 없다

request.getHeader("X-Auth-Request-User") 는 그 값이 어디서 왔는지 모른다. edge 가 붙였는지 클라이언트가 붙였는지 구별할 정보가 값 안에 없다.

Q4 가 「확인한 사실」로 적어둔 "upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을 검증할 방법이 없다"정확하다. 그리고 그것이 이 구조의 본질적 한계다.

2홉 실험에서 헤더 위조로 serverName: evil.example.com 을 만든 것과 같은 종류다. 거기서는 쿠키 속성이었지만 여기서는 신원 그 자체다.

여기까지가 요청만으로 되는 부분이다. 여기서 멈춰도 ①②④ 는 다 봤다.


4. 두 번째 주입 — IdP 에서 클레임을 바꾼다

여기부터 상태가 바뀐다. ③ 「role 변경은 언제 반영되는가」를 재려면 edge 세션이 실제로 있어야 하므로 oauth2-proxy 가 필요하고, 그것이 app2 를 쓴다.

4-0. app2 를 Grafana 에서 빌린다 — 되돌리는 것을 먼저 만든다

하기백업이 먼저다

sudo kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
wc -l /tmp/grafana-ingress-backup.yaml
sudo kubectl -n observability delete ingress grafana
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
sudo kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s

실측01-deploy.txt 의 첫 줄

  grafana ingress 삭제

되돌리기5-4. 지금 확인해 둔다

sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml

wc -l 을 왜 치나 — 백업 파일이 비어 있는데 삭제부터 하는 사고를 막는다. 0 줄이면 그 자리에서 멈춘다. 파일이 생겼는지 확인하지 않고 원본을 지우는 것이 이런 작업에서 가장 흔한 사고다.

하기 — 브라우저에서 https://app2.hyeonworks.com/ 를 열고 labuser / labpass 로 로그인한다.

확인 — 세션이 생겼나. 지우기 전에 항상 목록을 먼저 본다

sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'

실측01-orphan-lifecycle.txt 의 기준선

  _oauth2_proxy-f6a9201fd534a047998278452001ccbf
     type=string  ttl=3568초  크기=3510바이트

어디를 봐야 하는가키 이름이 _oauth2_proxy- 로 시작한다. 밑줄로 시작하고 안쪽은 밑줄이다. 'oauth2-proxy*' 같은 패턴은 하나도 안 맞는다 — 그러면 「세션이 없다」로 오독하고, 이어서 지우는 명령이 조용히 아무것도 안 지운다. 목록을 먼저 보는 이유가 이것이다.

4-1. ★ 왜 curl 로 못 하는가

확인 — oauth2-proxy 가 쿠키를 어떻게 만드는지 로그가 말한다

sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep -i 'Cookie settings' | head -1

실측01-orphan-lifecycle.txt

  기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
             httponly:true expiry:1h0m0s ... refresh:disabled

어디를 봐야 하는가httponly:truerefresh:disabled.

이 결과가 의미하는 것

  • httponly:trueJS 도 못 읽고, 브라우저 밖으로 꺼낼 수도 없다. 그래서 curl -b 로 로그인 상태를 흉내 낼 수 없다. 이 측정은 브라우저 안에서 해야 한다. 여기서 「curl 로 하면 되지 않나」를 붙들면 몇 시간이 간다 — 원래 실행도 그래서 Playwright 로 연 브라우저를 썼다
  • refresh:disabled4-4 의 결과를 미리 말해 준다. --cookie-refresh 가 없으면 세션은 토큰을 다시 받지 않는다

4-2. 기준선 — 지금 무슨 값이 나가고 있나

X-Auth-Request-Roles 대신 x-forwarded-email 을 쓴다. role 을 헤더로 내보내려면 추가 설정이 필요한데, 「IdP 의 클레임 변경이 언제 반영되는가」는 어느 클레임이든 같은 질문이다.

하기 — 로그인된 app2 탭에서 F12 → Console

for (let i = 0; i < 3; i++) {
  const r = await (await fetch('/api/echo')).text();
  console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
}

실측03-b4-role-propagation.txt

=== [1] 기준선 — 변경 전 (브라우저 fetch) ===
2026-09-04T07:51:23.862Z  req#1  HTTP 200  x-forwarded-email=labuser@example.com  x-forwarded-preferred-username=labuser
2026-09-04T07:51:24.304Z  req#2  HTTP 200  x-forwarded-email=labuser@example.com  x-forwarded-preferred-username=labuser
2026-09-04T07:51:24.722Z  req#3  HTTP 200  x-forwarded-email=labuser@example.com  x-forwarded-preferred-username=labuser

어디를 봐야 하는가labuser@example.com. 이 값이 대조군이다.

4-3. IdP 에서 값을 바꾼다

하기 — 셸에서

UID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
        get users -r keycloak-patterns -q username=labuser \
        --fields id --format csv --noquotes | tail -1)
echo "uid=$UID"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$UID -r keycloak-patterns -s email=CHANGED-labuser@example.com
date -u '+%Y-%m-%dT%H:%M:%SZ 변경'

되돌리기5-3. 지금 명령을 확인해 둔다

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$UID -r keycloak-patterns -s email=labuser@example.com

확인 — IdP 쪽은 정말 바뀌었나. 바뀌지 않은 것을 「반영 안 됨」으로 읽지 않기 위해 반드시 본다

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get users/$UID -r keycloak-patterns --fields email

실측03-b4-role-propagation.txt

=== [2] IdP 에서 email 을 바꾼다 (kubectl 출력) ===
  변경 시각(UTC): 2026-09-04T07:53:32.000Z
  IdP 의 값:
    [ {
      "email" : "changed-labuser@example.com"
    } ]
  oauth2-proxy 세션: 1 개 (그대로 살아 있다)

어디를 봐야 하는가IdP 값은 바뀌었고 세션은 그대로 1개다. 이 두 줄이 있어야 다음 절의 「옛 값」이 「반영 안 됨」이라고 말할 수 있다.

4-4. 요청을 반복한다 — 몇 번째부터 바뀌나

하기 — 브라우저 콘솔에서. 0.5초 간격으로 12번

for (let i = 0; i < 12; i++) {
  const r = await (await fetch('/api/echo')).text();
  console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
  await new Promise(s => setTimeout(s, 500));
}

실측03-b4-role-propagation.txt

=== [3] 변경 후 12회 반복 (브라우저 fetch) ===
2026-09-04T07:51:56.300Z  req#1   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:56.864Z  req#2   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:57.489Z  req#3   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:58.018Z  req#4   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:58.602Z  req#5   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:59.217Z  req#6   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:51:59.743Z  req#7   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:00.342Z  req#8   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:00.964Z  req#9   HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:01.574Z  req#10  HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:02.187Z  req#11  HTTP 200  x-forwarded-email=labuser@example.com
2026-09-04T07:52:02.719Z  req#12  HTTP 200  x-forwarded-email=labuser@example.com

  → 12회 · 약 6.4초 동안 전부 옛 값. 요청 횟수로는 반영되지 않는다.

어디를 봐야 하는가12줄이 전부 같다.

이 결과가 의미하는 것 — Q4 는 「몇 번째 요청부터 반영되는지」를 물었는데, 답은 「요청으로는 안 된다」이다. 요청 횟수가 아니라 세션의 나이가 정한다.

4-5. ★ 두 시계가 어긋나 있다 — 그래서 이 결론이 성립한다

브라우저 타임스탬프와 서버 타임스탬프를 그대로 비교하면 안 된다.

실측03-b4-role-propagation.txt

=== [시계 보정] 두 시계가 다르다 — 해석에 필요하다 ===
  개발 머신(브라우저 fetch 의 타임스탬프): 2026-09-04T07:52:20Z
  test-server (kubectl 출력의 타임스탬프): 2026-09-04T07:54:07Z
  → test-server 가 약 107초 앞선다.
     브라우저 07:51:56 = 서버 07:53:43 이므로, 아래 12회는 변경(07:53:32) 11초 뒤다.

어디를 봐야 하는가107초.

이 결과가 의미하는 것 — 보정 전에는 12회의 타임스탬프(07:51:56~)가 변경 시각(07:53:32)보다 앞서 보인다. 그대로 읽으면 「변경 전에 잰 것」이 되어 결론이 통째로 무너진다. 보정하면 12회는 변경 11초 뒤이고, 그래야 「변경 후에도 옛 값」이라는 결론이 선다.

확인 — 당신 환경의 어긋남을 잰다

date -u '+%Y-%m-%dT%H:%M:%SZ'

그리고 브라우저 콘솔에서

new Date().toISOString()

두 값의 차가 보정값이다.

두 기계의 로그를 나란히 놓기 전에 시계를 확인한다. D-4 는 이 확인을 안 해서 인증서 공백을 처음에 잘못 계산했고, 나중에 38분 25초로 정정했다. 같은 실수가 여기서도 났고, 증거 파일에 보정값을 적어 두는 것으로 처리했다.

4-6. 세션을 지우고 재인증시킨다

하기지우기 전에 목록을 본다(4-0 의 이유)

sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' \
  | xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del

미검증 — 후속 문서 §3 에 실린 형태를 실제 키 이름에 맞춰 고친 것이다. 패턴이 안 맞으면 아무것도 안 지워지고 오류도 안 난다 — 앞 명령의 목록과 del 이 돌려주는 개수를 대조한다.

되돌리기 — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 접근하면 새 세션이 만들어진다(그게 이 절의 목적이다).

하기 — 브라우저에서 app2 를 새로고침한 뒤 콘솔에서 3회

for (let i = 0; i < 3; i++) {
  const r = await (await fetch('/api/echo')).text();
  console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
}

실측03-b4-role-propagation.txt

=== [6] 재인증 후 (브라우저 fetch) ===
2026-09-04T07:53:01.121Z  req#1  HTTP 200  x-forwarded-email=changed-labuser@example.com
2026-09-04T07:53:01.456Z  req#2  HTTP 200  x-forwarded-email=changed-labuser@example.com
2026-09-04T07:53:01.785Z  req#3  HTTP 200  x-forwarded-email=changed-labuser@example.com

어디를 봐야 하는가새 값이 나온다. 그리고 로그인 화면은 안 떴다 — Keycloak SSO 가 살아 있어 조용히 재인증됐다(B-2 4-5 와 같은 성질이다).

이 결과가 의미하는 것

   변경 후 12회 요청(6.4초)  →  labuser@example.com          (옛 값)
   세션 삭제 후 재인증        →  changed-labuser@example.com  (새 값)

개념 — 세션은 로그인 시점의 스냅샷이다.

   로그인      →  IdP 가 준 클레임을 세션에 담는다
   이후 요청    →  세션에서 읽어 헤더로 내보낸다
                   └─ IdP 를 다시 부르지 않는다
   IdP 에서 변경 →  세션은 모른다
설정 반영 시점
지금 (--cookie-refresh 없음) 쿠키 만료(1시간) 또는 재인증까지 안 됨
--cookie-refresh=5m 최대 5분

권한을 뺏는 변경이 최대 1시간 늦게 반영된다. 해고된 사용자의 세션이 한 시간 더 산다는 뜻이고, 이것이 Q4 의 설계 판단 2번(즉시 반영이 필요한가)에 직접 답한다 — 즉시가 필요하면 헤더 방식은 맞지 않는다.


5. 고치기와 복구

5-1. nginx 에서 동명 헤더를 덮어쓴다 — 먼저 지워야 한다

이 실험대는 이 수정을 적용한 적이 없다. 해설 문서 6절이 「남긴 것」으로 분류한 항목이다. 아래는 미검증이며, 적용하려면 랩 호스트에서 사람이 직접 친다.

하기랩 호스트(test-server) 에서. 백업이 먼저다

sudo cp /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-available/keycloak-lab.b4-backup
ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup
sudo vi /etc/nginx/sites-available/keycloak-lab

location / { ... } 안, 기존 proxy_set_header 들 옆에 넣는다.

	# B-4 — 클라이언트가 보낸 X-Auth-Request-* 를 먼저 지운다.
	# 빈 값으로 set 해야 "설정한 헤더"가 되어 통과가 아니라 덮어쓰기가 된다.
	proxy_set_header X-Auth-Request-User   "";
	proxy_set_header X-Auth-Request-Email  "";
	proxy_set_header X-Auth-Request-Roles  "";

어디를 봐야 하는가"" 로 먼저 지우는 것이 핵심이다.

nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않는다(1-4). 그러니 「덮어쓰게 하려면 먼저 설정해야」 하고, 붙일 값이 없을 때 설정하는 방법이 빈 문자열이다. proxy_set_header X-Auth-Request-Roles ""; 는 nginx 에서 그 헤더를 upstream 으로 보내지 않는다는 뜻이다.

edge 가 진짜 값을 붙여야 하는 자리라면 지운 뒤에 다시 설정한다. 순서가 반대면 클라이언트 값이 살아남는다.

하기 — 문법을 보고 적용한다

sudo nginx -t && sudo systemctl reload nginx

어디를 봐야 하는가nginx -t마지막 줄. syntax is oktest is successful 두 마디가 다 나와야 통과다. 앞의 [warn] 은 통과를 막지 않는다. 실패면 && 가 reload 를 막아 준 것이고 지금 돌고 있는 nginx 는 옛 설정 그대로다.

되돌리기

sudo cp /etc/nginx/sites-available/keycloak-lab.b4-backup /etc/nginx/sites-available/keycloak-lab
sudo nginx -t && sudo systemctl reload nginx

5-2. 고쳐졌는지 같은 명령으로 다시 잰다

하기 — 2-1 과 똑같은 명령

curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
  https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'

미검증 — 이 실험대는 여기까지 재지 않았다.

어디를 봐야 하는가아무것도 안 나와야 한다(1-3 의 대조군과 같아진다). 값이 그대로 나오면 reload 가 안 갔거나 다른 server 블록을 고친 것이다. 워커 PID 가 바뀌었는지로 reload 여부를 판정한다.

systemctl status nginx --no-pager | head -20

5-3. IdP 값을 되돌린다

하기

UID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
        get users -r keycloak-patterns -q username=labuser \
        --fields id --format csv --noquotes | tail -1)
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$UID -r keycloak-patterns -s email=labuser@example.com
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get users/$UID -r keycloak-patterns --fields email

어디를 봐야 하는가"email" : "labuser@example.com". 4-6 에서 배운 대로, 되돌려도 살아 있는 세션에는 즉시 반영되지 않는다. 세션을 한 번 더 지우면 확실하다.

5-4. Grafana Ingress 를 되돌린다

하기

sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml

확인

sudo kubectl get ingress -A | grep app2
curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/

어디를 봐야 하는가app2 를 잡고 있는 Ingress가 observability/grafana 하나여야 한다. 둘이 남아 있으면 어느 쪽이 이길지는 컨트롤러가 정한다 — 되돌린 것이 아니라 경합을 만든 것이다.

oauth2-proxy Deployment 자체는 남겨도 된다. Ingress 만 떼면 app2 로는 안 들어간다. B-7 을 이어서 할 거라면 그대로 두는 편이 낫다.

5-5. 원상복구 확인표

항목 명령 돌아왔을 때
app2 sudo kubectl get ingress -A | grep app2 observability/grafana 하나만
Grafana curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/ Grafana 가 답한다 (200 또는 로그인 302)
app1 curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/echo 200
IdP … kcadm.sh get users/$UID -r keycloak-patterns --fields email labuser@example.com
nginx sudo nginx -t (호스트) test is successful
nginx 백업 ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup 되돌렸으면 지워도 된다
Redis … redis-cli --scan --pattern '_oauth2_proxy-*' 로그아웃했으면 없거나, 새 세션 하나

막히면

전부 이 실험대가 실제로 겪은 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.

증상 원인 확인
동명 헤더가 하나만 도착한 것처럼 보인다 tr ',' '\n' 으로 잘랐다. 값 배열이 두 줄로 쪼개진다 grep -o '…\[[^]]*\]' 로 대괄호째 뽑는다 — 1-2
jq: command not found 이 실험대에 jq 가 없다 grep -o 로 뽑거나 응답을 통째로 본다
python3 -m json.tool 을 쓰라고 되어 있다 해설 문서 7절의 형태다. 값 생성도 python3 -c 였다 head -c N /dev/zero | tr '\0' 'r' — 2-3
16000 에서 000 이 나온다 오류가 아니라 측정 결과다. nginx 가 연결을 끊는다 400(Tomcat)과 000(nginx)을 구별한다 — 2-3
sudo grep 이 빈 결과 호스트 sudo 는 비밀번호를 요구한다 sudo -n -l 로 확인. D-4 가 이 조용한 실패에 걸렸다
Redis 에서 세션이 안 보인다 패턴이 틀렸다. 키는 _oauth2_proxy- 로 시작한다 먼저 --scan 만 쳐서 이름을 눈으로 본다 — 4-0
xargs … del 이 아무것도 안 지운다 같은 원인. 패턴이 안 맞으면 조용히 0건 목록 개수와 del 반환 개수를 대조 — 4-6
curl -b 로 로그인 상태가 재현이 안 된다 쿠키가 HttpOnly 다. 꺼낼 수 없다 브라우저 콘솔에서 잰다 — 4-1
12회가 변경 시각보다 앞서 보인다 두 시계가 107초 어긋나 있었다 보정값을 먼저 잰다 — 4-5
값이 안 바뀐다 버그가 아니다. 세션이 새로 만들어져야 한다 4-6 · --cookie-refresh
app2 가 Grafana 도 프록시도 아닌 것을 준다 Ingress 가 둘 다 남아 있다 get ingress -A | grep app2 — 5-4
/api/me200 이다 위조가 통한 것이 아니라 진짜 JWT 를 보낸 것이다 헤더만 보냈는지 다시 본다 — 3-1

이 실험이 재지 않은 것

항목
5-1 의 nginx 수정 효과 적용한 적이 없다. 해설 문서가 「남긴 것」으로 분류했다
X-Auth-Request-Roles 자체의 반영 시점 role 을 헤더로 내보내려면 추가 설정이 필요해 x-forwarded-email 로 대체했다. 「클레임 변경이 언제 반영되는가」는 어느 클레임이든 같다
--cookie-refresh=5m 을 켠 뒤의 반영 시점 표의 「최대 5분」은 설정의 정의이지 이 실험대에서 잰 값이 아니다
upstream 의 내부 credential 검증 controller 한 곳에만 있다. 공통 경계로 옮기는 것은 코드 변경이라 이 실험 밖이다

다음

실험 B-4 가 남긴 질문
B-7 oauth2-proxy cookie secret 을 회전하면 저장소에 무엇이 남는가. app2 를 빌리는 절차가 같다
B-7a 고아 세션 4-6 에서 지운 그 키들의 수명과 정리 규칙
B-2 BFF 같은 질문을 서버 보관 토큰으로 풀면 어떻게 다른가
설계 2·4번이 해당하므로 Q4 자신의 기준에 따라 BFF 구조가 맞다. 두 구조가 같은 실험대에 다 있다