# 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 구조가 맞다.** 두 구조가 같은 실험대에 다 있다 |