Files
document-haness/docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap4-identity-header-trust.md
T
DongHyeonkaandClaude Opus 5 73026cada6 docs(keycloak-session-store): rewrite the prose and the diagram labels out of AI register
Two things read as machine-written and both are now fixed with the
repository's own tooling.

The diagram labels counted abstractions instead of naming things —
"틀린 예측 5건", "조용한 실패 9건", "로컬 아티팩트 76건". Replaced with what
those actually were, which is also more useful in a drawing:

  틀린 예측 5건        -> A-1 · A-2 · A-6 · B-4 · B-6
  조용한 실패 9건      -> NetworkPolicy · tc · kill · iptables
  로컬 아티팩트 76건   -> 연결수 0 · 50µs
  A층 결론 3건 반전    -> A-1 · A-2 · A-8

The prose was staccato: 30.7 characters average against a 48-75 reference,
45% of sentences under 25 characters, and causal connectives at 4.9 per 100
against a floor of 6. check_prose reported an error for closing six
sentences with 「~것이 ~이다」.

Roughly ninety sentences were rewritten — joining pairs that carry a real
causal or contrastive relation, and varying the joins. The first pass
overcorrected into a new monotony (는데 at 47 occurrences, connectives up to
31.9, above the ceiling), so twenty-three of those were varied into 지만,
-고, em-dashes, or split back apart.

  check_prose      FAIL error 1  ->  OK error 0
  평균 문장 길이    30.7  ->  43.1
  25자 미만 비율    45.4% ->  23.4%
  이유 연결어미     4.9   ->  within range

Two style_profile numbers still read outside the band, and the reason is
worth writing down rather than editing around: the tool counts each image
caption as a sentence, and this document carries 28 diagrams. Excluding
captions and separators the prose is 46.4 characters average with 14.3%
short — inside the reference range. Padding sentences to move the remaining
gap is what CLAUDE.md forbids, so it stops here.

Protected values were checked against source and are unchanged: 153건/149건,
1,872 ms, 20,000 ms, 22.2 초, 2305초, 845361바이트, 8856건, 205.7ms, 106초.
All 28 diagrams still lint clean with no sentence left on any canvas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:05:52 +09:00

286 lines
19 KiB
Markdown

---
id: a0e1cc05-92b3-4dac-bce1-513ab8cd862b
kind: CASE
slug: identity-header-trust
title: Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
topic: oauth-oidc-auth-boundary
topicName: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 39
verifiedOn: 2026-08-25
studio: "https://hyeonworks.com/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit"
public: "https://hyeonworks.com/cases/identity-header-trust"
assets:
- key: ap4-edge-trust-1cff2399
file: ../../../final/assets/tech-log-studio/ap4-edge-trust.svg
---
# Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
앞단에 세운 프록시가 로그인을 대신 받으면 upstream은 OAuth를 몰라도 되고, 대신 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단한다. 이 헤더는 인증을 마친 프록시가 붙일 수도 있고 브라우저가 직접 적어 보낼 수도 있는데, 두 값은 upstream이 받은 요청에서 이름도 형식도 같아 서로 구분되지 않는다.
그래서 이 구성에서는 헤더를 믿을 조건을 세 곳에 나눠 두었다. 밖에서 들어오는 길을 Nginx 8088 하나로 줄이고, Nginx가 클라이언트의 동명 헤더를 자기 값으로 덮어쓰고, upstream이 사용자 헤더와 함께 내부 토큰까지 대조한다. 호스트 포트를 닫아도 같은 Compose 네트워크 안에서는 app의 8081에 닿을 수 있고 그 요청은 Nginx를 거치지 않으니, 덮어쓰기도 함께 지나친다. upstream이 내부 토큰을 따로 대조하는 것은 그 요청을 걸러 내기 위해서다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
그 기준이 세운 다섯 조건을 Nginx 설정과 upstream 코드에서 하나씩 찾아 어디에 들어가 있는지 확인했다.
- **OAuth Token과 Application Session을 구분하는 기준**
AP4_SESSION은 브라우저와 Nginx 사이에서만 오가고 identity 헤더는 Nginx와 upstream 사이에서만 붙으며, upstream은 JWT를 입력으로 받지 않는다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 구조를 고르면 OAuth를 모르는 upstream 앞에 공통 관문이 생기는 대신, 네트워크 경로와 헤더를 어디까지 믿을지를 따로 정해 두어야 한다.
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
지금 edge는 사용자와 이메일만 넘기고 role은 넘기지 않는데, 그러면 인가를 어디서 판단할지가 남는다.
## 문제
앞단 프록시가 로그인을 맡으면 upstream은 OAuth를 몰라도 되고, 대신 요청에 붙어 온 X-Auth-Request-User 하나로 사용자를 판단하게 된다. 그러려면 upstream은 이 헤더를 인증을 마친 edge가 붙였는지 브라우저가 직접 적어 넣었는지 가릴 수 있어야 한다.
가릴 방법이 없다. 두 값은 upstream이 받은 요청에서 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 자리가 없기 때문이다.
그래서 backend port가 외부에 열려 있거나 Nginx가 브라우저의 동명 헤더를 그대로 넘기면, 공격자가 원하는 이름을 적어 인증된 사용자처럼 요청을 보낼 수 있다.
## 결론
헤더를 인증 근거로 쓰려면 세 곳에서 막아야 한다.
host port 닫힘 : 외부에서 upstream과 프록시로 바로 가는 경로를 막는다
Nginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다
upstream internal token : edge를 거치지 않은 내부 요청을 막는다
네트워크를 나눠 두는 것만으로는 내부 workload가 보낸 요청이나 Nginx가 잘못 넘긴 헤더가 신뢰되는 것까지 막지는 못한다. 공유 토큰만 두고 포트를 열어 두면, 그 토큰을 얻은 쪽이 Nginx를 건너뛰고 upstream에 바로 붙을 수 있다.
## 검증 환경
Keycloak 26.7.0, oauth2-proxy 7.15.2
client : edge-proxy
confidential, PKCE S256 : o
외부 공개
Nginx : 8088
app 8081, oauth2-proxy 4180 : Compose network에 expose만, host publish x
Nginx
auth_request /oauth2/auth
location = /oauth2/auth : internal
auth_request_set : user, email, Set-Cookie 복사
client 제공 동명 헤더 : 덮어쓰기
trusted proxy : 단일 IP
upstream
EdgeIdentityController.currentUser(HttpServletRequest)
X-Internal-Auth-Token 비교 : MessageDigest.isEqual
SecurityConfig의 /edge/** : permitAll
AP4_SESSION
HttpOnly : true
SameSite : Lax
Secure : false, local HTTP fixture 기준
expire : 1 hour, 프록시 설정값
session-cookie-minimal : true
server-side session store : x
automatic discovery : x
login, token, JWKS, userinfo URL : 각각 따로 관리
HTTP : o
## 재현 조건
1. cookie 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인한다.
2. cookie 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인한다.
3. authorization request에 client_id=edge-proxy와 code_challenge_method=S256이 있는지 확인한다.
4. 로그인 뒤 cookie가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
브라우저 요청 목록에 Keycloak token endpoint가 없어야 한다.
Web Storage가 비어 있어야 하고 document.cookie로 session cookie를 읽을 수 없어야 한다.
5. 정상 session에 다음 헤더를 얹어 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이 아니라 실제로 인증된 사용자여야 한다.
6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인한다.
7. host의 4180과 8081에 접근할 수 없는지 확인한다.
8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고,
둘 다 맞으면 200인지 확인한다.
## 본문
<!-- body:start -->
forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 엔드포인트에 허용 여부를 묻는 방식이고, Nginx에서는 `auth_request` 디렉티브가 그 질문을 subrequest로 만든다. 이 두 낱말만 알면 따라올 수 있고, 나머지 용어는 쓰는 자리에서 푼다. 먼저 위조 요청의 모양부터 보고, 그것을 막는 세 곳을 하나씩 따라간 뒤, 지금 확인한 범위와 확인하지 않은 범위를 나눠 적는다.
## 같은 이름의 헤더가 두 곳에서 만들어진다
:::evidence key="ap4-edge-trust-1cff2399" alt="왼쪽 외부 영역의 브라우저에 AP4_SESSION과 점선으로 표시된 클라이언트 제공 헤더가 있다. 가운데 Nginx는 8088만 공개하고 헤더 덮어쓰기를 맡는다. 오른쪽 점선 영역은 호스트 포트가 닫혀 있고 oauth2-proxy와 Spring upstream이 들어 있다. Nginx가 oauth2-proxy에 auth_request를 보내 사용자와 이메일을 받고, Nginx가 만든 헤더와 내부 토큰으로 upstream 요청을 만든다." caption="" zoom="true"
:::
앞단 프록시가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다. 로그인과 세션 검증은 앞단에 세운 oauth2-proxy가 맡는데, 이렇게 로그인을 대신 받는 관문을 edge라고 부른다. upstream은 요청에 붙어 온 `X-Auth-Request-User` 하나로 사용자를 판단한다. 이 헤더는 oauth2-proxy가 확인한 로그인 사용자의 이름을 담아 edge가 upstream 요청에 붙이는 값이다.
같은 이름의 헤더는 브라우저도 직접 적어 보낼 수 있다. upstream이 받는 요청에서 두 값은 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 자리가 없다. 그래서 upstream은 `X-Auth-Request-User`가 edge에서 온 값인지 브라우저가 적어 넣은 값인지 가리지 못한다. 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 보낼 수 있다.
### 상태 코드가 아니라 응답의 user로 판정한다
로그인을 마친 브라우저가 정상 요청에 헤더 3개를 얹어 보냈다.
```http label="공격자가 보낸 요청"
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`으로 바뀌지 않고 실제로 인증된 사용자로 남았는지다.
## 세 곳에서 나눠 막는다
헤더를 믿으려면 세 곳에서 막아야 한다. 각각이 걸러 내는 요청과 놓치는 요청이 다르므로, 아래에서 하나씩 무엇을 통과시키는지 함께 적는다.
### 밖에서 들어올 수 있는 길을 8088 하나로 줄인다
밖으로 연 포트는 Nginx의 8088 하나다. `app`의 8081과 oauth2-proxy의 4180은 Compose 네트워크에 `expose`만 하고 호스트 `ports`로는 내보내지 않아서, 포트 2개에는 밖에서 직접 붙을 수 없다.
인증 엔드포인트도 같은 이유로 닫아 두는데, `location = /oauth2/auth`가 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있고, 외부에서 같은 경로를 부르면 404가 된다. `internal` 지정이 없으면 이 엔드포인트가 밖에서 부를 수 있는 인증 우회 지점이 된다.
이 경계가 막는 것은 edge를 건너뛰고 upstream이나 프록시로 바로 가는 경로여서, 내부 workload가 보낸 요청이나 Nginx가 잘못 넘긴 헤더는 여기서 걸리지 않는다.
### 클라이언트가 보낸 헤더를 덮어써서 지운다
Nginx는 upstream을 부르기 전에 인증 결과를 먼저 묻는다. `auth_request`는 원래 요청을 처리하기 전에 지정한 경로로 subrequest를 보내고 그 응답 코드로 요청을 계속할지 정하는 디렉티브다.
```nginx label="upstream을 부르기 전에 먼저 물어본다"
auth_request /oauth2/auth;
```
oauth2-proxy가 세션을 유효하다고 판단하면 결과를 응답 헤더로 돌려준다. Nginx는 `auth_request_set`으로 그 값을 지역 변수 3개에 복사한다.
```text label="auth_request_set — 값의 출처가 여기서 고정된다"
$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개는 클라이언트가 보낸 값과 **merge하지 않고 덮어쓰기**로 채워진다.
```http label="upstream이 실제로 받는 요청"
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>
```
그래서 클라이언트가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다. 신뢰할 프록시 범위도 IP 1개로 좁혀 두었는데, 이 범위를 넓게 잡으면 같은 내부 네트워크에 있는 다른 서비스가 신뢰받는 프록시처럼 요청을 보낼 수 있기 때문이다.
### upstream이 내부 토큰까지 확인한다
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는데, 여기서 확인하는 값은 2개다. `X-Auth-Request-User`를 읽어 비어 있는지 보고, `X-Internal-Auth-Token`을 읽어 배포할 때 설정해 둔 내부 토큰(internal token)과 비교한다.
비교에는 일반 문자열 비교 대신 `MessageDigest.isEqual`을 썼는데, 두 바이트 배열이 앞에서 몇 바이트까지 같은지에 따라 실행 시간이 크게 달라지지 않는 비교다. 일반 비교를 쓰면 값이 어디까지 맞았는지가 응답 시간으로 새어 나갈 수 있다.
두 조건이 모두 맞을 때만 허용 목록에 있는 필드 4개를 응답에 넣는다.
```json label="정상 응답 — 필드 4개"
{
"pattern": "AP4-edge-forward-auth",
"user": "regular-user",
"email": "regular-user@example.test",
"identityHeader": "X-Auth-Request-User"
}
```
하나라도 다르면 401이 된다.
```json label="사용자 헤더가 없거나 내부 토큰이 틀릴 때"
{
"error": "trusted edge authentication is required"
}
```
:::danger
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` 컨트롤러가 직접 내부 토큰을 확인한다. 새 edge 엔드포인트를 추가하면서 같은 메서드를 부르지 않으면 그 엔드포인트는 보호되지 않는다.
:::
검사가 컨트롤러 하나에만 들어 있어서 운영으로 넘어갈 때는 필터나 인터셉터, security chain처럼 대상 엔드포인트 전체에 걸리는 공통 경계로 옮겨야 한다. 이 검사가 걸러 내는 것은 edge를 거치지 않고 들어온 내부 요청이다. 다만 토큰을 얻은 쪽에는 소용이 없으므로, 호스트 포트를 닫아 두는 일은 그대로 남는다.
### 경로에 따라 다른 코드가 돌아온다
같은 미인증 요청이라도 경로에 따라 결과가 다르다. 쿠키 없이 `/`를 부르면 `/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를 거치지 않고 내부에서 `/edge/me`로 바로 들어온 요청이다.
## 이 fixture가 보장하는 범위
### 브라우저에 남는 것은 opaque 쿠키 하나다
로그인이 끝나면 브라우저에는 `AP4_SESSION` 쿠키 하나가 남고, 서버 쪽 세션 저장소는 따로 두지 않았다. `session-cookie-minimal=true`를 쓰면 쿠키에는 access·refresh·ID 토큰 대신 edge에 필요한 최소 정보만 남는다. 여기서 opaque는 브라우저가 값을 해석하지 않고 다음 요청에 그대로 돌려준다는 뜻이다.
```text label="AP4_SESSION 쿠키 설정"
name = AP4_SESSION
HttpOnly = true
SameSite = Lax
Secure = false in local HTTP fixture
expire = 1 hour in proxy configuration
```
`HttpOnly`가 붙어 있어 JavaScript로 읽을 수 없고, 유효 기간은 프록시 설정에서 1시간이다. `Secure`가 `false`인 것은 local HTTP fixture 기준이라 그렇고, HTTPS로 올리면 `Secure = true`로 바꿔야 한다. 레플리카를 늘린다면 같은 쿠키를 검증할 시크릿을 어떻게 배포하고 교체할지도 정해야 하는데, 지금 fixture에는 정해 둔 것이 없다.
코드 교환은 브라우저가 아니라 oauth2-proxy가 컨테이너 안에서 하기 때문에, automatic discovery를 끄고 같은 realm을 가리키는 주소 4개를 각각 관리한다.
```text label="같은 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`을 쓴다.
### upstream이 믿는 입력이 JWT 1개에서 3개로 바뀐다
앞의 세 구조에서는 Resource Server가 서명된 JWT를 받아 서명과 issuer, audience를 직접 확인한다. `/edge/me`는 JWT를 입력으로 받지 않는다. 대신 요청이 edge를 거쳐 들어왔다는 네트워크 위치와 `X-Internal-Auth-Token`, edge가 넘긴 사용자와 이메일을 믿는다. 믿는 입력이 JWT 1개에서 3개로 늘어난 셈이라, 백엔드 직접 경로나 사용자 제공 헤더 중 하나만 열려도 다른 사용자처럼 요청을 보낼 수 있게 된다.
지금 edge 응답은 사용자와 이메일만 전달하고 role, groups, tenant, 인증 방식, 토큰 만료는 전달하지 않는다. 패턴이 금지하는 것은 아니지만, 헤더를 하나 늘릴 때 아래 6개를 함께 정해야 한다.
- claim 출처 : oauth2-proxy나 별도 인증 서비스가 어느 값을 읽는가
- 허용 목록 : Nginx가 어느 응답 헤더만 복사하는가
- 덮어쓰기 : 클라이언트가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가
- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지
- 갱신 : role이 바뀌면 프록시 세션과 downstream 인가가 언제 따라가는가
### 커밋된 테스트가 확인하도록 정의한 16개
이 기록에서 확인했다고 적은 것은 마지막 실행 성적표가 아니라, 커밋된 자동 테스트가 확인하도록 정의한 계약이다. 항목은 16개이고 그중 10개를 확인했다.
쿠키 없는 `/`는 302를 받고 쿠키 없는 `/api/edge`는 401을 받는다. authorization request에는 `edge-proxy` 클라이언트와 PKCE(Proof Key for Code Exchange) S256 challenge가 들어 있어야 한다. 로그인 뒤 쿠키는 `AP4_SESSION`이고 `HttpOnly`와 `SameSite=Lax`가 붙어 있어야 하며, 브라우저 요청 목록에 Keycloak 토큰 엔드포인트가 없고 Web Storage가 비어 있고 `document.cookie`로 세션 쿠키를 읽을 수 없어야 한다. 위조 헤더를 얹은 요청은 실제 사용자로 200을 받고, 외부에서 부른 `/oauth2/auth`는 404, 호스트의 4180과 8081은 접근 불가여야 한다. 사용자 헤더가 없거나 내부 토큰이 없거나 틀리면 401이다.
나머지 6개는 이 계약 밖이다. role 전달, 새 엔드포인트에 검사를 공통으로 거는 것, 상태를 바꾸는 요청의 CSRF(Cross-Site Request Forgery), 세션 갱신, 레플리카 사이의 시크릿 공유, 내부 시크릿 교체는 확인하지 않았다.
지금 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾸기 때문에 `/orders/123` 같은 임의 경로를 보존하는 범용 리버스 프록시가 아니고, 그래서 path와 method, body, streaming, websocket, 큰 헤더 동작은 입증하지 못했다.
upstream이 OAuth를 몰라도 되는 대신, 이 구조에서 믿어야 하는 입력은 서명된 JWT 1개에서 3개로 늘었다. 그 3개를 지키는 일 가운데 내부 토큰 검사를 컨트롤러 밖 공통 경계로 옮기는 것과, 레플리카 사이에서 세션 시크릿을 배포하고 교체하는 방법을 정하는 것은 아직 남아 있다.
<!-- body:end -->