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>
42 KiB
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,/api는header-lab네임스페이스의 echo 앱이다. 이 실험은/api/echo만 쓴다 — 도착한 헤더를 그대로 되돌려주는 앱이다.- 4절부터는
app2.hyeonworks.com을 Grafana 에서 잠시 빌린다. 인증서가auth·app1·app2만 덮으므로 네 번째 이름을 만들 수 없다. 끝나면 되돌린다 — 5-4. - 4절은 브라우저가 필요하다. oauth2-proxy 세션 쿠키가
HttpOnly라curl로 로그인 상태를 재현할 수 없다. 이유는 4-1. kubectl은kc-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:51–07: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/echo 는 BFF 가
아니라 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-proto가https— 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 -l→sudo: 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 으로
뽑는 형태다.
(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":\[[^]]*\]'
(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
=== Q4 ② 헤더 크기 상한 ===
보낸 길이 1000 → HTTP 200, 도착 길이 1000
보낸 길이 4000 → HTTP 200, 도착 길이 4000
보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지)
보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김)
보낸 길이 32000 → HTTP 000
→ 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.
어디를 봐야 하는가 — 000 과 400 이 다른 것이다.
| 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
=== 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
대조 — JWT 를 요구하는 경로:
/api/echo HTTP 200 (permitAll)
/api/me HTTP 401
/api/protected HTTP 401
어디를 봐야 하는가 — 같은 위조 헤더인데 결과가 갈린다.
이 결과가 의미하는 것 — 위조 헤더는 /api/echo 를 열어 준 것이 아니다.
거기는 원래 permitAll 이라 열려 있었다. /api/me 는 401 이다 —
헤더로는 인증이 안 된다.
실측 — 같은 파일의 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
기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
httponly:true expiry:1h0m0s ... refresh:disabled
어디를 봐야 하는가 — httponly:true 와 refresh:disabled.
이 결과가 의미하는 것
httponly:true— JS 도 못 읽고, 브라우저 밖으로 꺼낼 수도 없다. 그래서curl -b로 로그인 상태를 흉내 낼 수 없다. 이 측정은 브라우저 안에서 해야 한다. 여기서 「curl 로 하면 되지 않나」를 붙들면 몇 시간이 간다 — 원래 실행도 그래서 Playwright 로 연 브라우저를 썼다refresh:disabled— 4-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 ok 와
test 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/me 가 200 이다 |
위조가 통한 것이 아니라 진짜 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 구조가 맞다. 두 구조가 같은 실험대에 다 있다 |