--- id: 9bb0b051-c158-497f-a744-b24769c93783 kind: SETUP slug: reproduce-b4-forged-identity-headers title: 신원 헤더를 위조해 보내고 어디까지 도착하는지 본다 topic: trust-handed-over-at-the-edge topicName: 위조 신원 헤더와 로그아웃 전파 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/9bb0b051-c158-497f-a744-b24769c93783/edit" pinnedVersions: - name: curl version: 8.5.0 source: - final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-4 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # 신원 헤더를 위조해 보내고 어디까지 도착하는지 본다 신원 헤더를 위조해 보내고 어디까지 도착하는지, 같은 헤더를 토큰이 필요한 경로에 보내면 어떻게 되는지를 대조군과 함께 재는 절차다. 앞 세 절은 클러스터 상태를 안 바꾸고, 4절부터 Grafana 의 Ingress 를 빌린다. ## 관계 - **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다** 이 절차가 재는 동작의 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다. - **믿기 전에 그 헤더를 먼저 지운다** 이 절차가 재고 나온 기준이다. 처방을 이 실험대가 적용하지 않았다는 것도 거기 같이 적혀 있다. - **두 시계에서 온 값을 빼지 않는다** 5절의 반영 지연은 두 기계의 로그를 나란히 놓고 읽는다. 보정을 안 하면 결론이 뒤집힌다. - **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다** 같은 oauth2-proxy 와 같은 Redis 키를 쓴다. 이어서 하려면 Ingress 를 붙여 둔 채 넘어간다. ## 본문 ## 읽기 전에 — 어디서 치는가 기계가 셋이다. 위조 요청을 보내는 `curl` 은 어디서 쳐도 되고 밖에서 치는 편이 공격자 관점에 가깝다. `kubectl` 은 `[kc-lab-1]` 에서 치고 `sudo` 를 붙이지 않는다. **랩 호스트(`test-server`)로 넘어가는 것은 nginx 설정을 만지는 단계 하나뿐이고**, 그 블록만 `[test-server]` 라벨이 붙어 있다. 5절의 클레임 반영은 브라우저 콘솔에서 잰다 — oauth2-proxy 세션 쿠키가 `HttpOnly` 라 `curl` 로 로그인 상태를 재현할 수 없다. | 무엇 | 값 | |---|---| | 네임스페이스 | `header-lab`(echo) · `keycloak-lab`(BFF·oauth2-proxy) · `observability`(Grafana) | | 재는 경로 | `https://app1.hyeonworks.com/api/echo` — 도착한 헤더를 그대로 되돌려주는 앱 | | 대조 경로 | 같은 호스트의 `/api/me` · `/api/protected` — 토큰을 요구한다 | | 위조 수단 | `curl -H` 세 줄. 쿠키도 토큰도 없다 | | 빌리는 이름 | `app2.hyeonworks.com` — 4절에서 Grafana 에게 잠시 빌린다 | | 전 구간 | 약 30분. 1~3 절만 하고 멈춰도 결론 대부분이 나온다 | | 도구 | `jq` 가 이 실험대에 없다. JSON 은 `grep -o` 로 뽑는다 | **무엇을 재는 경로인지 먼저 못박는다.** 위조 헤더를 보내는 `https://app1.hyeonworks.com/api/echo` 는 `header-lab` 네임스페이스의 echo 앱으로 가고, 그 경로는 `permitAll` 이라 oauth2-proxy 를 거치지 않는다. 이 절차가 재는 것은 엣지가 인증을 끝낸 뒤의 인가가 아니라, 헤더를 받아 쓰는 업스트림이 그 값을 검증하는가다. 같은 위조 헤더를 토큰이 필요한 경로에 보내면 거기서 막히고, 그 대조를 주입 검증 절이 같이 친다. ## 이 실험이 가르는 것 엣지(oauth2-proxy·nginx)가 인증을 끝내고 신원을 헤더로 뒤에 넘기는 구조가 있다. `X-Auth-Request-User`, `X-Auth-Request-Roles` 같은 것들이고, 뒤쪽 애플리케이션은 그 헤더를 읽어 사용자를 안다. 그러면 그 헤더는 무엇을 보증하는가. Q4 는 확인한 사실로 이렇게 적어 두었다. > *"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"* 넷을 따로 잰다. ```text ① 여러 값을 어떻게 넣는가 쉼표? 헤더를 여러 개? → 구별할 수 있나 ② 커지면 어떻게 되는가 잘리나? 거부되나? ③ IdP 에서 바꾸면 언제 반영되나 ④ 위조하면 통하는가 ★ 여기가 권한의 문제다 ``` 헤더가 누구인지만 말하면 위조는 인증 우회가 된다. 헤더가 무엇을 할 수 있는지(role)까지 말하면 위조는 권한 상승이 된다. 로그인한 일반 사용자가 자기 요청에 `X-Auth-Request-Roles: admin` 을 한 줄 더 붙이는 것으로 끝난다. 그래서 이 구조는 세 곳이 동시에 성립해야만 안전하다고 가이드가 적는다. ```text ① 외부 → upstream 직접 경로 차단 (NetworkPolicy) ② edge 에서 동명 헤더 덮어쓰기 (proxy_set_header) ③ upstream 에서 내부 credential 검증 (공통 경계) ``` 하나라도 빠지면 나머지 둘이 무의미하다. 이 절차는 ②가 빠져 있다는 것을 재고, 그 결과로 ④가 성립한다는 것을 재고, ③이 한 곳에만 있다는 것을 확인한다. ## 전제와 되돌리기 - `03-nginx` · `04-tls` · `05-keycloak` 이 끝나 있다. - B-0 이 끝나 BFF 와 Redis 가 떠 있다. - **`app1.hyeonworks.com` 이 경로에 따라 둘로 갈린다.** `/` 는 BFF, `/api` 는 `header-lab` 네임스페이스의 echo 앱이다. 이 절차는 `/api/echo` 만 쓴다. - 4절부터는 `app2.hyeonworks.com` 을 Grafana 에서 잠시 빌린다. 인증서가 `auth` · `app1` · `app2` 만 덮으므로 네 번째 이름을 만들 수 없다. - 4절은 브라우저가 필요하다. oauth2-proxy 세션 쿠키가 `HttpOnly` 라 `curl` 로 로그인 상태를 재현할 수 없다. 그 `HttpOnly` 는 짐작이 아니라 기동 로그에 적혀 있다. ```bash label="[kc-lab-1] 쿠키 속성을 기동 로그에서 읽는다" kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep -i 'Cookie settings' | head -1 ``` 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text 기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s ... refresh:disabled ``` 그래서 원래 실행도 Playwright 로 연 브라우저를 썼다. 앞부분은 안전하고 뒷부분이 상태를 바꾼다. | 절 | 무엇을 하나 | 되돌릴 것 | |---|---|---| | 1~3 | **요청만 보낸다.** 클러스터 상태가 안 바뀐다 | 없음 | | 4 | Grafana 에서 app2 를 빌리고 **IdP 의 사용자 속성을 바꾼다** | Ingress · email 값 · 세션 | | 5 | **nginx 설정을 바꾼다** (호스트) | 설정 파일 | 되돌리기는 셋이고 셋 다 먼저 읽어 둔다. ```bash label="[kc-lab-1] 중간에 그만둘 때 ① Ingress 를 돌려준다" kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f /tmp/grafana-ingress-backup.yaml ``` `$USER_ID` 는 주입 5절 ③ 에서 잡는 값이라 여기를 먼저 읽는 지금은 비어 있다. 실제로 칠 일이 생기는 것은 그 절을 친 뒤이고, 1~3 절만 하고 그만두는 사람은 email 을 바꾼 적이 없으니 이 줄 자체가 필요 없다. ```bash label="[kc-lab-1] 중간에 그만둘 때 ② IdP 의 email 을 되돌린다 — $USER_ID 를 잡은 뒤에 친다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ update users/$USER_ID -r keycloak-patterns -s email=labuser@example.com ``` `keycloak-lab.b4-backup` 은 관찰 절 끝의 처방 단계에서 뜨는 파일이라 그 단계를 안 쳤으면 없다. 셋을 미리 읽어 두라는 것은 읽으라는 뜻이고 지금 치라는 뜻이 아니다 — 없는 상태로 치면 `cp: cannot stat` 로 끝난다(망가지지는 않는다). ```bash label="[test-server] 중간에 그만둘 때 ③ nginx 설정을 되돌린다 — 백업을 뜬 뒤에만" sudo cp /etc/nginx/sites-available/keycloak-lab.b4-backup /etc/nginx/sites-available/keycloak-lab sudo nginx -t && sudo systemctl reload nginx ``` ## 주입 전에 같은 명령으로 먼저 본다 시험군만 재는 측정은 측정이 아니다. 위조 헤더가 도착했다고 말하려면 아무것도 안 붙였을 때 무엇이 도착하는지를 먼저 봐야 한다. 넓은 것부터 좁혀 간다. ```text 경로 확인 → echo 응답 통째로 보기 → 대조군(아무것도 안 붙임) → nginx 가 지금 뭘 설정하나 ``` ### 1. app1 이 경로에 따라 어디로 가는가 **무엇을 보는가** — 같은 호스트를 잡고 있는 Ingress 목록. ```bash label="[kc-lab-1] ① Ingress 를 전부 본다" kubectl get ingress -A ``` **어디를 보나** — 모양은 이렇다(observed). ```text 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 label="[kc-lab-1] ② echo 의 경로 규칙을 본다" kubectl -n header-lab describe ingress echo | grep -A5 Rules ``` ```text Rules: Host Path Backends ---- ---- -------- app1.hyeonworks.com /api echo:8081 (10.42.0.61:8081,10.42.1.72:8081) ``` **이 값이 뜻하는 것** — `https://app1.hyeonworks.com/api/echo` 는 BFF 가 아니라 echo 앱으로 간다. 이 확인을 건너뛰면 뒤에 나오는 `200` 을 「BFF 가 위조 헤더를 받아 줬다」로 읽게 된다. `kubectl get endpoints` 는 쓰지 않는다. v1.33 부터 deprecated 라 경고가 뜬다. 위처럼 `describe ingress` · `describe svc` 를 보거나 `get endpointslice -l kubernetes.io/service-name=echo` 를 본다. ### 2. echo 응답을 통째로 본다 **무엇을 보는가** — 어떤 키가 있는지. 무엇으로 거를지는 그 뒤에 정한다. ```bash label="[밖에서] 응답을 통째로 본다" curl -s https://app1.hyeonworks.com/api/echo ``` **어디를 보나** — 한 줄 JSON 이 통째로 나온다(모양은 observed). ```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 가 같은 이름의 헤더를 여러 번 허용하기 때문이고, 동명 헤더를 두 개 보냈을 때 무엇이 도착했는지도 이 배열이 말해 준다. `x-forwarded-proto` 가 `https` 인 것은 nginx 가 `proxy_set_header` 로 설정한 헤더라서다. `scheme` · `secure` · `serverName` 은 Keycloak 이 `iss` 클레임과 리다이렉트를 만들 때 쓰는 값들이다. 걸러 볼 때는 `grep -o` 를 쓴다. 가이드가 이 줄을 미검증으로 표시했다(unknown). ```bash label="[밖에서] 한 헤더만 뽑아 본다" curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-forwarded-proto":\[[^]]*\]' ``` ```text "x-forwarded-proto":["https"] ``` **`tr ',' '\n' | grep` 은 여기서 쓰면 안 된다.** 값 배열이 `["admin","editor"]` 처럼 쉼표를 품고 있어서 배열이 두 줄로 잘린다. 첫 줄만 보고 「하나만 도착했다」로 읽게 되는데, 이 절차에서 가장 조심할 오독이다. `grep -o '…\[[^]]*\]'` 는 대괄호 안을 통째로 뽑는다. ### 3. 대조군 — 아무것도 안 붙였을 때 무엇이 도착하나 **무엇을 보는가** — `x-auth-request-*` 칸이 비어 있는지. 가이드가 이 줄도 미검증으로 표시했다(unknown). ```bash label="[밖에서] 대조군 — 아무것도 안 붙이고 찾아본다" curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request[^]]*\]' ``` **어디를 보나** — 아무것도 안 나와야 한다. **이 값이 뜻하는 것** — `x-auth-request-*` 는 엣지가 붙이는 헤더인데 `app1` 앞에는 oauth2-proxy 가 없으므로 지금은 없다. 이 칸이 비어 있는 것이 대조군이다. 주입 뒤 여기에 값이 나타나면 그건 내가 보낸 것이 도착한 것이고, 이 확인을 건너뛰면 원래 있던 것과 내가 넣은 것이 구별되지 않는다. ### 4. nginx 가 지금 무엇을 설정하는가 **무엇을 보는가** — `proxy_set_header` 목록. 랩 호스트에서 친다. ```bash label="[test-server] nginx 가 설정하는 헤더를 본다" sudo grep proxy_set_header /etc/nginx/sites-available/keycloak-lab ``` **어디를 보나** — `03-nginx` 가 세운 설정 그대로다(모양은 observed). ```text 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-*` 가 목록에 없고, 그것이 주입 결과를 전부 설명한다. ```nginx proxy_set_header X-Forwarded-Proto https; # 설정한 것 → 덮어쓴다 # X-Auth-Request-Roles 설정 없음 # 안 한 것 → 그대로 흘려보낸다 ``` nginx 는 자기가 `proxy_set_header` 로 설정한 헤더만 덮어쓴다. 설정하지 않은 헤더는 손대지 않고 통과시킨다. 「nginx 가 덮어쓴다」는 명제는 조건부이고, 그 조건이 빠지면 틀린 문장이 된다. **`sudo` 가 아무 결과도 안 주면 실패한 것이다.** 랩 호스트의 sudo 는 비밀번호를 요구한다(`sudo -n -l` → `sudo: a password is required`). 빈 출력을 「설정이 없다」로 읽지 말고 비밀번호를 넣어 다시 친다. ## 주입 주입은 둘이다. 첫째는 요청에 헤더를 붙여 보내는 것이고, 둘째는 IdP 에서 클레임을 바꾸는 것이다. 첫째는 클러스터 상태를 바꾸지 않아 되돌릴 것이 없다. 아무것도 설치하지 않고 아무 권한도 없이 `curl` 한 줄로 여기까지 간다. ### 1. 동명 헤더 두 개를 보낸다 **목적** — 같은 이름의 헤더 둘이 도착 시점에 어떻게 보이는지 만든다. ① 같은 헤더를 값만 달리해 두 번 붙인다. 가이드가 미검증으로 표시했다(unknown) — 원래 실행은 스크립트가 응답을 정리했고 아래는 같은 값을 `grep` 으로 뽑는 형태다. ```bash label="[밖에서] 동명 헤더 두 개를 보낸다" 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":\[[^]]*\]' ``` **예상 결과** — 대괄호 안에 값이 둘 들어온다. 판정은 관찰 절에서 한다. **왜 필요한가** — 덮어쓰는지, 합치는지, 통과시키는지 셋 중 어느 것인지가 여기서 갈린다. **문제가 생기면** — 하나만 온 것처럼 보이면 `tr ',' '\n'` 으로 자르지 않았는지 본다. ### 2. 값 안의 쉼표를 보낸다 **목적** — 구분자로 쓰는 쉼표와 값에 들어간 쉼표를 도착 시점에 구별할 수 있는지 만든다. ① 쉼표로 구분한 값 하나와 값 안에 쉼표가 든 값 하나를 각각 보낸다. ```bash label="[밖에서] 쉼표 구분과 값 안 쉼표를 각각 보낸다" 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":\[[^]]*\]' ``` **예상 결과** — 두 줄이 같은 모양으로 온다. **왜 필요한가** — role 이름에 쉼표가 들어갈 수 있다면 쉼표로 자르는 방식이 성립하지 않는다. **문제가 생기면** — 대괄호째 뽑았는지 다시 본다. ### 3. 헤더를 키운다 **목적** — 크기 상한에서 무슨 일이 나는지 만든다. ① 먼저 한 번은 읽는 형태로 본다. 무엇이 돌아오는지 봐야 뒤의 숫자를 읽을 수 있다. 두 줄 다 미검증이다(unknown) — 원래 실행은 값을 파이썬으로 만들었다. ```bash label="[밖에서] ① 8000자짜리 값을 만들어 응답을 읽는다" 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 ``` ② 여러 크기를 비교할 때는 코드만 뽑는 형태로 바꾼다. ```bash label="[밖에서] ② 다섯 크기의 상태 코드만 뽑는다" for n in 1000 4000 8000 16000 32000; do V=$(head -c "$n" /dev/zero | tr '\0' 'r') curl -s -o /dev/null -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \ https://app1.hyeonworks.com/api/echo done ``` **예상 결과** — 다섯 줄이 나오고 뒤 셋이 앞 둘과 다르다. 값은 관찰 절에 있다. **왜 필요한가** — 읽는 형태를 한 번 보지 않으면 `400` 이 무엇을 돌려준 `400` 인지 모른다. 8000 에서 오는 것은 JSON 이 아니라 HTML 오류 페이지다. **문제가 생기면** — `000` 이 나와도 명령이 잘못된 것이 아니다. 그건 측정 결과다. ### 4. 신원 자체를 위조한다 **목적** — 로그인 없이 신원 헤더 세 줄만 보낸 상태를 만든다. ① 쿠키도 토큰도 없이 헤더 세 줄만 붙인다. ```bash label="[밖에서] 로그인하지 않고 신원 헤더 세 줄만 보낸다" curl -s \ -H 'X-Auth-Request-User: administrator' \ -H 'X-Auth-Request-Email: admin@example.com' \ -H 'X-Auth-Request-Roles: realm-admin,superuser' \ https://app1.hyeonworks.com/api/echo ``` **예상 결과** — echo 앱이 응답을 돌려준다. 무엇이 돌아왔는지는 주입 검증에서 읽는다. **왜 필요한가** — 여기까지가 요청만으로 되는 부분이다. 설치한 것도 받은 권한도 없다. **문제가 생기면** — 응답이 안 오면 1절의 경로 확인으로 돌아간다. ### 5. Ingress 를 빌리고 IdP 의 값을 바꾼다 **목적** — 클레임 변경이 언제 반영되는지 재려고 엣지 세션을 실제로 만든다. 그러려면 oauth2-proxy 가 필요하고, 그것이 app2 를 쓴다. ① 백업이 먼저다. 파일이 생겼는지 줄 수로 확인한 뒤에 원본을 지운다. ```bash label="[kc-lab-1] ① Grafana Ingress 를 백업하고 프록시를 올린다" kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml wc -l /tmp/grafana-ingress-backup.yaml kubectl -n observability delete ingress grafana kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s ``` ② 브라우저에서 `https://app2.hyeonworks.com/` 를 열고 `labuser` / `labpass` 로 로그인한다. **③ 을 치기 전에 변경 전 값을 먼저 잰다.** 관찰 ⑤ 의 첫 JS 블록(3회 반복)을 로그인된 app2 탭의 콘솔에서 지금 돌리고 세 줄을 적어 둔다. ③ 을 친 뒤에 재면 **세션이 스냅샷이라 변경 뒤에도 옛 값이 나오므로 두 측정이 화면에서 똑같아 보인다.** 대조군이 무너졌다는 것을 알아챌 단서가 없어서, 그대로 읽으면 「12회를 보내도 안 바뀐다」를 재지 않고 그냥 적게 된다. ③ IdP 의 email 을 바꾸고 변경 시각을 UTC 로 남긴다. ```bash label="[kc-lab-1] ③ IdP 의 email 을 바꾸고 시각을 남긴다" USER_ID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ get users -r keycloak-patterns -q username=labuser \ --fields id --format csv --noquotes | tail -1) echo "uid=$USER_ID" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ update users/$USER_ID -r keycloak-patterns -s email=CHANGED-labuser@example.com date -u '+%Y-%m-%dT%H:%M:%SZ 변경' ``` **예상 결과** — ①의 첫 줄은 이렇다(observed, `01-deploy.txt`). ```text grafana ingress 삭제 ``` **왜 필요한가** — `wc -l` 은 백업 파일이 비어 있는데 삭제부터 하는 사고를 막는다. 0 줄이면 거기서 멈추고 원본을 지우지 않는다. 파일이 생겼는지 확인하지 않고 원본을 지우는 것이 이런 작업에서 가장 흔한 사고다. **문제가 생기면** — `rollout status` 가 타임아웃이면 Ingress 를 먼저 돌려놓고 다시 시작한다. ## 주입 검증 결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 본다. **첫째 주입은 대조군 칸에 값이 나타났는가로 확인한다.** 실측은 이렇다(observed, `01-header-handling.txt`). ```text === Q4 ④ upstream 이 검증하는가 === 아무 인증 없이 보냄: x-auth-request-user ['administrator'] x-auth-request-email ['admin@example.com'] x-auth-request-roles ['realm-admin,superuser'] remoteAddr 100.123.124.30 → 그대로 도착. 검증 없음. ``` 주입 전에 비어 있던 칸에 값이 들어와 있고, `remoteAddr` 이 내 주소다 — 숨지도 않았다. 증거 파일의 `['admin', 'editor']` 같은 표기는 스크립트가 정리한 것이고, `curl` 로 직접 보면 같은 값이 JSON 배열 `["admin","editor"]` 로 온다. **여기서 「도착했다」를 「통했다」로 옮기면 틀린다.** 도착해도 아무도 안 읽으면 무해하다. 읽는 쪽이 검증을 하는지를 같은 헤더로 확인한다. ```bash label="[밖에서] 같은 위조 헤더를 세 경로에 보낸다" for p in /api/echo /api/me /api/protected; do curl -s -o /dev/null -w "$p %{http_code}\n" \ -H 'X-Auth-Request-User: administrator' \ -H 'X-Auth-Request-Roles: realm-admin' \ "https://app1.hyeonworks.com$p" done ``` 실측은 이렇다(observed, 같은 파일). ```text 대조 — JWT 를 요구하는 경로: /api/echo HTTP 200 (permitAll) /api/me HTTP 401 /api/protected HTTP 401 ``` **같은 위조 헤더인데 결과가 갈린다.** 위조 헤더가 `/api/echo` 를 열어 준 것이 아니라 거기는 원래 `permitAll` 이라 열려 있었다. `/api/me` 는 `401` 이고, 헤더로는 인증이 안 된다. 가이드는 이것을 앞선 실험과 이어 붙인다. > 2홉 실험에서 헤더 위조로 `serverName: evil.example.com` 을 만든 것과 같은 종류다. 거기서는 쿠키 속성이었지만 여기서는 신원 그 자체다. `permitAll` 과 `401` 을 가르는 설정은 backend 의 `SecurityConfig` 에 있다. ```text backend SecurityConfig: .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll() .anyRequest().authenticated() .oauth2ResourceServer(oauth2 -> oauth2.jwt(...)) ``` **둘째 주입은 IdP 쪽이 정말 바뀌었는지와 세션이 그대로인지를 같이 본다.** 바뀌지 않은 것을 「반영 안 됨」으로 읽지 않으려면 반드시 본다. ```bash label="[kc-lab-1] IdP 의 email 을 다시 읽는다" kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ get users/$UID -r keycloak-patterns --fields email ``` **`$UID` 는 손으로 `$USER_ID` 로 바꿔 친다.** 값을 담은 변수는 주입 5절 ③ 의 `USER_ID` 이고 `UID` 는 셸이 이미 쓰고 있는 읽기 전용 이름이라, 그대로 두면 `users/1000` 을 읽어 없는 사용자가 나온다. 실측은 이렇다(observed, `03-b4-role-propagation.txt`). ```text === [2] IdP 에서 email 을 바꾼다 (kubectl 출력) === 변경 시각(UTC): 2026-09-04T07:53:32.000Z IdP 의 값: [ { "email" : "changed-labuser@example.com" } ] oauth2-proxy 세션: 1 개 (그대로 살아 있다) ``` IdP 값은 바뀌었고 세션은 하나다. 이 두 줄이 있어야 다음 절의 옛 값을 「반영 안 됨」이라고 말할 수 있다. 세션 목록은 지우기 전에 항상 먼저 본다. ```bash label="[kc-lab-1] oauth2-proxy 세션 키를 본다" kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' ``` ```text _oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568초 크기=3510바이트 ``` **키 이름이 `_oauth2_proxy-` 로 시작한다.** 밑줄로 시작하고 안쪽은 밑줄이다. `'oauth2-proxy*'` 같은 패턴은 하나도 안 맞고, 그러면 세션이 없다고 오독한 뒤 이어서 지우는 명령이 조용히 아무것도 안 지운다. ## 관찰 **① 동명 헤더 둘은 덮어쓰이지도 합쳐지지도 않는다.** 실측은 이렇다(observed, `01-header-handling.txt`). ```text (b) 동명 헤더 두 개 보냄: X-Auth-Request-Roles: admin X-Auth-Request-Roles: editor 도착: ['admin', 'editor'] ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다 ``` `curl` 로 직접 보면 `"x-auth-request-roles":["admin","editor"]` 로 보인다. 셋 중 어느 것도 아니었다. | 가설 | 도착했을 모양 | 실제 | |---|---|---| | 덮어쓴다 | `["editor"]` 하나 | ✗ | | 합친다 | `["admin, editor"]` 한 문자열 | ✗ | | **통과시킨다** | **`["admin","editor"]`** | **✔** | 이 절차 앞머리에 옮긴 Q4 의 한 줄이 이 표에서 반만 남는다. 합치지 않는다는 맞았고 덮어쓴다는 틀렸다. 그 줄에는 조건이 빠져 있었다. 조건은 주입 전 4 절에서 읽은 `proxy_set_header` 여섯 줄에 그 이름이 있느냐이고, `X-Auth-Request-*` 는 거기 없었다. 엣지가 `X-Auth-Request-Roles: viewer` 를 붙여도, 공격자가 같은 헤더를 `admin` 으로 함께 보내면 둘 다 업스트림에 도착한다. ```text edge 가 붙인 것: X-Auth-Request-Roles: viewer 공격자가 보낸 것: X-Auth-Request-Roles: admin upstream 이 받는 것: ["viewer","admin"] 또는 ["admin","viewer"] └─ 프레임워크가 "첫 번째"를 고르면 순서가 권한을 정한다 ``` Spring 의 `request.getHeader()` 는 첫 번째를 돌려주고, 그 순서는 프록시가 정한다. 애플리케이션 코드 어디에도 이 결정이 안 적혀 있다. **② 값 안의 쉼표는 구분자와 구별되지 않는다.** 실측은 이렇다(observed, 같은 파일). ```text (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)가 도착 시점에 똑같이 생겼다. 둘 다 값이 하나인 배열이고 그 안에 쉼표가 있다. ```text "admin,editor,viewer" 쉼표로 자르면 → [admin, editor, viewer] 맞다 "role-with,comma" 쉼표로 자르면 → [role-with, comma] ★ 틀렸다 ``` role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다. Keycloak 의 role 이름은 임의 문자열이므로 애플리케이션이 「쉼표 쓰지 마세요」라고 정할 수 있는 조건이 아니다. | 대안 | | |---|---| | 동명 헤더 여러 개 | HTTP 가 허용하고 실제로 도착한다. 다만 위조와 구별이 안 된다 | | Base64 로 감싼 JSON 배열 | 구분자 문제가 사라진다. 대신 크기가 커진다 | | **헤더를 안 쓰고 JWT 를 넘긴다** | 서명이 있어 위조도 구분자도 해결된다 → BFF 구조 | **③ 크기는 절벽에서 떨어진다.** 8000 에서는 Tomcat 의 HTML 오류 페이지가 온다. JSON 이 아니라 HTML 이라는 것 자체가 애플리케이션까지 갔는데 파싱 전에 잘렸다는 신호다. ```text === Q4 ② 헤더 크기 상한 === 보낸 길이 1000 → HTTP 200, 도착 길이 1000 보낸 길이 4000 → HTTP 200, 도착 길이 4000 보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지) 보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김) 보낸 길이 32000 → HTTP 000 → 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다. ``` `000` 과 `400` 이 다른 값이다(observed, 같은 파일). | curl 이 찍는 값 | 뜻 | |---|---| | `400` | 응답을 받았다. 서버가 거부했다 | | `000` | 응답 자체를 못 받았다. 연결이 끊겼거나 아예 안 열렸다 | | 크기 | 누가 거부하나 | 클라이언트가 보는 것 | |---|---|---| | ~8KB | **Tomcat** (`maxHttpHeaderSize` 기본 8KB) | `400` + HTML 오류 페이지 | | ~16KB 이상 | **nginx** (`large_client_header_buffers`) | 응답 없음 / 연결 끊김 | 두 실패가 전혀 다르게 보인다. `400` 은 애플리케이션 오류처럼 보여 앱 로그를 뒤지게 하고, `000` 은 네트워크 장애처럼 보여 방화벽을 뒤지게 한다. 헤더가 커진 것은 같은데 진단이 갈린다. ```text role 이 늘어난다 → 헤더가 커진다 → 8KB 를 넘는 순간 전면 400 ``` 점진적으로 나빠지지 않는다. 그 절벽은 사용자마다 다르다 — role 이 많은 사용자만 깨지고 테스트 계정으로는 영원히 안 보인다. **④ 위조한 신원은 검증 없이 도착한다.** 주입 검증에 실은 네 줄이 그 결과이고, 같은 헤더가 `/api/me` 에서 `401` 인 것도 거기 같이 적었다. ```text JWT 경로 → 서명이 있다 → 검증할 대상이 있다 → 위조가 안 된다 헤더 경로 → 서명이 없다 → 검증할 대상이 없다 → ★ 위조를 구별할 방법이 없다 ``` `request.getHeader("X-Auth-Request-User")` 는 그 값이 어디서 왔는지 모른다. 엣지가 붙였는지 클라이언트가 붙였는지 구별할 정보가 값 안에 없다. Q4 가 확인한 사실로 적어 둔 다른 한 줄은 그대로 성립했다. > *"upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을 검증할 방법이 없다"* **⑤ 클레임 변경은 요청 횟수로는 반영되지 않는다.** 먼저 바꾸기 전 값을 브라우저에서 잰다 — **아래 첫 블록은 주입 5절 ③ 을 치기 전에 돌린다.** 여기까지 읽고 나서 처음 돌리면 이미 email 을 바꾼 뒤라 「변경 전」을 잰 것이 아니다. `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]); } ``` ```text === [1] 기준선 — 변경 전 (브라우저 fetch) === 2026-09-04T07:51:23.862Z req#1 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser 2026-09-04T07:51:24.304Z req#2 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser 2026-09-04T07:51:24.722Z req#3 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser ``` 바꾼 뒤 0.5초 간격으로 12번 반복한다. ```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)); } ``` 실측은 이렇다(observed, `03-b4-role-propagation.txt`). ```text === [3] 변경 후 12회 반복 (브라우저 fetch) === 2026-09-04T07:51:56.300Z req#1 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:51:56.864Z req#2 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:51:57.489Z req#3 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:51:58.018Z req#4 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:51:58.602Z req#5 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:51:59.217Z req#6 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:51:59.743Z req#7 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:52:00.342Z req#8 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:52:00.964Z req#9 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:52:01.574Z req#10 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:52:02.187Z req#11 HTTP 200 x-forwarded-email=labuser@example.com 2026-09-04T07:52:02.719Z req#12 HTTP 200 x-forwarded-email=labuser@example.com → 12회 · 약 6.4초 동안 전부 옛 값. 요청 횟수로는 반영되지 않는다. ``` 12줄이 전부 같다. 몇 번째 요청부터 반영되는지를 물었는데 답은 요청으로는 안 된다는 것이고, 요청 횟수가 아니라 세션의 나이가 정한다. 가이드가 적은 값은 **12회 · 약 6.4초**다. **이 결론은 시계를 보정해야 성립한다.** 실측은 이렇다(observed, 같은 파일). ```text === [시계 보정] 두 시계가 다르다 — 해석에 필요하다 === 개발 머신(브라우저 fetch 의 타임스탬프): 2026-09-04T07:52:20Z test-server (kubectl 출력의 타임스탬프): 2026-09-04T07:54:07Z → test-server 가 약 107초 앞선다. 브라우저 07:51:56 = 서버 07:53:43 이므로, 아래 12회는 변경(07:53:32) 11초 뒤다. ``` D 층의 「106초」와 이 「약 107초」는 같은 왜곡을 두 번 쟀다. 둘 다 `test-server` 가 dev 머신보다 앞선 양을 말한다. 여기서는 타임스탬프 둘의 차(`07:54:07 − 07:52:20`)로 어림했고, D-4a 에서는 ACME 응답을 제3의 기준으로 두고 다시 쟀다. 보정에 쓸 값은 D-4a 의 106초이고 여기 107초는 이 절의 12회를 읽기 위한 어림이다. 두 값을 섞어 빼지 않는다. 보정 전에는 12회의 타임스탬프(`07:51:56~`)가 변경 시각(`07:53:32`)보다 앞서 보인다. 그대로 읽으면 변경 전에 잰 것이 되어 결론이 통째로 무너진다. 보정하면 12회는 변경 11초 뒤이고, 그래야 변경 후에도 옛 값이라는 말이 선다. 자기 환경의 어긋남은 `date -u '+%Y-%m-%dT%H:%M:%SZ'` 와 브라우저 콘솔의 `new Date().toISOString()` 을 견줘서 잰다. 두 기계의 로그를 나란히 놓기 전에 시계를 확인한다 — D-4 도 이 확인을 안 해서 인증서 공백을 처음에 잘못 계산했고 나중에 **38분 25초**로 정정했다. 세션을 지우고 재인증시키면 새 값이 온다. 지우기 전에 목록을 본다. 가이드가 두 번째 줄을 미검증으로 표시했다(unknown). ```bash label="[kc-lab-1] 세션을 보고 지운다" kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' \ | xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del ``` ```text === [6] 재인증 후 (브라우저 fetch) === 2026-09-04T07:53:01.121Z req#1 HTTP 200 x-forwarded-email=changed-labuser@example.com 2026-09-04T07:53:01.456Z req#2 HTTP 200 x-forwarded-email=changed-labuser@example.com 2026-09-04T07:53:01.785Z req#3 HTTP 200 x-forwarded-email=changed-labuser@example.com ``` 새 값이 나오고, 로그인 화면은 안 떴다(observed). Keycloak SSO 가 살아 있어 조용히 재인증됐다. ```text 변경 후 12회 요청(6.4초) → labuser@example.com (옛 값) 세션 삭제 후 재인증 → changed-labuser@example.com (새 값) ``` 세션은 로그인 시점의 스냅샷이다. 로그인할 때 IdP 가 준 클레임을 세션에 담고, 이후 요청은 세션에서 읽어 헤더로 내보내며 IdP 를 다시 부르지 않는다. 그래서 IdP 에서 바꿔도 세션은 모른다. 지금 구성(`--cookie-refresh` 없음)에서는 쿠키 만료(1시간) 또는 재인증까지 안 되고, `--cookie-refresh=5m` 이면 최대 5분이라고 가이드가 적는다. 다만 그 5분은 설정의 정의이지 이 실험대에서 잰 값이 아니다(unknown). 권한을 뺏는 변경이 최대 1시간 늦게 반영되므로, 즉시 반영이 필요하면 헤더 방식은 맞지 않는다. **nginx 에서 동명 헤더를 먼저 지우는 것이 그 처방이고, 이 실험대는 그 수정을 적용한 적이 없다**(unknown). 해설 문서 6절이 「남긴 것」으로 분류한 항목이고, 가이드는 「아래는 미검증이며, 적용하려면 랩 호스트에서 사람이 직접 친다」로 못박는다. 적용한다면 랩 호스트(`test-server`)에서, 백업을 먼저 뜬다. ```bash label="[test-server] 설정을 백업하고 편집기로 연다" sudo cp /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-available/keycloak-lab.b4-backup ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup sudo vi /etc/nginx/sites-available/keycloak-lab ``` `location / { ... }` 안, 기존 `proxy_set_header` 들 옆에 넣는다. ```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 는 자기가 설정하지 않은 헤더를 덮어쓰지 않으니 덮어쓰게 하려면 먼저 설정해야 하고, 붙일 값이 없을 때 설정하는 방법이 빈 문자열이다. `proxy_set_header X-Auth-Request-Roles "";` 는 nginx 에서 그 헤더를 업스트림으로 보내지 않는다는 뜻이다. 엣지가 진짜 값을 붙여야 한다면 지운 뒤에 다시 설정한다 — 순서가 반대면 클라이언트 값이 살아남는다. ```bash label="[test-server] 문법을 검사하고 reload 한다" sudo nginx -t && sudo systemctl reload nginx ``` `nginx -t` 의 마지막 줄에서 `syntax is ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 은 통과를 막지 않는다. 실패면 `&&` 가 reload 를 막아 준 것이고 지금 돌고 있는 nginx 는 옛 설정 그대로다. 고쳐졌는지는 동명 헤더 두 개를 보낸 명령을 똑같이 다시 쳐서 보고, 대조군과 같아지면(아무것도 안 나오면) 고쳐졌다. **이 실험대는 여기까지 재지 않았다**(unknown). 가이드는 판정 규칙을 하나 더 붙인다. 값이 그대로 나오면 reload 가 안 갔거나 다른 `server` 블록을 고친 것이고, reload 가 실제로 갔는지는 워커 PID 가 바뀌었는지로 본다. ```bash label="[test-server] 워커 PID 로 reload 가 갔는지 본다" systemctl status nginx --no-pager | head -20 ``` D-4a 가 같은 판정법을 인증서 갱신에 쓴다. ## 복구와 원상복구 확인표 ### 1. IdP 값을 되돌린다 **목적** — 바꿔 둔 email 을 원래 값으로 돌린다. ① 사용자 id 를 다시 잡고 값을 되돌린 뒤 같은 명령으로 읽는다. ```bash label="[kc-lab-1] email 을 되돌리고 다시 읽는다" USER_ID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ get users -r keycloak-patterns -q username=labuser \ --fields id --format csv --noquotes | tail -1) kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ update users/$USER_ID -r keycloak-patterns -s email=labuser@example.com kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ get users/$UID -r keycloak-patterns --fields email ``` **마지막 줄의 `$UID` 도 손으로 `$USER_ID` 로 바꿔 친다.** 앞에서와 같은 이유로, 셸이 이미 쓰고 있는 읽기 전용 이름을 그대로 두면 `users/1000` 을 읽어 없는 사용자가 나온다. 가이드는 이 편 전체에 대해 「명령은 같고 변수 이름만 다르다」로 적어 두었다. 아래 확인표의 IdP 행도 같이 바꿔 읽는다. **예상 결과** — `"email" : "labuser@example.com"` 이 나와야 한다. **왜 필요한가** — 되돌려도 살아 있는 세션에는 즉시 반영되지 않는다. 세션을 한 번 더 지우면 확실하다. **문제가 생기면** — `uid=` 가 빈 값이면 `--format csv --noquotes` 출력의 마지막 줄이 아닌 다른 줄을 잡은 것이다. ### 2. Grafana Ingress 를 돌려준다 **목적** — `app2` 를 잡고 있는 Ingress 를 하나로 만든다. **이 편은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 뜬다.** 같은 Grafana Ingress 를 빌리는 다른 편들(B-7·B-7a·C-1·C-2)은 `~/grafana-ingress-backup.yaml` 을 읽는다. 경로가 다르므로 여기서 빌린 채로 그 편들로 넘어가면 복구가 `no such file` 로 죽고 **Grafana 가 안 열리는 채로 끝난다.** 이어서 갈 거면 이 절을 먼저 끝내 Grafana Ingress 를 돌려놓는다. 그리고 `/tmp` 는 재부팅으로 날아가므로 이 편을 이틀에 나눠 치지 않는다. ① oauth2-proxy 것을 먼저 지우고 Grafana 것을 올린다. ```bash label="[kc-lab-1] ① Ingress 를 돌려준다" kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f /tmp/grafana-ingress-backup.yaml ``` ② 하나만 남았는지 확인한다. ```bash label="[kc-lab-1] ② app2 를 잡고 있는 Ingress 를 센다" kubectl get ingress -A | grep app2 curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/ ``` **예상 결과** — `app2` 를 잡고 있는 Ingress 가 `observability/grafana` 하나여야 한다. **왜 필요한가** — 둘이면 어느 쪽이 이길지는 컨트롤러가 정하므로 되돌린 것이 아니라 경합을 만든 것이다. oauth2-proxy Deployment 자체는 놔둬도 된다 — Ingress 만 떼면 app2 로는 안 들어가고, B-7 을 이어서 할 거라면 그편이 낫다고 가이드가 적는다. **문제가 생기면** — app2 가 Grafana 도 프록시도 아닌 것을 주면 Ingress 가 둘 다 남아 있다. | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | app2 | `kubectl get ingress -A \| grep app2` | `observability/grafana` **하나만** | | Grafana | `curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/` | Grafana 가 답한다 (`200` 또는 로그인 `302`) | | app1 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/echo` | `200` | | IdP | `… kcadm.sh get users/$UID -r keycloak-patterns --fields email` | `labuser@example.com` | | nginx | `sudo nginx -t` (호스트) | `test is successful` | | nginx 백업 | `ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup` | 되돌렸으면 지워도 된다 | | Redis | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | 로그아웃했으면 없거나, 새 세션 하나 | ## 막히면 가이드는 이 표의 증상이 전부 이 실험대가 실제로 겪었거나 그 기록에서 곧바로 따라 나오는 것이라고 적는다. | 증상 | 원인 | 확인 | |---|---|---| | 동명 헤더가 **하나만 도착한 것처럼** 보인다 | **`tr ',' '\n'` 으로 잘랐다.** 값 배열이 두 줄로 쪼개진다 | `grep -o '…\[[^]]*\]'` 로 대괄호째 뽑는다 | | `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep -o` 로 뽑거나 응답을 통째로 본다 | | `python3 -m json.tool` 을 쓰라고 되어 있다 | 해설 문서 7절의 형태다. 값 생성도 `python3 -c` 였다 | `head -c N \/dev\/zero \| tr '\0' 'r'` | | 16000 에서 `000` 이 나온다 | **오류가 아니라 측정 결과다.** nginx 가 연결을 끊는다 | `400`(Tomcat)과 `000`(nginx)을 구별한다 | | `sudo grep` 이 빈 결과 | **호스트 sudo 는 비밀번호를 요구한다** | `sudo -n -l` 로 확인 | | Redis 에서 세션이 안 보인다 | 패턴이 틀렸다. 키는 **`_oauth2_proxy-`** 로 시작한다 | 먼저 `--scan` 만 쳐서 이름을 눈으로 본다 | | `xargs … del` 이 아무것도 안 지운다 | 같은 원인. 패턴이 안 맞으면 **조용히** 0건 | 목록 개수와 `del` 반환 개수를 대조 | | `curl -b` 로 로그인 상태가 재현이 안 된다 | **쿠키가 `HttpOnly` 다.** 꺼낼 수 없다 | 브라우저 콘솔에서 잰다 | | 12회가 **변경 시각보다 앞서** 보인다 | **두 시계가 107초 어긋나 있었다** | 보정값을 먼저 잰다 | | 값이 안 바뀐다 | **버그가 아니다.** 세션이 새로 만들어져야 한다 | `--cookie-refresh` | | app2 가 Grafana 도 프록시도 아닌 것을 준다 | Ingress 가 **둘 다 남아 있다** | `get ingress -A \| grep app2` | | `/api/me` 가 `200` 이다 | 위조가 통한 것이 **아니라** 진짜 JWT 를 보낸 것이다 | 헤더만 보냈는지 다시 본다 | ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 14:23 KST`(①②④)와 `07:51–07:53 UTC`(③)에 돈 실행에서 나왔다(observed). - (observed) 동명 헤더 두 개가 `['admin', 'editor']` 로 둘 다 도착한 것, 쉼표 구분 (a)와 값 안 쉼표 (c)가 도착 시점에 구별되지 않는 것, 크기 훑기 다섯 줄(`1000`·`4000` 은 `200`, `8000` 은 `400`, `16000`·`32000` 은 `000`), 인증 없이 보낸 위조 신원 세 줄과 `remoteAddr` `100.123.124.30`, JWT 를 요구하는 경로의 `/api/echo 200` · `/api/me 401` · `/api/protected 401`, IdP 변경 시각 `2026-09-04T07:53:32.000Z` 와 바뀐 값, 변경 후 12회의 타임스탬프와 값 전부, 두 시계가 약 107초 어긋난 것, 세션 삭제 후 3회의 새 값, `_oauth2_proxy-f6a9201fd534a047998278452001ccbf` 의 `ttl=3568초` · `크기=3510바이트`. - **경로를 혼동하지 않는다**(observed) — 위조 헤더를 잰 곳은 `app1.hyeonworks.com/api` 의 echo 앱이고 그 경로는 `permitAll` 이라 oauth2-proxy 를 거치지 않는다. 헤더가 도착한 것과 인가가 뚫린 것은 다른 사건이고, 같은 헤더를 `/api/me` 에 보내면 `401` 이다. - (unknown) `proxy_set_header X-Auth-Request-* "";` 수정. **이 실험대는 그것을 적용한 적이 없다.** 가이드가 「적용하려면 랩 호스트에서 사람이 직접 친다」로 못박았고, 적용한 뒤 다시 재는 절도 미검증이다. 그러므로 위조가 막히는지는 이 문서 어디에도 측정으로 없다. - (unknown) `grep -o` 로 헤더 배열을 뽑는 줄들, `head -c N /dev/zero | tr '\0' 'r'` 로 긴 값을 만드는 줄, 크기 훑기 루프, `xargs -r … redis-cli del` 로 세션을 지우는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행은 스크립트와 `python3 -c` 를 썼다. - (unknown) `--cookie-refresh=5m` 을 켰을 때의 「최대 5분」. 설정의 정의이지 이 실험대에서 잰 값이 아니다. - 이 실험이 재지 않은 것 — `X-Auth-Request-Roles` 자체의 반영 시점은 재지 않았다. role 을 헤더로 내보내려면 추가 설정이 필요해 `x-forwarded-email` 로 대체했고, 클레임 변경이 언제 반영되는가는 어느 클레임이든 같다는 것이 그 근거다. 업스트림의 내부 credential 검증을 공통 경계로 옮기는 것도 코드 변경이라 이 실험 밖이다.