Files
document-haness/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/case/case-nginx-does-not-overwrite-a-header-it-never-sets.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

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

9.8 KiB
Raw Blame History

kind, slug, title, topic, topicName, project, status, lastVerifiedOn, sourceRevision, source, assets, evidence
kind slug title topic topicName project status lastVerifiedOn sourceRevision source assets evidence
CASE nginx-does-not-overwrite-a-header-it-never-sets nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다 trust-handed-over-at-the-edge 위조 신원 헤더와 로그아웃 전파 keycloak-session-store 게시 전 2026-09-04 cdac9b8178391311d8eca1ebc6cac15bb62d79af
final/document.md#선택이-코드와-흐름에-반영되는-방식-b4
key file
b4-header-trust-boundary ../../../final/assets/b4-header-trust-boundary/b4-header-trust-boundary.svg
../../../final/evidence/raw/b4-edge-authorization__01-header-handling.txt
../../../final/evidence/raw/followup__03-b4-role-propagation.txt

nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다

밖에서 붙인 위조 헤더가 앱까지 그대로 도착했다. nginx 는 자기가 proxy_set_header 로 설정한 이름만 덮어쓰기 때문이다. 다만 같은 위조 헤더로 JWT 를 요구하는 경로를 찔렀을 때는 401 이었다. IdP 에서 클레임을 바꿔도 12회 요청·약 6.4초 동안 옛 값이 갔다.

관계

  • 믿기 전에 그 헤더를 먼저 지운다 이 실험에서 위조 헤더가 통과한 조건을 반복 적용할 기준으로 편 것이다.
  • 백채널 로그아웃은 양쪽 다 없었다 엣지와 IdP 가 만든 인증 결과가 앱까지 가는 같은 경로를 다루고, 그쪽은 로그아웃 통지가 끊긴 경우다.

문제

Forward-Auth 구조에서 앱은 자기가 토큰을 검증하지 않고 프록시가 넣어 준 헤더를 읽어 인가한다. 그러면 그 헤더를 어디까지 믿을 수 있는지가 정해져야 하는데, 앞선 작업이 남긴 열린 질문 Q4 가 그것이었다. 설계로는 답이 나오지 않아 실제로 돌려 봐야 했다.

nginx 를 앞에 두었으니 밖에서 같은 이름의 헤더를 붙여 보내도 프록시 단계에서 덮어쓰일 것으로 봤다. 그 예측이 틀렸다.

결론

nginx 가 덮어쓰는 헤더 : proxy_set_header 로 자기가 설정한 이름만 설정하지 않은 이름 : 클라이언트가 보낸 값이 앱까지 간다 먼저 지우지 않았을 때 위조 헤더가 앱까지 도착하는가 : o 같은 위조 헤더를 JWT 를 요구하는 경로에 보냈을 때 : 401 헤더만 읽어 인가하는 앱이 위조와 정상을 구별할 수 있는가 : x 지우는 줄을 넣어 본 적 : 없다. 넣은 뒤를 재지 않았다

IdP 에서 클레임을 바꿨을 때는 이렇게 나왔다.

옛 값이 간 구간 : 12회 요청 · 약 6.4초 새 값이 온 시점 : Redis 세션을 지워 재인증시킨 뒤 cookie-refresh 설정 : 없음

검증 환경

실험대 : 베어메탈 test-server 한 대 위에 VM 두 대 test-server : Arch Linux, 12GB, WiFi only kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis 엣지 : 호스트 nginx 가 Let's Encrypt TLS 를 끝내고 traefik 으로 넘긴다 인증 프록시 : oauth2-proxy 두 replica 위조를 보낸 경로 : 도착한 헤더를 그대로 되돌려주는 echo 앱. 앞에 oauth2-proxy 가 없다 클레임 반영을 잰 경로 : oauth2-proxy 뒤에 세션을 만들어 놓고 쟀다. 쿠키가 HttpOnly 라 브라우저에서 쳤다 분석 리비전 : cdac9b8178391311d8eca1ebc6cac15bb62d79af 실행일 : 2026-09-04 14:23 KST(헤더 주입)와 07:5107:53 UTC(클레임 반영)

재현 조건

  1. 도착한 헤더를 그대로 되돌려주는 앱을 nginx 뒤에 둔다. 헤더가 어디까지 가는지를 재려는 것이라 이 경로에는 인증 프록시를 두지 않는다.

  2. nginx 설정에 X-Auth-Request-* 를 지우는 줄을 넣지 않은 상태로 둔다.

  3. 밖에서 X-Auth-Request-Roles 를 직접 붙여 인증 없이 보내고, 앱에 도착한 값을 확인한다.

  4. 같은 위조 헤더로 토큰을 검증하는 경로도 함께 찌른다. 대조군이 없으면 「도착했다」를 「통했다」로 읽는다.

  5. 클레임 반영은 따로 잰다. oauth2-proxy 를 앞에 둔 경로에서 로그인해 세션을 만든다.

  6. IdP 에서 그 사용자의 클레임을 바꾸고, 같은 세션으로 요청을 반복하면서 도착한 값이 언제 바뀌는지 센다.

  7. Redis 에서 그 세션을 지워 재인증시킨 뒤 값을 다시 확인한다.

  8. 두 기계의 시계가 어긋나 있으면 먼저 보정한다. 보정하지 않으면 변경 뒤에 잰 요청이 변경 전으로 보인다.

본문

앱은 자기가 검증하지 않은 값을 읽어 인가한다

앞선 작업이 남긴 열린 질문 넷 가운데 Q4 는 「Forward-Auth 구조에서 Application Authorization 을 어디까지 Edge 에 둘 것인가」였다. Forward-Auth 는 요청을 앱으로 넘기기 전에 프록시가 인증 결과를 헤더로 붙여 주는 구조이고, 그래서 앱은 토큰을 직접 검증하는 대신 X-Auth-Request-Roles 같은 헤더를 읽어 누가 무엇을 할 수 있는지 정한다.

이 실험대에서 그 구조는 nginxoauth2-proxy → 앱의 2홉이다. 요청이 호스트 nginx 로 들어와 oauth2-proxy 를 거쳐 앱에 닿으므로, 앱이 보는 헤더는 oauth2-proxy 가 붙인 것이라고 전제하게 된다. B-4 는 그 전제가 성립하는지 보려 했다.

그래서 경로를 둘로 나눠 쟀다. 헤더가 어디까지 가는지는 도착한 헤더를 그대로 되돌려주는 앱에서 쟀다. 그 경로에는 oauth2-proxy 가 없고 nginx 만 앞에 있어서, 도착한 값이 밖에서 온 것인지 프록시가 붙인 것인지 헷갈릴 여지가 없다. 클레임 변경이 언제 반영되는지는 oauth2-proxy 뒤에 세션을 만들어 놓고 따로 쟀다.

지우지 않은 이름은 그대로 지나간다

로그인도 하지 않고 위조한 X-Auth-Request-User · X-Auth-Request-Email · X-Auth-Request-Roles 를 붙여 요청을 보냈더니 셋 다 앱까지 그대로 도착했다. 예측한 것은 반대였다 — nginx 가 앞에 있으니 동명 헤더는 프록시 단계에서 덮어쓰일 것으로 봤다.

nginx 는 proxy_set_header 로 자기가 설정한 이름만 덮어쓴다. 설정하지 않은 이름은 클라이언트가 보낸 값을 건드리지 않고 뒤로 넘기므로, 그 헤더를 앱이 믿으려면 프록시가 그 이름을 먼저 빈 값으로 지워야 한다.

같은 이름의 헤더를 두 개 붙여 보냈을 때도 뒤엣것이 앞엣것을 밀어내지 않았다. 보낸 두 값이 모두 앱까지 그대로 도착했다 — 덮어쓰지도 합치지도 않는다.

proxy_set_header X-Auth-Request-Roles ""

이 줄은 적어만 놓고 넣어 보지 않았다. 이 실험대의 nginx 설정에 이 줄이 들어간 적이 없고, 넣은 뒤에 위조 헤더가 사라지는지도 재지 않았다. 앞 문단의 「도착한다」는 관측이고 이 줄은 그 관측에서 따라 나오는 처방이라, 효과는 아직 재지 않았다.

밖에서 들어온 위조 헤더가 프록시를 그대로 통과해 앱에 닿는 구성. 프록시가 그 이름을 설정할 때만 덮어쓴다.

그림에서 앱으로 들어가는 화살표는 하나뿐이다. 인증을 거친 요청이든 밖에서 헤더만 붙여 보낸 요청이든 같은 이름으로 도착하므로, 헤더를 읽어 인가하는 앱은 받은 요청만 보고 어느 쪽인지 가를 방법이 없다. 지우는 단계를 프록시에 넣어야 두 경로가 갈린다.

도착한 것과 인가를 뚫은 것은 다르다

같은 위조 헤더를 토큰을 검증하는 경로에도 보냈다. 결과가 갈렸다.

어디로 보냈나 돌아온 것
헤더를 되돌려주는 경로 200 · 여기는 원래 인증을 요구하지 않는다
토큰을 요구하는 두 경로 둘 다 401

서명이 붙은 토큰을 요구하는 곳은 헤더 세 줄로 열리지 않았다. 위조 헤더가 앱까지 도착하는 것과 그 헤더로 인가가 뚫리는 것은 다른 사건인데, 이 실험은 앞의 것만 관측했다. 뒤의 것은 앱이 그 헤더를 읽어 인가를 정할 때만 따라온다.

값 안에 서명이 없으니 request.getHeader("X-Auth-Request-User") 는 그 값이 프록시에서 왔는지 클라이언트에서 왔는지 모른다. 그래서 헤더로 인가하는 앱은 두 경로를 가를 정보를 아예 못 받고, 토큰으로 인가하는 앱은 검증할 것이 있어서 갈린다.

값을 바꿔도 12회 요청·약 6.4초 동안 옛 값이 갔다

클레임 반영은 oauth2-proxy 뒤에서 쟀다. IdP 에서 값을 바꾼 뒤 0.5초 간격으로 12회를 보냈는데 12회 · 약 6.4초 동안 앱에는 옛 값이 갔고, Redis 세션을 지워 재인증시킨 뒤에야 새 값이 왔다.

oauth2-proxy 세션은 로그인 시점의 스냅샷이어서 --cookie-refresh 가 없으면 요청을 몇 번 보내든 쿠키 만료나 재인증까지 옛 값이 간다. 요청 횟수로는 줄일 수 없으므로, role 이나 tenant 변경이 즉시 반영돼야 하는 시스템에서는 인가 판단을 엣지에 두는 범위가 그만큼 좁아진다.

이 실험이 재지 않은 것

프록시에서 헤더를 먼저 지우는 수정을 넣어 보지 않았다. 넣으면 위조 헤더가 앱에 안 닿는다는 것은 nginx 의 동작에서 따라 나오는 예측이지 여기서 잰 값이 아니다.

--cookie-refresh 를 켠 구성에서는 재지 않았다. 클레임 반영 지연이 그 설정으로 얼마나 줄어드는지도 확인하지 않았다.

여기서 본 것은 호스트 nginx 와 oauth2-proxy 를 이렇게 엮은 한 구성이다. 다른 프록시나 다른 순서로 엮은 구조에서 같은 동작을 한다고 넓히지 않는다.