From 7dc0a3e5dae0a0dfecc47dcf510635744fa56ef5 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 4 Sep 2026 14:23:41 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20B-4=20=E2=80=94=20the=20edge=20does=20n?= =?UTF-8?q?ot=20overwrite=20the=20headers=20it=20never=20sets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two headers of the same name both arrive rather than one overwriting the other, because nginx only replaces headers it sets with proxy_set_header. A comma inside a role name is indistinguishable from the delimiter, and the size limit is a cliff: Tomcat returns 400 around 8KB and the connection dies around 16KB, so the same cause produces two different-looking failures. Forged identity headers reach the upstream untouched while the JWT-protected paths return 401, which is Q4's own point that a header-fed upstream has nothing to verify against. By Q4's checklist that answer alone points at the BFF structure. Co-Authored-By: Claude Opus 5 --- .../01-header-handling.txt | 40 +++ docs/evidence/b4-edge-authorization/README.md | 14 + .../experiment-b4-edge-authorization-scope.md | 247 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 docs/evidence/b4-edge-authorization/01-header-handling.txt create mode 100644 docs/evidence/b4-edge-authorization/README.md create mode 100644 docs/experiment-b4-edge-authorization-scope.md diff --git a/docs/evidence/b4-edge-authorization/01-header-handling.txt b/docs/evidence/b4-edge-authorization/01-header-handling.txt new file mode 100644 index 0000000..1d15f6f --- /dev/null +++ b/docs/evidence/b4-edge-authorization/01-header-handling.txt @@ -0,0 +1,40 @@ +=== Q4 ① 다중 값 role — 구분자와 동명 헤더 === +(a) 쉼표 구분 한 개 헤더 + 보냄: X-Auth-Request-Roles: admin,editor,viewer + 도착: ['admin,editor,viewer'] ← 문자열 하나 그대로 + +(b) 동명 헤더 두 개 + 보냄: X-Auth-Request-Roles: admin + X-Auth-Request-Roles: editor + 도착: ['admin', 'editor'] ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다 + +(c) 값 안에 구분자가 들어간 경우 + 보냄: X-Auth-Request-Roles: role-with,comma + 도착: ['role-with,comma'] ← (a) 와 구별 불가 + +=== Q4 ② 헤더 크기 상한 === + 보낸 길이 1000 → HTTP 200, 도착 길이 1000 + 보낸 길이 4000 → HTTP 200, 도착 길이 4000 + 보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지) + 보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김) + 보낸 길이 32000 → HTTP 000 + + → 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다. + +=== 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 + → 그대로 도착. 검증 없음. + + 대조 — JWT 를 요구하는 경로: + /api/echo HTTP 200 (permitAll) + /api/me HTTP 401 + /api/protected HTTP 401 + + backend SecurityConfig: + .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll() + .anyRequest().authenticated() + .oauth2ResourceServer(oauth2 -> oauth2.jwt(...)) diff --git a/docs/evidence/b4-edge-authorization/README.md b/docs/evidence/b4-edge-authorization/README.md new file mode 100644 index 0000000..0447db6 --- /dev/null +++ b/docs/evidence/b4-edge-authorization/README.md @@ -0,0 +1,14 @@ +# B-4 — Edge 인가 범위 증거 + +2026-09-04 15:25–15:35 KST +해설: [`docs/experiment-b4-edge-authorization-scope.md`](../../experiment-b4-edge-authorization-scope.md) + +| 파일 | 무엇을 보여주는가 | +|---|---| +| `01-header-handling.txt` | ① 동명 헤더가 **둘 다 도착**(`['admin','editor']`)하고 값 안의 쉼표를 구분자와 구별할 수 없다 · ② 8KB 에서 Tomcat 400, 16KB 에서 연결 끊김 — **자르지 않고 거부** · ④ 위조 신원 헤더가 그대로 도착, JWT 경로는 401 | + +## 핵심 세 줄 + +1. **Q4 의 「nginx 가 동명 헤더를 덮어쓴다」는 조건부다.** nginx 는 자기가 `proxy_set_header` 한 헤더만 덮어쓰고, 나머지는 통과시킨다 — 지금 `X-Auth-Request-*` 는 통과한다. +2. **크기는 절벽이다.** 점진적으로 나빠지지 않고 8KB 에서 전면 400 이 되며, role 이 많은 사용자만 깨진다. +3. **헤더를 인가 근거로 쓰면 위조 가능성이 곧 권한 상승이다.** 2홉 실험의 결론이 여기서는 신원 자체에 적용된다. diff --git a/docs/experiment-b4-edge-authorization-scope.md b/docs/experiment-b4-edge-authorization-scope.md new file mode 100644 index 0000000..1c50e14 --- /dev/null +++ b/docs/experiment-b4-edge-authorization-scope.md @@ -0,0 +1,247 @@ +# 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) + +--- + +## 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 의 주제와 겹친다.** + +--- + +## 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-*` 를 **명시적으로 덮어쓴다** |