docs: B-4 — the edge does not overwrite the headers it never sets
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b16e1dccf7
commit
7dc0a3e5da
@@ -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(...))
|
||||
@@ -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홉 실험의 결론이 여기서는 신원 자체에 적용된다.
|
||||
@@ -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-*` 를 **명시적으로 덮어쓴다** |
|
||||
Reference in New Issue
Block a user