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>
914 lines
42 KiB
Markdown
914 lines
42 KiB
Markdown
# 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: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. 어느 이름이 어디로 가는가
|
||
|
||
**확인**
|
||
```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 구조가 맞다.** 두 구조가 같은 실험대에 다 있다 |
|