--- kind: CASE slug: nginx-does-not-overwrite-a-header-it-never-sets title: nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다 topic: trust-handed-over-at-the-edge topicName: 위조 신원 헤더와 로그아웃 전파 project: keycloak-session-store status: 게시 전 lastVerifiedOn: 2026-09-04 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af source: - final/document.md#선택이-코드와-흐름에-반영되는-방식-b4 assets: - key: b4-header-trust-boundary file: ../../../final/assets/b4-header-trust-boundary/b4-header-trust-boundary.svg evidence: - ../../../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:51–07: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` 같은 헤더를 읽어 누가 무엇을 할 수 있는지 정한다. 이 실험대에서 그 구조는 `nginx` → `oauth2-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` 로 자기가 설정한 이름만 덮어쓴다. 설정하지 않은 이름은 클라이언트가 보낸 값을 건드리지 않고 뒤로 넘기므로, 그 헤더를 앱이 믿으려면 프록시가 그 이름을 먼저 빈 값으로 지워야 한다. 같은 이름의 헤더를 두 개 붙여 보냈을 때도 뒤엣것이 앞엣것을 밀어내지 않았다. 보낸 두 값이 모두 앱까지 그대로 도착했다 — 덮어쓰지도 합치지도 않는다. ```nginx label="앱이 읽는 헤더 이름을 프록시에서 먼저 비운다" proxy_set_header X-Auth-Request-Roles "" ``` **이 줄은 적어만 놓고 넣어 보지 않았다.** 이 실험대의 nginx 설정에 이 줄이 들어간 적이 없고, 넣은 뒤에 위조 헤더가 사라지는지도 재지 않았다. 앞 문단의 「도착한다」는 관측이고 이 줄은 그 관측에서 따라 나오는 처방이라, 효과는 아직 재지 않았다. ![밖에서 들어온 위조 헤더가 프록시를 그대로 통과해 앱에 닿는 구성. 프록시가 그 이름을 설정할 때만 덮어쓴다.](../../../final/assets/b4-header-trust-boundary/b4-header-trust-boundary.svg) 그림에서 앱으로 들어가는 화살표는 하나뿐이다. 인증을 거친 요청이든 밖에서 헤더만 붙여 보낸 요청이든 같은 이름으로 도착하므로, 헤더를 읽어 인가하는 앱은 받은 요청만 보고 어느 쪽인지 가를 방법이 없다. 지우는 단계를 프록시에 넣어야 두 경로가 갈린다. ## 도착한 것과 인가를 뚫은 것은 다르다 같은 위조 헤더를 토큰을 검증하는 경로에도 보냈다. 결과가 갈렸다. | 어디로 보냈나 | 돌아온 것 | |---|---| | 헤더를 되돌려주는 경로 | `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 를 이렇게 엮은 한 구성이다. 다른 프록시나 다른 순서로 엮은 구조에서 같은 동작을 한다고 넓히지 않는다.