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

12 KiB
Raw Blame History

B-4 — 인가를 Edge 에 어디까지 둘 것인가 → Q4

브랜치 feature/keycloak-b4-edge-authorization-scope · 증거 docs/evidence/b4-edge-authorization/ · 2026-09-04 15:2515:35 KST

선행: two-hop-proxy-header-contract.md — 헤더 신뢰 경계

대응 질문Q4 · Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가


구조

B-4 구조 — 설정하지 않은 헤더는 통과한다

다이어그램 규약은 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 변경 반영 시점 측정 완료 → 후속 문서 §3. 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 의 헤더 처리는 조건부다

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-*명시적으로 덮어쓴다