Files
DongHyeonkaandClaude Opus 5 ab59130196 chore: 이전 세션이 남긴 변경을 커밋한다
이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 —
final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인
final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다.
삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다.

그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개,
writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물,
scripts/check-ssot-facts.py 와 그 시험이 들어 있다.

이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:02:02 +09:00

21 KiB

id, kind, slug, title, topic, topicName, project, status, version, verifiedOn, studio, public, assets, sourceRevision, source
id kind slug title topic topicName project status version verifiedOn studio public assets sourceRevision source
a0e1cc05-92b3-4dac-bce1-513ab8cd862b CASE identity-header-trust Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유 oauth-oidc-auth-boundary OAuth/OIDC 인증 경계 KeyCloak Patterns 게시 중 42 2026-08-25 https://hyeonworks.com/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit https://hyeonworks.com/cases/identity-header-trust
key file
ap4-edge-trust-architecture ../../../final/assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.svg
key file
ap4-edge-forward-auth-flow ../../../final/assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.svg
keycloak-patterns-lab@2026-08
final/document.md#검토한-선택지와-막힌-지점-ap4
final/document.md#선택의-이유와-지킨-경계-ap4
final/document.md#선택이-코드와-흐름에-반영되는-방식-ap4-완주
final/document.md#결정이-지켜지는지-확인하는-방법-ap4
final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4

Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유

앞단에 세운 프록시가 로그인을 대신 받으면, 업스트림은 요청에 붙어 온 X-Auth-Request-User 헤더 하나만 보고 누가 보낸 요청인지 정한다. 이 헤더는 인증을 마친 프록시가 붙일 수도 있고 브라우저가 직접 적어 보낼 수도 있는데, 업스트림에 도착한 요청만으로는 둘을 가려낼 수 없다.

관계

  • Forward-Auth에서 Identity Header를 신뢰하기 위한 조건 그 기준이 세운 다섯 조건이 Nginx 설정과 업스트림 코드의 어디에 들어가 있는지 하나씩 확인했다.
  • OAuth Token과 Application Session을 구분하는 기준 AP4_SESSION은 브라우저와 Nginx 사이에서만 오가고 사용자 정보 헤더는 Nginx와 업스트림 사이에서만 붙으며, 업스트림은 JWT를 입력으로 받지 않는다.
  • OAuth/OIDC 인증 패턴 선택 기준 이 구조를 고르면 OAuth를 모르는 업스트림 앞에 공통 관문이 생기는 대신, 네트워크 경로와 헤더를 어디까지 믿을지를 따로 정해 두어야 한다.
  • Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가 지금 엣지는 사용자와 이메일만 넘기고 role은 넘기지 않는데, 그러면 인가를 어디서 판단할지 정해야 한다.

문제

앞단 프록시가 로그인을 맡으면 업스트림은 OAuth를 몰라도 되고, 대신 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단한다. 그러려면 업스트림은 이 헤더를 인증을 마친 엣지가 붙였는지 브라우저가 직접 적어 넣었는지 가릴 수 있어야 한다.

가릴 방법이 없다. 두 값은 업스트림이 받은 요청에서 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 곳이 없기 때문이다.

그래서 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 동명 헤더를 그대로 넘기면, 공격자가 원하는 이름을 적어 인증된 사용자처럼 요청을 보낼 수 있다.

결론

헤더를 인증 근거로 쓰려면 세 곳에서 막아야 한다.

호스트 포트 닫기 : 외부에서 업스트림과 프록시로 바로 가는 경로를 막는다 Nginx 헤더 덮어쓰기 : 클라이언트가 보낸 동명 헤더를 합치지 않고 덮어쓴다 업스트림 내부 토큰 : 엣지를 거치지 않은 내부 요청을 막는다

호스트 포트를 닫아도 같은 Compose 네트워크 안에서는 app의 8081에 닿을 수 있고 그 요청은 Nginx를 거치지 않으니, 덮어쓰기도 함께 지나친다.

이렇게 네트워크를 나눠 두는 것만으로는 내부 서비스가 보낸 요청도, Nginx가 잘못 넘긴 헤더도 걸러 내지 못한다. 공유 토큰만 두고 포트를 열어 두면, 그 토큰을 얻은 쪽이 Nginx를 건너뛰고 업스트림에 바로 붙을 수 있다.

검증 환경

Keycloak 26.7.0, oauth2-proxy 7.15.2

클라이언트 : edge-proxy confidential, PKCE S256 : o

외부 공개 Nginx : 8088 app 8081, oauth2-proxy 4180 : Compose 네트워크에 expose만, 호스트 publish x

Nginx auth_request /oauth2/auth location = /oauth2/auth : internal auth_request_set : user, email, Set-Cookie 복사 클라이언트가 보낸 동명 헤더 : 덮어쓰기 신뢰할 프록시 : 단일 IP

업스트림 EdgeIdentityController.currentUser(HttpServletRequest) X-Internal-Auth-Token 비교 : MessageDigest.isEqual SecurityConfig의 /edge/** : permitAll

AP4_SESSION HttpOnly : true SameSite : Lax Secure : false, 로컬 HTTP 학습 환경 기준 expire : 1 hour, 프록시 설정값 session-cookie-minimal : true

서버 쪽 세션 저장소 : x automatic discovery : x login, token, JWKS, userinfo URL : 각각 따로 관리

HTTP : o

재현 조건

  1. 쿠키 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인한다.

  2. 쿠키 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인한다.

  3. authorization request에 client_id=edge-proxy와 code_challenge_method=S256이 있는지 확인한다.

  4. 로그인 뒤 쿠키가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다. 브라우저 요청 목록에 Keycloak 토큰 엔드포인트가 없어야 한다. Web Storage가 비어 있어야 하고 document.cookie로 세션 쿠키를 읽을 수 없어야 한다.

  5. 정상 세션에 다음 헤더를 얹어 GET /api/edge를 보낸다. X-Auth-Request-User : spoofed-admin X-Auth-Request-Email : spoofed-admin@example.test X-Internal-Auth-Token : attacker-controlled-token

응답은 200이고, 그 안의 user는 spoofed-admin이 아니라 실제로 인증된 사용자여야 한다.

  1. 외부에서 GET /oauth2/auth를 부르면 404인지 확인한다.

  2. host의 4180과 8081에 접근할 수 없는지 확인한다.

  3. 내부에서 /edge/me를 부를 때 사용자 정보 헤더만 있거나 내부 토큰이 없거나 틀리면 401이고, 둘 다 맞으면 200인지 확인한다.

본문

forward-auth는 실제 요청을 업스트림으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 묻는 방식이고, Nginx에서는 auth_request 디렉티브가 그 질문을 하위 요청(subrequest)으로 만든다.

같은 이름의 헤더가 두 곳에서 만들어진다

외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처.

로그인과 세션 검증은 앞단에 세운 oauth2-proxy가 맡는다. 이렇게 로그인을 대신 받는 관문을 엣지라고 부른다. 밖에서 오는 요청은 Nginx가 받고, Nginx는 oauth2-proxy에 세션이 유효한지 물어본 결과를 업스트림 요청에 연결한다. 브라우저가 직접 부를 수 있는 주소는 Nginx뿐이고, oauth2-proxy와 Spring 업스트림은 같은 배포 안에서만 부를 수 있다. 업스트림은 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단한다. 이 헤더는 oauth2-proxy가 확인한 로그인 사용자의 이름을 담아 엣지가 업스트림 요청에 붙이는 값이고, 아래에서는 엣지가 이렇게 만들어 붙이는 값을 사용자 정보 헤더라고 부른다.

같은 이름의 헤더는 브라우저도 직접 적어 보낼 수 있다. 업스트림이 받는 요청에서 두 값은 이름도 형식도 같고 어느 쪽이 붙였는지 적힌 곳도 없어서, 업스트림은 X-Auth-Request-User가 엣지에서 온 값인지 브라우저가 적어 넣은 값인지 가리지 못한다. 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 요청을 보낼 수 있다.

이 구조를 고른 이유는 업스트림에 OAuth 코드를 넣기 어려워서였다. 사용자별 API를 조합하고 세밀한 인가까지 애플리케이션이 직접 맡아야 하는 경우였다면 그 조합을 백엔드가 맡는 구조가 더 자연스럽다. 업스트림을 거의 고치지 않으려고 앞단에 관문을 세웠는데, 세우고 나니 외부에서 위조할 수 있는 헤더를 그대로 믿는 구성이 됐다. 아래 세 곳은 그다음에 붙인 것이다.

위조 헤더를 얹은 요청에서 무엇을 보는가

로그인을 마친 브라우저가 정상 요청에 헤더 3개를 얹어 보낸다.

GET http://localhost:8088/api/edge
Cookie: AP4_SESSION=<opaque-session>
X-Auth-Request-User: spoofed-admin
X-Auth-Request-Email: spoofed-admin@example.test
X-Internal-Auth-Token: attacker-controlled-token

세션 자체는 유효해서 이 요청이 200으로 처리되는 것은 정상이다. 볼 값은 응답의 user이고, 여기에 spoofed-admin이 아니라 실제로 인증된 사용자가 들어 있어야 이 검사를 통과한다. 「로그인이 성공한다」를 성공 기준으로 삼으면 이 경계는 재지 못한다. 위조 헤더가 통과해도 정상 사용자는 자기 이름을 보기 때문이다.

세 곳에서 나눠 막는다

브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도.

앞단을 Traefik ForwardAuth로 세우는 방법도 살펴봤다. 인증 판단을 맡길 수는 있지만 OIDC(OpenID Connect) 클라이언트나 세션 관리자 자체는 아니고, 지금 Nginx가 내는 속성을 그대로 내려면 네 가지가 더 필요하다. trustForwardHeader=false, 허용 목록에 있는 인증 응답 헤더만 복사, 로그인 리다이렉트를 따로 만드는 일, 그리고 업스트림 내부 토큰이나 더 강한 서비스 신원(workload identity) 주입이다. 마지막 항목이 대안 설정에 없어서 그대로 바꿔 끼울 수 있다고는 확인하지 못했다. Nginx를 쓴 것은 auth_request와 401 처리, 헤더 추출과 덮어쓰기를 한 파일에서 볼 수 있어서다.

세 곳은 각각 걸러 내는 요청과 놓치는 요청이 다르다.

밖에서 들어올 수 있는 길을 8088 하나로 줄인다

밖으로 연 포트는 Nginx의 8088 하나다. app의 8081과 oauth2-proxy의 4180은 Compose 네트워크에 expose만 하고 호스트 ports로는 내보내지 않아서, 이 둘에는 밖에서 직접 붙을 수 없다.

인증 엔드포인트도 같은 이유로 닫아 두는데, location = /oauth2/authinternal이라 Nginx가 만든 하위 요청만 들어갈 수 있고, 외부에서 같은 경로를 부르면 404가 된다. internal을 지정하지 않으면 브라우저가 이 경로를 직접 부를 수 있다.

이 경계는 엣지를 건너뛰고 업스트림이나 프록시로 바로 가는 경로만 막아서, 내부 서비스가 보낸 요청이나 Nginx가 잘못 넘긴 헤더는 여기서 걸리지 않는다.

클라이언트가 보낸 헤더를 덮어써서 지운다

Nginx는 업스트림을 부르기 전에 auth_request로 인증 결과를 먼저 묻고, 하위 요청이 돌려준 응답 코드로 요청을 계속할지 정한다.

auth_request /oauth2/auth;

oauth2-proxy가 세션을 유효하다고 판단하면 결과를 응답 헤더로 돌려준다. Nginx는 auth_request_set으로 그 값을 지역 변수 3개에 복사한다.

$auth_user   ← oauth2-proxy X-Auth-Request-User
$auth_email  ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie

그다음 원래 요청을 그대로 넘기지 않는다. 외부 /api/edge는 내부 /edge/me로 다시 매핑되고, 헤더 3개는 클라이언트가 보낸 값에 합치지 않고 덮어쓴다.

GET http://app:8081/edge/me
X-Auth-Request-User: <oauth2-proxy-authenticated-user>
X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
X-Internal-Auth-Token: <nginx-environment-secret>

그래서 클라이언트가 무엇을 보냈든 업스트림 입력은 oauth2-proxy가 확인한 값이 된다. 신뢰할 프록시 범위도 IP 1개로 좁혀 두었는데, 이 범위를 넓게 잡으면 같은 내부 네트워크에 있는 다른 서비스가 신뢰받는 프록시처럼 요청을 보낼 수 있기 때문이다.

업스트림이 내부 토큰까지 확인한다

EdgeIdentityController.currentUser(HttpServletRequest)/edge/me를 받는데, 여기서 확인하는 값은 2개다. X-Auth-Request-User를 읽어 비어 있는지 보고, X-Internal-Auth-Token을 읽어 배포할 때 설정해 둔 내부 토큰(internal token)과 비교한다.

비교에는 MessageDigest.isEqual을 쓴다. 두 바이트 배열이 앞에서 몇 바이트까지 같은지에 따라 실행 시간이 달라지지 않는 비교다.

두 조건이 모두 맞을 때만 허용 목록에 있는 필드 4개를 응답에 넣는다.

{
  "pattern": "AP4-edge-forward-auth",
  "user": "regular-user",
  "email": "regular-user@example.test",
  "identityHeader": "X-Auth-Request-User"
}

이 JSON이 Nginx를 지나 브라우저가 부른 /api/edge의 응답이 된다.

하나라도 다르면 401이 된다.

{
  "error": "trusted edge authentication is required"
}

:::danger

현재 SecurityConfig/edge/**permitAll로 두고 /edge/me 컨트롤러가 직접 내부 토큰을 확인한다. 새 엣지 엔드포인트를 추가하면서 같은 메서드를 부르지 않으면 그 엔드포인트는 보호되지 않는다.

:::

검사가 컨트롤러 하나에만 들어 있어서 운영으로 넘어갈 때는 필터나 인터셉터, 시큐리티 체인처럼 대상 엔드포인트 전체에 걸리는 공통 경계로 옮겨야 한다. 이 검사는 엣지를 거치지 않고 들어온 내부 요청을 걸러 낸다. 다만 토큰을 얻은 쪽에는 소용이 없으므로, 호스트 포트는 계속 닫아 두어야 한다.

경로에 따라 다른 코드가 돌아온다

쿠키 없이 /를 부르면 /oauth2/start로 302가 되고, 같은 상태에서 /api/edge를 부르면 Location 없는 401이 된다. 사람은 로그인 화면으로 가야 하고 프로그램은 리다이렉트를 따라가는 대신 401을 받아야 해서, 같은 미인증 요청이라도 경로마다 결과를 다르게 두었다.

리다이렉트 없는 JSON 401은 정확히 /api/edge 경로에만 구성돼 있다. 다른 경로는 로그인 리다이렉트 규칙을 따른다.

외부 입력 인증 상태 결과
GET / 미인증 /oauth2/start 302
GET /api/edge 미인증 리다이렉트 없는 401
GET /oauth2/auth 무관 404
GET / + 위조 헤더 정상 세션 실제 사용자 200
/edge/me + 사용자 정보 헤더만 내부 토큰 없음 401
/edge/me + 틀린 토큰 토큰 불일치 401

위의 네 줄은 밖에서 들어온 요청이고, 아래 두 줄은 엣지를 거치지 않고 내부에서 /edge/me로 바로 들어온 요청이다.

이 학습 환경이 보장하는 범위

여기서 확인한 것은 Keycloak 26.7.0과 oauth2-proxy 7.15.2를 한 대에서 돌리는 학습 환경이다. 쿠키 속성과 리다이렉트를 눈으로 보려고 HTTPS 대신 HTTP를 쓴 설정도 있다. 코드를 실행해 봤다고 운영까지 확인한 것은 아니다.

브라우저에 남는 것은 opaque 쿠키 하나다

여기서 opaque는 브라우저가 값을 해석하지 않고 다음 요청에 그대로 돌려준다는 뜻이다. 로그인이 끝나면 브라우저에는 AP4_SESSION 쿠키 하나가 남고, 서버 쪽 세션 저장소는 따로 두지 않았다. session-cookie-minimal=true를 쓰면 쿠키에는 액세스·리프레시·ID 토큰 대신 엣지에 필요한 최소 정보만 남는다.

name       = AP4_SESSION
HttpOnly   = true
SameSite   = Lax
Secure     = false in local HTTP fixture
expire     = 1 hour in proxy configuration

HttpOnly가 붙어 있어 JavaScript로 읽을 수 없고, 유효 기간은 프록시 설정에서 1시간이다. Securefalse인 것은 로컬에서 HTTP로 띄운 학습 환경 기준이라 그렇고, HTTPS로 올리면 Secure = true로 바꿔야 한다. 레플리카를 늘린다면 같은 쿠키를 검증할 시크릿을 어떻게 배포하고 교체할지도 정해야 하는데, 지금 학습 환경에는 정해 둔 것이 없다. 1시간이 지난 뒤 재인증이 어떻게 이어지는지, 쿠키에 담기지 않는 리프레시 토큰의 수명주기가 어떻게 되는지도 확인하지 않았다.

코드 교환은 브라우저가 아니라 oauth2-proxy가 컨테이너 안에서 하기 때문에, automatic discovery를 끄고 같은 realm을 가리키는 주소 4개를 각각 관리한다.

issuer expected value = http://localhost:8080/realms/keycloak-patterns
login URL             = http://localhost:8080/.../auth
redeem/token URL      = http://keycloak:8080/.../token
JWKS/userinfo URL     = http://keycloak:8080/...

issuer는 요청을 보내기 위한 주소가 아니라 Keycloak이 발급한 토큰의 iss claim이 기대한 값과 같은지 검증하는 기준값이다. 브라우저는 Docker 내부 호스트명인 keycloak:8080에 접근할 수 없어서 로그인에는 localhost:8080을 쓴다. 컨테이너 안에서는 자기 localhost:8080이 Keycloak이 아니므로 토큰과 JWKS(JSON Web Key Set) 요청에는 keycloak:8080을 쓴다.

업스트림이 믿는 입력

앞의 세 구조에서는 Resource Server가 서명된 JWT를 받아 서명과 issuer, audience를 직접 확인한다. /edge/me는 JWT를 입력으로 받지 않는다. 대신 요청이 엣지를 거쳐 들어왔다는 네트워크 위치와 X-Internal-Auth-Token, 엣지가 넘긴 사용자와 이메일을 믿는다. 믿는 입력이 JWT 1개에서 3개로 늘어난 셈이라, 백엔드 직접 경로나 클라이언트가 보낸 헤더 중 하나만 열려도 다른 사용자처럼 요청을 보낼 수 있다.

지금 엣지 응답은 사용자와 이메일만 전달하고 role, groups, tenant, 인증 방식, 토큰 만료는 전달하지 않는다. role을 넘기면 무엇이 달라지는지는 확인하지 않았다. 패턴이 금지하는 것은 아니지만, 헤더를 하나 늘릴 때 아래 6개를 함께 정해야 한다.

  • claim 출처 : oauth2-proxy나 별도 인증 서비스가 어느 값을 읽는가
  • 허용 목록 : Nginx가 어느 응답 헤더만 복사하는가
  • 덮어쓰기 : 클라이언트가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
  • 직렬화 : 다중 값, 구분자, 이스케이프, 최대 크기는 무엇인가
  • 업스트림 검증 : 헤더 존재만 볼지 값과 서비스 신원까지 볼지
  • 갱신 : role이 바뀌면 프록시 세션과 다운스트림 인가에 언제 반영되는가

커밋된 테스트가 확인하도록 정의한 계약

이 기록에서 확인했다고 적은 것은 마지막 실행 성적표가 아니다.

쿠키 없는 /는 302를 받고 쿠키 없는 /api/edge는 401을 받는다. authorization request에는 edge-proxy 클라이언트와 PKCE(Proof Key for Code Exchange) S256 challenge가 들어 있어야 한다. 로그인 뒤 쿠키는 AP4_SESSION이고 HttpOnlySameSite=Lax가 붙어 있어야 한다. 브라우저 요청 목록에는 Keycloak 토큰 엔드포인트가 없어야 하고, Web Storage는 비어 있어야 하며 document.cookie로는 세션 쿠키를 읽을 수 없어야 한다. 위조 헤더를 얹은 요청은 실제 사용자로 200을 받고, 외부에서 부른 /oauth2/auth는 404, 호스트의 4180과 8081은 접근 불가여야 한다. 사용자 정보 헤더가 없거나 내부 토큰이 없거나 틀리면 401이다.

role 전달, 새 엔드포인트에 검사를 공통으로 거는 것, 상태를 바꾸는 요청의 CSRF(Cross-Site Request Forgery), 세션 갱신, 레플리카 사이의 시크릿 공유, 내부 시크릿 교체까지 여섯 가지는 이 계약 밖이라 확인하지 않았다.

지금 설정은 /api/edge/를 모두 /edge/me로 바꾸기 때문에 /orders/123 같은 임의 경로를 보존하는 범용 리버스 프록시가 아니고, 그래서 경로와 메서드, 요청 본문, 스트리밍, 웹소켓, 큰 헤더 동작은 입증하지 못했다.

업스트림이 OAuth를 몰라도 되는 대신, 이 구조는 네트워크 경로와 사용자 정보 헤더, 내부 토큰을 믿어야 한다. 그것을 지키려면 내부 토큰 검사를 컨트롤러 밖 공통 경계로 옮기고, 레플리카 사이에서 세션 시크릿을 배포하고 교체하는 방법을 정해야 한다. 둘 다 아직 하지 않았다.