An independent audit found ten documents printing values their evidence files do not contain. C-1 printed a session count of 0 where the evidence says 4, C-2 printed a success readback for a command that exited 1, and A-1 credited the conntrack flush with a split that the timestamps attribute to a pod restart four seconds earlier. Also measured wal_writer_delay, which A-3 had asserted as matching without ever querying it, relabelled the A-6 control that moved 41 percent, noted A-8's nine-sample resolution, corrected D-1's RTO to the 41 seconds its own timeline shows, and added a correction banner to D-2. Every experiment document now links its evidence files with their real collection times, and the duplicate screenshots are documented as duplicates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
B-4 — 인가를 Edge 에 어디까지 둘 것인가 → Q4
브랜치 feature/keycloak-b4-edge-authorization-scope ·
증거 docs/evidence/b4-edge-authorization/ ·
2026-09-04 15:25–15:35 KST
선행: two-hop-proxy-header-contract.md — 헤더 신뢰 경계
대응 질문 — Q4 · Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
구조
다이어그램 규약은
diagrams/_style.md. 실험대 전체 구조는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 는
같은 이름의 헤더가 여러 번 오는 것을 허용한다.
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
.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 의 헤더 처리는 조건부다
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/README.md.
7. 재현 절차 (명령어)
# ① 동명 헤더 — 덮어쓰는가 합치는가 통과시키는가
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-* 를 명시적으로 덮어쓴다 |