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

914 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# B-4 재현 가이드 — 신원 헤더를 위조해 보내고 그대로 도착하는 것을 본다
해설 문서: [`docs/experiment-b4-edge-authorization-scope.md`](../../experiment-b4-edge-authorization-scope.md) ·
증거 원문: [`docs/evidence/b4-edge-authorization/`](../../evidence/b4-edge-authorization/) ·
③ 반영 시점: [후속 문서 §3](../../experiment-followup-untested-items.md) ·
[`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 동명 헤더 두 개가 **둘 다 도착**하는 것 | `curl -H … -H …` · echo 응답 |
| 값 안의 쉼표를 구분자와 **구별할 수 없는** 것 | 같은 자리 |
| 8KB 에서 `400`, 16KB 에서 **응답 자체가 없는** 것 | 크기 훑기 |
| 인증 없이 보낸 **위조 신원**이 그대로 도착하는 것 | 같은 자리 |
| JWT 를 요구하는 경로는 `401` 인 것 | 대조군 |
| IdP 에서 값을 바꿔도 **12회 요청 동안 옛 값**인 것 | 브라우저 콘솔 (쿠키가 HttpOnly 라 curl 로 못 한다) |
## 전제
- [`03-nginx`](../03-nginx/) · [`04-tls`](../04-tls/) · [`05-keycloak`](../05-keycloak/)
가 끝나 있다.
- `app1.hyeonworks.com`**경로에 따라 둘로 갈린다.** `/` 는 BFF,
**`/api``header-lab` 네임스페이스의 echo 앱**이다. 이 실험은 `/api/echo`
만 쓴다 — 도착한 헤더를 그대로 되돌려주는 앱이다.
- 4절부터는 **`app2.hyeonworks.com` 을 Grafana 에서 잠시 빌린다.** 인증서가
`auth` · `app1` · `app2` 만 덮으므로 네 번째 이름을 만들 수 없다.
**끝나면 되돌린다** — [5-4](#5-4-grafana-ingress-를-되돌린다).
- 4절은 **브라우저가 필요하다.** oauth2-proxy 세션 쿠키가 `HttpOnly`
`curl` 로 로그인 상태를 재현할 수 없다. 이유는 [4-1](#4-1--왜-curl-로-못-하는가).
- `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: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. 어느 이름이 어디로 가는가
**확인**
```bash
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 가
둘이고, 경로로 갈린다.
**확인** — 어느 경로가 어디로 가는지 눈으로 본다
```bash
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 응답을 한 번 통째로 본다
**나중에 걸러 보려면 먼저 통째로 봐야 한다.** 어떤 키가 있는지 알아야 무엇으로
거를지 정할 수 있다.
**확인**
```bash
curl -s https://app1.hyeonworks.com/api/echo
```
**형태** — 한 줄 JSON 이 통째로 나온다
```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` 를 쓴다.
**확인****미검증**
```bash
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. 대조군 — 아무것도 안 붙였을 때
**확인****미검증**
```bash
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`)** 에서
```bash
sudo grep proxy_set_header /etc/nginx/sites-available/keycloak-lab
```
**형태** — [`03-nginx`](../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 의 헤더 처리는 조건부다.**
>
> ```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. 동명 헤더 두 개 — 덮어쓰는가, 합치는가, 통과시키는가
**하기**
```bash
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`](../../evidence/b4-edge-authorization/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. 값 안의 쉼표 — 구분자와 구별할 수 있는가
**하기**
```bash
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`](../../evidence/b4-edge-authorization/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)"` 로 값을 만들었다.
파이썬 없이 만든다
```bash
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 이라는 것 자체가 「애플리케이션까지
갔는데 파싱 전에 잘렸다」는 신호다.
이제 여러 크기를 **비교**한다. 비교가 목적이니 여기서는 코드만 뽑는 형태가 맞다.
**하기****미검증**
```bash
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`](../../evidence/b4-edge-authorization/01-header-handling.txt)
```
=== 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. 신원 자체를 위조한다
**하기**
```bash
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`](../../evidence/b4-edge-authorization/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 를 요구하는 경로는 어떻게 되나
**하기**
```bash
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`](../../evidence/b4-edge-authorization/01-header-handling.txt)
```
대조 — 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 에서 빌린다 — 되돌리는 것을 먼저 만든다
**하기****백업이 먼저다**
```bash
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`](../../evidence/b7-cookie-secret/01-deploy.txt) 의 첫 줄
```
grafana ingress 삭제
```
**되돌리기** — [5-4](#5-4-grafana-ingress-를-되돌린다). **지금 확인해 둔다**
```bash
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` 로 로그인한다.
**확인** — 세션이 생겼나. **지우기 전에 항상 목록을 먼저 본다**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
의 기준선
```
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
type=string ttl=3568초 크기=3510바이트
```
**어디를 봐야 하는가****키 이름이 `_oauth2_proxy-` 로 시작한다.**
밑줄로 시작하고 안쪽은 밑줄이다. `'oauth2-proxy*'` 같은 패턴은 **하나도 안
맞는다** — 그러면 「세션이 없다」로 오독하고, 이어서 지우는 명령이 조용히
아무것도 안 지운다. **목록을 먼저 보는 이유가 이것이다.**
## 4-1. ★ 왜 curl 로 못 하는가
**확인** — oauth2-proxy 가 쿠키를 어떻게 만드는지 로그가 말한다
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep -i 'Cookie settings' | head -1
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
기동 로그: 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
```js
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`](../../evidence/followup/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 에서 값을 바꾼다
**하기** — 셸에서
```bash
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](#5-3-idp-값을-되돌린다). 지금 명령을 확인해 둔다
```bash
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 쪽은 정말 바뀌었나. **바뀌지 않은 것을 「반영 안 됨」으로
읽지 않기 위해** 반드시 본다
```bash
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`](../../evidence/followup/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번
```js
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`](../../evidence/followup/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`](../../evidence/followup/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초 뒤**이고,
그래야 「변경 후에도 옛 값」이라는 결론이 선다.
**확인** — 당신 환경의 어긋남을 잰다
```bash
date -u '+%Y-%m-%dT%H:%M:%SZ'
```
그리고 브라우저 콘솔에서
```js
new Date().toISOString()
```
두 값의 차가 보정값이다.
> **두 기계의 로그를 나란히 놓기 전에 시계를 확인한다.** D-4 는 이 확인을
> 안 해서 인증서 공백을 처음에 잘못 계산했고, 나중에 **38분 25초**로
> 정정했다. 같은 실수가 여기서도 났고, **증거 파일에 보정값을 적어 두는
> 것**으로 처리했다.
## 4-6. 세션을 지우고 재인증시킨다
**하기****지우기 전에 목록을 본다**(4-0 의 이유)
```bash
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회
```js
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`](../../evidence/followup/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`)** 에서. **백업이 먼저다**
```bash
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` 들 옆에 넣는다.
```nginx
# 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 가 진짜 값을 붙여야 하는 자리라면 **지운 뒤에 다시 설정**한다.
> 순서가 반대면 클라이언트 값이 살아남는다.
**하기** — 문법을 보고 적용한다
```bash
sudo nginx -t && sudo systemctl reload nginx
```
**어디를 봐야 하는가**`nginx -t`**마지막 줄**. `syntax is ok`
`test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 은 통과를
막지 않는다. 실패면 `&&` 가 reload 를 **막아 준 것**이고 지금 돌고 있는
nginx 는 옛 설정 그대로다.
**되돌리기**
```bash
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 과 **똑같은 명령**
```bash
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 여부를 판정한다.
```bash
systemctl status nginx --no-pager | head -20
```
## 5-3. IdP 값을 되돌린다
**하기**
```bash
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 를 되돌린다
**하기**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml
```
**확인**
```bash
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](b2-multi-instance-session.md) BFF | 같은 질문을 **서버 보관 토큰**으로 풀면 어떻게 다른가 |
| 설계 | **2·4번이 해당하므로 Q4 자신의 기준에 따라 BFF 구조가 맞다.** 두 구조가 같은 실험대에 다 있다 |