# B-4 — 인가를 Edge 에 어디까지 둘 것인가 → Q4 브랜치 `feature/keycloak-b4-edge-authorization-scope` · 증거 [`docs/evidence/b4-edge-authorization/`](evidence/b4-edge-authorization/) · 2026-09-04 15:25–15:35 KST 선행: [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) — 헤더 신뢰 경계 **대응 질문** — [Q4 · Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가](https://hyeonworks.com/questions/edge-authorization-scope) --- ## 구조 ![B-4 구조 — 설정하지 않은 헤더는 통과한다](diagrams/b4-header-forgery.svg) > 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md). > 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg). --- ## 0. 결론부터 | Q4 의 미지수 | 측정 결과 | |---|---| | ① 다중 값 구분자·escaping | **값 안의 쉼표와 구분자를 구별할 수 없다.** 동명 헤더는 **둘 다 도착한다** | | ② 크기 상한 초과 시 | **자르지 않고 거부한다.** 거부 계층이 둘이고 증상이 다르다 (400 / 연결 끊김) | | ③ role 변경 반영 시점 | **아래 4절** | | ④ upstream 이 값을 검증하는가 | **아무것도 검증하지 않는다.** 위조 헤더가 그대로 도착한다 | **그리고 Q4 가 「확인한 사실」로 적어둔 것 하나가 측정과 어긋났다.** --- ## 1. Q4 의 전제 하나를 정정한다 > Q4 확인한 사실: *"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다."* 측정하면 그렇지 않다. ``` 보냄: X-Auth-Request-Roles: admin X-Auth-Request-Roles: editor 도착: ['admin', 'editor'] ← 둘 다 살아서 도착했다 ``` ### 왜 어긋나는가 — 조건이 빠져 있다 **nginx 는 자기가 `proxy_set_header` 로 설정한 헤더만 덮어쓴다.** 설정하지 않은 헤더는 **손대지 않고 그대로 흘려보낸다.** 그리고 HTTP 는 같은 이름의 헤더가 여러 번 오는 것을 허용한다. ```nginx proxy_set_header X-Forwarded-Proto https; # ← 이건 덮어쓴다 (2홉 실험에서 확인) # X-Auth-Request-Roles 에 대한 설정이 없다 # ← 이건 통과한다 ``` > **"nginx 가 덮어쓴다"는 명제는 조건부다.** > 덮어쓰려면 **그 헤더를 명시적으로 설정해야 한다.** > Q4 의 제약 *"전달할 헤더는 allowlist 로 해야 하고 client 가 보낸 동명 헤더는 > 항상 덮어써야 한다"* 는 옳고, **지금은 그렇게 되어 있지 않다.** ### 보안적 함의 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. 구분자 문제 → Q4 ① ``` (a) X-Auth-Request-Roles: admin,editor,viewer → 도착 ['admin,editor,viewer'] (c) X-Auth-Request-Roles: role-with,comma → 도착 ['role-with,comma'] ``` **(a) 와 (c) 가 도착 시점에 구별되지 않는다.** ``` "admin,editor,viewer" 쉼표로 자르면 → [admin, editor, viewer] 맞다 "role-with,comma" 쉼표로 자르면 → [role-with, comma] ★ 틀렸다 ``` **role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다.** Keycloak 의 role 이름은 임의 문자열이므로 **막을 수 있는 것이 아니다.** | 대안 | | |---|---| | 동명 헤더 여러 개 | HTTP 가 허용하고 실제로 도착한다. **다만 위조와 구별이 안 된다** | | Base64 로 감싼 JSON 배열 | 구분자 문제가 사라진다. 대신 크기가 커진다 (②) | | **헤더를 안 쓰고 JWT 를 넘긴다** | 서명이 있어 위조도 구분자도 해결된다 → **BFF 구조** | **세 번째가 Q4 가 도달하려는 결론이다.** --- ## 3. 크기 상한 → Q4 ② ``` 1000 → 200, 도착 1000 4000 → 200, 도착 4000 8000 → 400 (Tomcat 의 HTML 오류 페이지) 16000 → 000 (응답 자체를 못 받음) 32000 → 000 ``` **자르지 않는다. 거부한다.** 그리고 **거부하는 계층이 둘**이다. | 크기 | 누가 거부하나 | 클라이언트가 보는 것 | |---|---|---| | ~8KB | **Tomcat** (`maxHttpHeaderSize` 기본 8KB) | `400` + HTML 오류 페이지 | | ~16KB 이상 | **nginx** (`large_client_header_buffers`) | **응답 없음 / 연결 끊김** | > **두 실패가 전혀 다르게 보인다.** 앞의 것은 애플리케이션 오류처럼, > 뒤의 것은 네트워크 장애처럼 보인다. **원인은 같은데 진단이 갈린다.** ### 실무적 의미 ``` role 이 늘어난다 → 헤더가 커진다 → 8KB 를 넘는 순간 전면 400 ``` **점진적으로 나빠지지 않고 절벽에서 떨어진다.** 그리고 그 절벽은 **사용자마다 다르다** — role 이 많은 사용자만 깨진다. **Q4 의 가정** *"헤더 종류가 늘어나면 정해야 할 계약도 늘어난다"* 는 크기에서도 성립하며, **한계가 있다**는 것이 이 측정이다. --- ## 4. upstream 은 아무것도 검증하지 않는다 → Q4 ④ 인증 없이 신원 헤더를 위조해 보냈다. ``` x-auth-request-user ['administrator'] x-auth-request-email ['admin@example.com'] x-auth-request-roles ['realm-admin,superuser'] remoteAddr 100.123.124.30 ``` **그대로 도착했다.** 대조 — JWT 를 요구하는 경로는 막힌다. ``` /api/echo HTTP 200 ← permitAll /api/me HTTP 401 /api/protected HTTP 401 ``` ```java .requestMatchers("/actuator/health", "/api/public", ...).permitAll() .anyRequest().authenticated() .oauth2ResourceServer(oauth2 -> oauth2.jwt(...)) ``` **JWT 경로는 서명을 검증하므로 위조가 안 된다. 헤더 경로는 검증할 대상이 없다.** > Q4 확인한 사실 — *"upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을 > 검증할 방법이 없다."* **정확하다. 그리고 그것이 이 구조의 본질적 한계다.** > > 2홉 실험에서 **헤더 위조로 `serverName: evil.example.com` 을 만든 것과 같은 > 종류**다. 거기서는 쿠키 속성이었지만 **여기서는 신원 그 자체다.** ### 그래서 세 곳이 독립적으로 필요하다 2홉 실험의 결론이 그대로 적용된다. | 필요한 것 | 지금 상태 | |---|---| | ① 외부에서 upstream 으로 **직접 가는 경로 차단** | NetworkPolicy 패턴 확립됨 (2홉 실험) | | ② edge 에서 **동명 헤더 덮어쓰기** | **★ 안 되어 있다** (1절) | | ③ upstream 에서 **내부 credential 검증** | **★ controller 한 곳에만 있다** (Q4 제약) | **셋 중 하나라도 빠지면 나머지 둘이 무의미하다.** --- ## 5. Q4 의 설계 판단 5문항 — 측정에 근거해 답한다 > *2번부터 5번 중 하나라도 그렇다면 헤더를 늘리기보다 BFF 구조로 구성하자.* | # | 질문 | 이 실험이 주는 답 | |---|---|---| | 1 | 전달할 claim 이 계속 늘어나는가 | **늘면 8KB 절벽이 있다** (3절). 크기가 상한을 정한다 | | 2 | role·tenant 변경이 **즉시 반영**돼야 하는가 | 헤더는 **edge 가 세션을 갱신할 때까지 옛 값**이다 | | 3 | 정책이 애플리케이션 **도메인을 알아야** 하는가 | 안다면 edge 가 도메인을 알아야 하고, **경계가 무너진다** | | 4 | 헤더 값이 **인가 판단의 근거**가 되는가 | **★ 그렇다면 위조 가능성이 곧 권한 상승이다** (4절) | | 5 | **서비스별 정책 차이**가 커지는가 | edge 설정이 서비스 수만큼 늘어난다 | **4번이 이 실험에서 가장 무겁다.** 헤더를 인가 근거로 쓰는 순간, **헤더 신뢰 경계 세 곳이 모두 완전해야만** 안전하다. 하나라도 새면 **인증 우회가 아니라 권한 상승**이다. > **결론 — 2·4번이 해당하므로 Q4 자신의 기준에 따라 BFF 구조가 맞다.** > 그리고 이 실험대에는 이미 BFF(B-0~B-3)가 있다. 두 구조를 같은 > 실험대에서 비교할 수 있는 상태다. --- ## 6. 남긴 것 | 항목 | 상태 | |---|---| | ③ role 변경 반영 시점 | **미측정.** oauth2-proxy 가 없어 "proxy session" 이 존재하지 않는다 | | ⑤ internal token 을 공통 경계로 이동 | **코드 변경.** `backend/` 의 SecurityConfig 에서 `permitAll` 경로를 좁히고 Filter 로 옮기는 작업 | | edge 에서 동명 헤더 덮어쓰기 | **nginx 설정 변경 필요** — `proxy_set_header X-Auth-Request-Roles ""` 로 먼저 지우고 다시 설정 | **③ 은 oauth2-proxy 배포가 선행이며, 그것은 B-7 의 주제와 겹친다.** --- --- ## 개념 ### nginx 의 헤더 처리는 조건부다 ```nginx proxy_set_header X-Forwarded-Proto https; # 설정한 것 → 덮어쓴다 # X-Auth-Request-Roles 설정 없음 # 안 한 것 → 통과시킨다 ``` HTTP 는 **같은 이름의 헤더가 여러 번 오는 것을 허용**하므로, edge 가 붙인 것과 클라이언트가 보낸 것이 **함께 도착**한다. Spring 의 `request.getHeader()` 는 **첫 번째**를 돌려주고, 그 순서는 프록시가 정한다. ### 헤더 크기 한계는 계층마다 다르다 | 크기 | 누가 거부하나 | 클라이언트가 보는 것 | |---|---|---| | ~8KB | **Tomcat** (`maxHttpHeaderSize`) | `400` + HTML | | ~16KB | **nginx** (`large_client_header_buffers`) | 응답 없음 | **같은 원인이 두 가지로 보인다.** 그리고 점진적이 아니라 절벽이며, **role 이 많은 사용자만** 깨진다. ### 세 곳이 독립적으로 필요하다 ``` ① 외부 → upstream 직접 경로 차단 (NetworkPolicy) ② edge 에서 동명 헤더 덮어쓰기 (proxy_set_header) ③ upstream 에서 내부 credential 검증 (공통 경계) ``` **하나라도 빠지면 나머지 둘이 무의미하다.** 2홉 실험의 결론이 그대로 적용되며, 거기서는 쿠키 속성이었지만 **여기서는 신원 자체**다. --- --- ## 증거 파일 **증거 수집 시각: 2026-09-04 14:23 – 14:23 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.) | 파일 | 종류 | |---|---| | [`01-header-handling.txt`](evidence/b4-edge-authorization/01-header-handling.txt) | 터미널 원문 | 파일별 상세는 [`evidence/b4-edge-authorization/README.md`](evidence/b4-edge-authorization/README.md). ## 7. 재현 절차 (명령어) ```bash # ① 동명 헤더 — 덮어쓰는가 합치는가 통과시키는가 curl -s -H "X-Auth-Request-Roles: admin" -H "X-Auth-Request-Roles: editor" \ https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -A3 roles # ② 크기 상한 — 어디서 어떻게 깨지는가 for n in 1000 4000 8000 16000; do V=$(python3 -c "print('r'*$n)") curl -s -o /tmp/o -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \ https://app1.hyeonworks.com/api/echo done # ④ 위조가 통하는가 curl -s -H "X-Auth-Request-User: administrator" \ -H "X-Auth-Request-Roles: realm-admin" \ https://app1.hyeonworks.com/api/echo # 대조 — JWT 를 요구하는 경로 curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/me ``` --- ## 8. 다음 실험에 남기는 것 | 실험 | 이 실험이 준 것 | |---|---| | **B-7** oauth2-proxy | ③(반영 시점)을 재려면 proxy session 이 있어야 한다 | | **C-1** SSO | 헤더 기반과 BFF 기반이 **SSO 에서 어떻게 다른가** | | 코드 | `permitAll` 을 좁히고 internal token 검증을 **공통 경계**로 옮긴다 (Q4 제약) | | 설정 | nginx 에서 `X-Auth-Request-*` 를 **명시적으로 덮어쓴다** |