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>
311 lines
12 KiB
Markdown
311 lines
12 KiB
Markdown
# 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)
|
||
|
||
---
|
||
|
||
## 구조
|
||
|
||

|
||
|
||
> 다이어그램 규약은 [`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-*` 를 **명시적으로 덮어쓴다** |
|