Files
keycloak-pattern/docs/experiment-b4-edge-authorization-scope.md
T
DongHyeonkaandClaude Opus 5 9dbee18a42 docs(b4): item 3 is no longer unmeasured — link it to the follow-up result
B-4 left role propagation open because oauth2-proxy was not deployed yet.
B-7 deployed it and the follow-up measured it: the value does not change
with request count, only when a new session is created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:10:43 +09:00

311 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# B-4 — 인가를 Edge 에 어디까지 둘 것인가 → Q4
브랜치 `feature/keycloak-b4-edge-authorization-scope` ·
증거 [`docs/evidence/b4-edge-authorization/`](evidence/b4-edge-authorization/) ·
2026-09-04 15:2515: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 변경 반영 시점 | **측정 완료 →** [후속 문서 §3](experiment-followup-untested-items.md). IdP 에서 값을 바꿔도 **12회 요청·6초 동안 옛 값**, 세션 삭제 후 재인증에서야 새 값 |
| ⑤ internal token 을 공통 경계로 이동 | **코드 변경.** `backend/` 의 SecurityConfig 에서 `permitAll` 경로를 좁히고 Filter 로 옮기는 작업 |
| edge 에서 동명 헤더 덮어쓰기 | **nginx 설정 변경 필요**`proxy_set_header X-Auth-Request-Roles ""` 로 먼저 지우고 다시 설정 |
**③ 은 oauth2-proxy 배포가 선행이었고, 그것이 B-7 의 주제와 겹쳤다.**
B-7 에서 oauth2-proxy 를 올린 뒤 후속 작업으로 측정했다 — 결론은
**"요청 횟수와 무관하다. 세션이 새로 만들어져야 한다"** 이다.
세션은 로그인 시점의 스냅샷이고, `--cookie-refresh` 가 없으면 갱신되지 않는다.
---
---
## 개념
### 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-*`**명시적으로 덮어쓴다** |