Files
document-haness/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-b4-forged-identity-headers.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:59:42 +09:00

49 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
9bb0b051-c158-497f-a744-b24769c93783 SETUP reproduce-b4-forged-identity-headers 신원 헤더를 위조해 보내고 어디까지 도착하는지 본다 trust-handed-over-at-the-edge 위조 신원 헤더와 로그아웃 전파 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/9bb0b051-c158-497f-a744-b24769c93783/edit
name version
curl 8.5.0
final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-4
cdac9b8178391311d8eca1ebc6cac15bb62d79af

신원 헤더를 위조해 보내고 어디까지 도착하는지 본다

신원 헤더를 위조해 보내고 어디까지 도착하는지, 같은 헤더를 토큰이 필요한 경로에 보내면 어떻게 되는지를 대조군과 함께 재는 절차다. 앞 세 절은 클러스터 상태를 안 바꾸고, 4절부터 Grafana 의 Ingress 를 빌린다.

관계

  • nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다 이 절차가 재는 동작의 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다.
  • 믿기 전에 그 헤더를 먼저 지운다 이 절차가 재고 나온 기준이다. 처방을 이 실험대가 적용하지 않았다는 것도 거기 같이 적혀 있다.
  • 두 시계에서 온 값을 빼지 않는다 5절의 반영 지연은 두 기계의 로그를 나란히 놓고 읽는다. 보정을 안 하면 결론이 뒤집힌다.
  • cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다 같은 oauth2-proxy 와 같은 Redis 키를 쓴다. 이어서 하려면 Ingress 를 붙여 둔 채 넘어간다.

본문

읽기 전에 — 어디서 치는가

기계가 셋이다. 위조 요청을 보내는 curl 은 어디서 쳐도 되고 밖에서 치는 편이 공격자 관점에 가깝다. kubectl[lab host] 에서 치고 sudo 를 붙이지 않는다. 랩 호스트(test-server)로 넘어가는 것은 nginx 설정을 만지는 단계 하나뿐이고, 그 블록만 [test-server] 라벨이 붙어 있다. 5절의 클레임 반영은 브라우저 콘솔에서 잰다 — oauth2-proxy 세션 쿠키가 HttpOnlycurl 로 로그인 상태를 재현할 수 없다.

원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectlpermission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.

무엇
네임스페이스 header-lab(echo) · keycloak-lab(BFF·oauth2-proxy) · observability(Grafana)
재는 경로 https://app1.hyeonworks.com/api/echo — 도착한 헤더를 그대로 되돌려주는 앱
대조 경로 같은 호스트의 /api/me · /api/protected — 토큰을 요구한다
위조 수단 curl -H 세 줄. 쿠키도 토큰도 없다
빌리는 이름 app2.hyeonworks.com — 4절에서 Grafana 에게 잠시 빌린다
전 구간 약 30분. 1~3 절만 하고 멈춰도 결론 대부분이 나온다
도구 jq 가 이 실험대에 없다. JSON 은 grep -o 로 뽑는다

무엇을 재는 경로인지 먼저 못박는다. 위조 헤더를 보내는 https://app1.hyeonworks.com/api/echoheader-lab 네임스페이스의 echo 앱으로 가고, 그 경로는 permitAll 이라 oauth2-proxy 를 거치지 않는다. 이 절차가 재는 것은 엣지가 인증을 끝낸 뒤의 인가가 아니라, 헤더를 받아 쓰는 업스트림이 그 값을 검증하는가다. 같은 위조 헤더를 토큰이 필요한 경로에 보내면 거기서 막히고, 그 대조를 주입 검증 절이 같이 친다.

이 실험이 가르는 것

엣지(oauth2-proxy·nginx)가 인증을 끝내고 신원을 헤더로 뒤에 넘기는 구조가 있다. X-Auth-Request-User, X-Auth-Request-Roles 같은 것들이고, 뒤쪽 애플리케이션은 그 헤더를 읽어 사용자를 안다. 그러면 그 헤더는 무엇을 보증하는가. Q4 는 확인한 사실로 이렇게 적어 두었다.

"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"

넷을 따로 잰다.

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

헤더가 누구인지만 말하면 위조는 인증 우회가 된다. 헤더가 무엇을 할 수 있는지(role)까지 말하면 위조는 권한 상승이 된다. 로그인한 일반 사용자가 자기 요청에 X-Auth-Request-Roles: admin 을 한 줄 더 붙이는 것으로 끝난다. 그래서 이 구조는 세 곳이 동시에 성립해야만 안전하다고 가이드가 적는다.

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

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

전제와 되돌리기

  • 03-nginx · 04-tls · 05-keycloak 이 끝나 있다.
  • B-0 이 끝나 BFF 와 Redis 가 떠 있다.
  • app1.hyeonworks.com 이 경로에 따라 둘로 갈린다. / 는 BFF, /apiheader-lab 네임스페이스의 echo 앱이다. 이 절차는 /api/echo 만 쓴다.
  • 4절부터는 app2.hyeonworks.com 을 Grafana 에서 잠시 빌린다. 인증서가 auth · app1 · app2 만 덮으므로 네 번째 이름을 만들 수 없다.
  • 4절은 브라우저가 필요하다. oauth2-proxy 세션 쿠키가 HttpOnlycurl 로 로그인 상태를 재현할 수 없다.

HttpOnly 는 짐작이 아니라 기동 로그에 적혀 있다.

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

실측은 이렇다(observed, 01-orphan-lifecycle.txt).

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

그래서 원래 실행도 Playwright 로 연 브라우저를 썼다.

앞부분은 안전하고 뒷부분이 상태를 바꾼다.

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

되돌리기는 셋이고 셋 다 먼저 읽어 둔다.

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

$USER_ID 는 주입 5절 ③ 에서 잡는 값이라 여기를 먼저 읽는 지금은 비어 있다. 실제로 칠 일이 생기는 것은 그 절을 친 뒤이고, 1~3 절만 하고 그만두는 사람은 email 을 바꾼 적이 없으니 이 줄 자체가 필요 없다.

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

keycloak-lab.b4-backup 은 관찰 절 끝의 처방 단계에서 뜨는 파일이라 그 단계를 안 쳤으면 없다. 셋을 미리 읽어 두라는 것은 읽으라는 뜻이고 지금 치라는 뜻이 아니다 — 없는 상태로 치면 cp: cannot stat 로 끝난다(망가지지는 않는다).

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

주입 전에 같은 명령으로 먼저 본다

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

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

1. app1 이 경로에 따라 어디로 가는가

무엇을 보는가 — 같은 호스트를 잡고 있는 Ingress 목록.

kubectl get ingress -A

어디를 보나 — 모양은 이렇다(observed).

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 가 둘이고 경로로 갈린다. 어느 경로가 어디로 가는지는 눈으로 본다.

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 앱으로 간다. 이 확인을 건너뛰면 뒤에 나오는 200 을 「BFF 가 위조 헤더를 받아 줬다」로 읽게 된다.

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

2. echo 응답을 통째로 본다

무엇을 보는가 — 어떤 키가 있는지. 무엇으로 거를지는 그 뒤에 정한다.

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

어디를 보나 — 한 줄 JSON 이 통째로 나온다(모양은 observed).

{"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 가 같은 이름의 헤더를 여러 번 허용하기 때문이고, 동명 헤더를 두 개 보냈을 때 무엇이 도착했는지도 이 배열이 말해 준다. x-forwarded-protohttps 인 것은 nginx 가 proxy_set_header 로 설정한 헤더라서다. scheme · secure · serverName 은 Keycloak 이 iss 클레임과 리다이렉트를 만들 때 쓰는 값들이다.

걸러 볼 때는 grep -o 를 쓴다. 2026-09-17 에 쳐 보니 이 형태는 한 줄도 못 잡는다(observed).

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

echo 가 응답을 들여쓰고 콜론 양옆에 공백을 넣어 찍기 때문이다. 위 실측은 줄바꿈을 없애고 옮겨 적은 것이라 그 공백이 안 보인다. 실제 화면은 이렇다(observed).

  "x-forwarded-proto" : [ "http" ],

콜론과 대괄호 안쪽의 공백을 허용한다.

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

아래 3절의 grep -o '"x-auth-request[^]]*\]' 는 그대로 써도 된다. 이름 뒤에 바로 : 를 요구하지 않아서 공백이 있어도 걸린다. 콜론을 붙여 쓴 형태만 고치면 된다.

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

3. 대조군 — 아무것도 안 붙였을 때 무엇이 도착하나

무엇을 보는가x-auth-request-* 칸이 비어 있는지. 가이드가 이 줄도 미검증으로 표시했다(unknown).

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

어디를 보나 — 아무것도 안 나와야 한다. 2026-09-17 에도 아무것도 안 나왔다(observed).

이 값이 뜻하는 것x-auth-request-* 는 엣지가 붙이는 헤더인데 app1 앞에는 oauth2-proxy 가 없으므로 지금은 없다. 이 칸이 비어 있는 것이 대조군이다. 주입 뒤 여기에 값이 나타나면 그건 내가 보낸 것이 도착한 것이고, 이 확인을 건너뛰면 원래 있던 것과 내가 넣은 것이 구별되지 않는다.

4. nginx 가 지금 무엇을 설정하는가

무엇을 보는가proxy_set_header 목록. 랩 호스트에서 친다.

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

어디를 보나03-nginx 가 세운 설정 그대로다(모양은 observed).

		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-* 가 목록에 없고, 그것이 주입 결과를 전부 설명한다.

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

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

sudo 가 아무 결과도 안 주면 실패한 것이다. 랩 호스트의 sudo 는 비밀번호를 요구한다(sudo -n -lsudo: a password is required). 빈 출력을 「설정이 없다」로 읽지 말고 비밀번호를 넣어 다시 친다.

주입

주입은 둘이다. 첫째는 요청에 헤더를 붙여 보내는 것이고, 둘째는 IdP 에서 클레임을 바꾸는 것이다. 첫째는 클러스터 상태를 바꾸지 않아 되돌릴 것이 없다. 아무것도 설치하지 않고 아무 권한도 없이 curl 한 줄로 여기까지 간다.

1. 동명 헤더 두 개를 보낸다

목적 — 같은 이름의 헤더 둘이 도착 시점에 어떻게 보이는지 만든다.

① 같은 헤더를 값만 달리해 두 번 붙인다. 2026-09-17 에 쳐서 확인했다(observed) — 원래 실행은 스크립트가 응답을 정리했고 아래는 같은 값을 grep 으로 뽑는 형태다. 콜론 양옆 공백을 허용해야 걸린다.

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" *: *\[[^]]*\]'

예상 결과 — 대괄호 안에 값이 둘 들어온다. 2026-09-17 실측이다(observed).

"x-auth-request-roles" : [ "admin", "editor" ]

판정은 관찰 절에서 한다.

왜 필요한가 — 덮어쓰는지, 합치는지, 통과시키는지 셋 중 어느 것인지가 여기서 갈린다.

문제가 생기면 — 하나만 온 것처럼 보이면 tr ',' '\n' 으로 자르지 않았는지 본다.

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" *: *\[[^]]*\]'

예상 결과 — 두 줄이 같은 모양으로 온다. 2026-09-17 실측이다(observed).

"x-auth-request-roles" : [ "admin,editor,viewer" ]
"x-auth-request-roles" : [ "role-with,comma" ]

왜 필요한가 — role 이름에 쉼표가 들어갈 수 있다면 쉼표로 자르는 방식이 성립하지 않는다. 위 두 줄이 도착 시점에 구별되지 않는다는 것이 그 근거다.

문제가 생기면 — 대괄호째 뽑았는지 다시 본다.

3. 헤더를 키운다

목적 — 크기 상한에서 무슨 일이 나는지 만든다.

① 먼저 한 번은 읽는 형태로 본다. 무엇이 돌아오는지 봐야 뒤의 숫자를 읽을 수 있다. 두 줄 다 미검증이다(unknown) — 원래 실행은 값을 파이썬으로 만들었다.

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

② 여러 크기를 비교할 때는 코드만 뽑는 형태로 바꾼다.

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

예상 결과 — 다섯 줄이 나오고 뒤 셋이 앞 둘과 다르다. 값은 관찰 절에 있다.

왜 필요한가 — 읽는 형태를 한 번 보지 않으면 400 이 무엇을 돌려준 400 인지 모른다. 8000 에서 오는 것은 JSON 이 아니라 HTML 오류 페이지다.

문제가 생기면000 이 나와도 명령이 잘못된 것이 아니다. 그건 측정 결과다.

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

예상 결과 — echo 앱이 응답을 돌려준다. 무엇이 돌아왔는지는 주입 검증에서 읽는다. 2026-09-17 에 쳐 보니 위조한 두 줄이 그대로 도착했다(observed).

"x-auth-request-email" : [ "attacker@evil.invalid" ]
"x-auth-request-user" : [ "attacker" ]

대조군(3절)에서는 그 칸이 비어 있었으므로, 이 두 줄은 내가 보낸 것이 엣지를 지나 앱까지 닿았다는 뜻이다.

왜 필요한가 — 여기까지가 요청만으로 되는 부분이다. 설치한 것도 받은 권한도 없다.

문제가 생기면 — 응답이 안 오면 1절의 경로 확인으로 돌아간다.

5. Ingress 를 빌리고 IdP 의 값을 바꾼다

목적 — 클레임 변경이 언제 반영되는지 재려고 엣지 세션을 실제로 만든다. 그러려면 oauth2-proxy 가 필요하고, 그것이 app2 를 쓴다.

① 백업이 먼저다. 파일이 생겼는지 줄 수로 확인한 뒤에 원본을 지운다.

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

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

③ 을 치기 전에 변경 전 값을 먼저 잰다. 관찰 ⑤ 의 첫 JS 블록(3회 반복)을 로그인된 app2 탭의 콘솔에서 지금 돌리고 세 줄을 적어 둔다. ③ 을 친 뒤에 재면 세션이 스냅샷이라 변경 뒤에도 옛 값이 나오므로 두 측정이 화면에서 똑같아 보인다. 대조군이 무너졌다는 것을 알아챌 단서가 없어서, 그대로 읽으면 「12회를 보내도 안 바뀐다」를 재지 않고 그냥 적게 된다.

③ IdP 의 email 을 바꾸고 변경 시각을 UTC 로 남긴다.

USER_ID=$(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=$USER_ID"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$USER_ID -r keycloak-patterns -s email=CHANGED-labuser@example.com
date -u '+%Y-%m-%dT%H:%M:%SZ 변경'

예상 결과 — ①의 첫 줄은 이렇다(observed, 01-deploy.txt).

  grafana ingress 삭제

왜 필요한가wc -l 은 백업 파일이 비어 있는데 삭제부터 하는 사고를 막는다. 0 줄이면 거기서 멈추고 원본을 지우지 않는다. 파일이 생겼는지 확인하지 않고 원본을 지우는 것이 이런 작업에서 가장 흔한 사고다.

문제가 생기면rollout status 가 타임아웃이면 Ingress 를 먼저 돌려놓고 다시 시작한다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 본다.

첫째 주입은 대조군 칸에 값이 나타났는가로 확인한다. 실측은 이렇다(observed, 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
  → 그대로 도착. 검증 없음.

주입 전에 비어 있던 칸에 값이 들어와 있고, remoteAddr 이 내 주소다 — 숨지도 않았다. 증거 파일의 ['admin', 'editor'] 같은 표기는 스크립트가 정리한 것이고, curl 로 직접 보면 같은 값이 JSON 배열 ["admin","editor"] 로 온다.

여기서 「도착했다」를 「통했다」로 옮기면 틀린다. 도착해도 아무도 안 읽으면 무해하다. 읽는 쪽이 검증을 하는지를 같은 헤더로 확인한다.

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

실측은 이렇다(observed, 같은 파일).

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

같은 위조 헤더인데 결과가 갈린다. 위조 헤더가 /api/echo 를 열어 준 것이 아니라 거기는 원래 permitAll 이라 열려 있었다. /api/me401 이고, 헤더로는 인증이 안 된다.

가이드는 이것을 앞선 실험과 이어 붙인다.

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

permitAll401 을 가르는 설정은 backend 의 SecurityConfig 에 있다.

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

둘째 주입은 IdP 쪽이 정말 바뀌었는지와 세션이 그대로인지를 같이 본다. 바뀌지 않은 것을 「반영 안 됨」으로 읽지 않으려면 반드시 본다.

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

$UID 는 손으로 $USER_ID 로 바꿔 친다. 값을 담은 변수는 주입 5절 ③ 의 USER_ID 이고 UID 는 셸이 이미 쓰고 있는 읽기 전용 이름이라, 그대로 두면 users/1000 을 읽어 없는 사용자가 나온다.

실측은 이렇다(observed, 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 값은 바뀌었고 세션은 하나다. 이 두 줄이 있어야 다음 절의 옛 값을 「반영 안 됨」이라고 말할 수 있다. 세션 목록은 지우기 전에 항상 먼저 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
  _oauth2_proxy-f6a9201fd534a047998278452001ccbf
     type=string  ttl=3568초  크기=3510바이트

키 이름이 _oauth2_proxy- 로 시작한다. 밑줄로 시작하고 안쪽은 밑줄이다. 'oauth2-proxy*' 같은 패턴은 하나도 안 맞고, 그러면 세션이 없다고 오독한 뒤 이어서 지우는 명령이 조용히 아무것도 안 지운다.

관찰

① 동명 헤더 둘은 덮어쓰이지도 합쳐지지도 않는다. 실측은 이렇다(observed, 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"]

이 절차 앞머리에 옮긴 Q4 의 한 줄이 이 표에서 반만 남는다. 합치지 않는다는 맞았고 덮어쓴다는 틀렸다. 그 줄에는 조건이 빠져 있었다. 조건은 주입 전 4 절에서 읽은 proxy_set_header 여섯 줄에 그 이름이 있느냐이고, X-Auth-Request-* 는 거기 없었다.

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

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

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

② 값 안의 쉼표는 구분자와 구별되지 않는다. 실측은 이렇다(observed, 같은 파일).

(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 가 허용하고 실제로 도착한다. 다만 위조와 구별이 안 된다
Base64 로 감싼 JSON 배열 구분자 문제가 사라진다. 대신 크기가 커진다
헤더를 안 쓰고 JWT 를 넘긴다 서명이 있어 위조도 구분자도 해결된다 → BFF 구조

③ 크기는 절벽에서 떨어진다. 8000 에서는 Tomcat 의 HTML 오류 페이지가 온다. JSON 이 아니라 HTML 이라는 것 자체가 애플리케이션까지 갔는데 파싱 전에 잘렸다는 신호다.

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

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

000400 이 다른 값이다(observed, 같은 파일).

curl 이 찍는 값
400 응답을 받았다. 서버가 거부했다
000 응답 자체를 못 받았다. 연결이 끊겼거나 아예 안 열렸다
크기 누가 거부하나 클라이언트가 보는 것
~8KB Tomcat (maxHttpHeaderSize 기본 8KB) 400 + HTML 오류 페이지
~16KB 이상 nginx (large_client_header_buffers) 응답 없음 / 연결 끊김

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

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

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

④ 위조한 신원은 검증 없이 도착한다. 주입 검증에 실은 네 줄이 그 결과이고, 같은 헤더가 /api/me 에서 401 인 것도 거기 같이 적었다.

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

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

Q4 가 확인한 사실로 적어 둔 다른 한 줄은 그대로 성립했다.

"upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을 검증할 방법이 없다"

⑤ 클레임 변경은 요청 횟수로는 반영되지 않는다. 먼저 바꾸기 전 값을 브라우저에서 잰다 — 아래 첫 블록은 주입 5절 ③ 을 치기 전에 돌린다. 여기까지 읽고 나서 처음 돌리면 이미 email 을 바꾼 뒤라 「변경 전」을 잰 것이 아니다. 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]);
}
=== [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

바꾼 뒤 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));
}

실측은 이렇다(observed, 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줄이 전부 같다. 몇 번째 요청부터 반영되는지를 물었는데 답은 요청으로는 안 된다는 것이고, 요청 횟수가 아니라 세션의 나이가 정한다. 가이드가 적은 값은 12회 · 약 6.4초다.

이 결론은 시계를 보정해야 성립한다. 실측은 이렇다(observed, 같은 파일).

=== [시계 보정] 두 시계가 다르다 — 해석에 필요하다 ===
  개발 머신(브라우저 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초 뒤다.

D 층의 「106초」와 이 「약 107초」는 같은 왜곡을 두 번 쟀다. 둘 다 test-server 가 dev 머신보다 앞선 양을 말한다. 여기서는 타임스탬프 둘의 차(07:54:07 07:52:20)로 어림했고, D-4a 에서는 ACME 응답을 제3의 기준으로 두고 다시 쟀다. 보정에 쓸 값은 D-4a 의 106초이고 여기 107초는 이 절의 12회를 읽기 위한 어림이다. 두 값을 섞어 빼지 않는다.

보정 전에는 12회의 타임스탬프(07:51:56~)가 변경 시각(07:53:32)보다 앞서 보인다. 그대로 읽으면 변경 전에 잰 것이 되어 결론이 통째로 무너진다. 보정하면 12회는 변경 11초 뒤이고, 그래야 변경 후에도 옛 값이라는 말이 선다. 자기 환경의 어긋남은 date -u '+%Y-%m-%dT%H:%M:%SZ' 와 브라우저 콘솔의 new Date().toISOString() 을 견줘서 잰다. 두 기계의 로그를 나란히 놓기 전에 시계를 확인한다 — D-4 도 이 확인을 안 해서 인증서 공백을 처음에 잘못 계산했고 나중에 38분 25초로 정정했다.

세션을 지우고 재인증시키면 새 값이 온다. 지우기 전에 목록을 본다. 가이드가 두 번째 줄을 미검증으로 표시했다(unknown).

kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' \
  | xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
=== [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

새 값이 나오고, 로그인 화면은 안 떴다(observed). Keycloak SSO 가 살아 있어 조용히 재인증됐다.

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

세션은 로그인 시점의 스냅샷이다. 로그인할 때 IdP 가 준 클레임을 세션에 담고, 이후 요청은 세션에서 읽어 헤더로 내보내며 IdP 를 다시 부르지 않는다. 그래서 IdP 에서 바꿔도 세션은 모른다. 지금 구성(--cookie-refresh 없음)에서는 쿠키 만료(1시간) 또는 재인증까지 안 되고, --cookie-refresh=5m 이면 최대 5분이라고 가이드가 적는다. 다만 그 5분은 설정의 정의이지 이 실험대에서 잰 값이 아니다(unknown). 권한을 뺏는 변경이 최대 1시간 늦게 반영되므로, 즉시 반영이 필요하면 헤더 방식은 맞지 않는다.

nginx 에서 동명 헤더를 먼저 지우는 것이 그 처방이고, 이 실험대는 그 수정을 적용한 적이 없다(unknown). 해설 문서 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 는 자기가 설정하지 않은 헤더를 덮어쓰지 않으니 덮어쓰게 하려면 먼저 설정해야 하고, 붙일 값이 없을 때 설정하는 방법이 빈 문자열이다. proxy_set_header X-Auth-Request-Roles ""; 는 nginx 에서 그 헤더를 업스트림으로 보내지 않는다는 뜻이다. 엣지가 진짜 값을 붙여야 한다면 지운 뒤에 다시 설정한다 — 순서가 반대면 클라이언트 값이 살아남는다.

sudo nginx -t && sudo systemctl reload nginx

nginx -t 의 마지막 줄에서 syntax is oktest is successful 두 마디가 다 나와야 통과다. 앞의 [warn] 은 통과를 막지 않는다. 실패면 && 가 reload 를 막아 준 것이고 지금 돌고 있는 nginx 는 옛 설정 그대로다. 고쳐졌는지는 동명 헤더 두 개를 보낸 명령을 똑같이 다시 쳐서 보고, 대조군과 같아지면(아무것도 안 나오면) 고쳐졌다. 이 실험대는 여기까지 재지 않았다(unknown).

가이드는 판정 규칙을 하나 더 붙인다. 값이 그대로 나오면 reload 가 안 갔거나 다른 server 블록을 고친 것이고, reload 가 실제로 갔는지는 워커 PID 가 바뀌었는지로 본다.

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

D-4a 가 같은 판정법을 인증서 갱신에 쓴다.

복구와 원상복구 확인표

1. IdP 값을 되돌린다

목적 — 바꿔 둔 email 을 원래 값으로 돌린다.

① 사용자 id 를 다시 잡고 값을 되돌린 뒤 같은 명령으로 읽는다.

USER_ID=$(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)
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  update users/$USER_ID -r keycloak-patterns -s email=labuser@example.com
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get users/$UID -r keycloak-patterns --fields email

마지막 줄의 $UID 도 손으로 $USER_ID 로 바꿔 친다. 앞에서와 같은 이유로, 셸이 이미 쓰고 있는 읽기 전용 이름을 그대로 두면 users/1000 을 읽어 없는 사용자가 나온다. 가이드는 이 편 전체에 대해 「명령은 같고 변수 이름만 다르다」로 적어 두었다. 아래 확인표의 IdP 행도 같이 바꿔 읽는다.

예상 결과"email" : "labuser@example.com" 이 나와야 한다.

왜 필요한가 — 되돌려도 살아 있는 세션에는 즉시 반영되지 않는다. 세션을 한 번 더 지우면 확실하다.

문제가 생기면uid= 가 빈 값이면 --format csv --noquotes 출력의 마지막 줄이 아닌 다른 줄을 잡은 것이다.

2. Grafana Ingress 를 돌려준다

목적app2 를 잡고 있는 Ingress 를 하나로 만든다.

이 편은 백업을 /tmp/grafana-ingress-backup.yaml 에 뜬다. 같은 Grafana Ingress 를 빌리는 다른 편들(B-7·B-7a·C-1·C-2)은 ~/grafana-ingress-backup.yaml 을 읽는다. 경로가 다르므로 여기서 빌린 채로 그 편들로 넘어가면 복구가 no such file 로 죽고 Grafana 가 안 열리는 채로 끝난다. 이어서 갈 거면 이 절을 먼저 끝내 Grafana Ingress 를 돌려놓는다. 그리고 /tmp 는 재부팅으로 날아가므로 이 편을 이틀에 나눠 치지 않는다.

① oauth2-proxy 것을 먼저 지우고 Grafana 것을 올린다.

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

② 하나만 남았는지 확인한다.

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 을 이어서 할 거라면 그편이 낫다고 가이드가 적는다.

문제가 생기면 — app2 가 Grafana 도 프록시도 아닌 것을 주면 Ingress 가 둘 다 남아 있다.

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

무엇이 관측이고 무엇이 아닌가

이 절차의 숫자는 2026-09-04 14:23 KST(①②④)와 07:5107:53 UTC(③)에 돈 실행에서 나왔다(observed).

  • (observed) 동명 헤더 두 개가 ['admin', 'editor'] 로 둘 다 도착한 것, 쉼표 구분 (a)와 값 안 쉼표 (c)가 도착 시점에 구별되지 않는 것, 크기 훑기 다섯 줄(1000·4000200, 8000400, 16000·32000000), 인증 없이 보낸 위조 신원 세 줄과 remoteAddr 100.123.124.30, JWT 를 요구하는 경로의 /api/echo 200 · /api/me 401 · /api/protected 401, IdP 변경 시각 2026-09-04T07:53:32.000Z 와 바뀐 값, 변경 후 12회의 타임스탬프와 값 전부, 두 시계가 약 107초 어긋난 것, 세션 삭제 후 3회의 새 값, _oauth2_proxy-f6a9201fd534a047998278452001ccbfttl=3568초 · 크기=3510바이트.
  • 경로를 혼동하지 않는다(observed) — 위조 헤더를 잰 곳은 app1.hyeonworks.com/api 의 echo 앱이고 그 경로는 permitAll 이라 oauth2-proxy 를 거치지 않는다. 헤더가 도착한 것과 인가가 뚫린 것은 다른 사건이고, 같은 헤더를 /api/me 에 보내면 401 이다.
  • (unknown) proxy_set_header X-Auth-Request-* ""; 수정. 이 실험대는 그것을 적용한 적이 없다. 가이드가 「적용하려면 랩 호스트에서 사람이 직접 친다」로 못박았고, 적용한 뒤 다시 재는 절도 미검증이다. 그러므로 위조가 막히는지는 이 문서 어디에도 측정으로 없다.
  • (unknown) grep -o 로 헤더 배열을 뽑는 줄들, head -c N /dev/zero | tr '\0' 'r' 로 긴 값을 만드는 줄, 크기 훑기 루프, xargs -r … redis-cli del 로 세션을 지우는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행은 스크립트와 python3 -c 를 썼다.
  • (unknown) --cookie-refresh=5m 을 켰을 때의 「최대 5분」. 설정의 정의이지 이 실험대에서 잰 값이 아니다.
  • 이 실험이 재지 않은 것 — X-Auth-Request-Roles 자체의 반영 시점은 재지 않았다. role 을 헤더로 내보내려면 추가 설정이 필요해 x-forwarded-email 로 대체했고, 클레임 변경이 언제 반영되는가는 어느 클레임이든 같다는 것이 그 근거다. 업스트림의 내부 credential 검증을 공통 경계로 옮기는 것도 코드 변경이라 이 실험 밖이다.