refactor: 문서 개선 중
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
---
|
||||
id: a0e1cc05-92b3-4dac-bce1-513ab8cd862b
|
||||
kind: CASE
|
||||
slug: identity-header-trust
|
||||
title: Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 37
|
||||
verifiedOn: 2026-08-25
|
||||
studio: "https://hyeonworks.com/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit"
|
||||
public: "https://hyeonworks.com/cases/identity-header-trust"
|
||||
---
|
||||
|
||||
# Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
|
||||
|
||||
`X-Auth-Request-User`는 edge가 인증 결과로 추가하는 헤더지만 client도 같은 이름의 헤더를 보낼 수 있다. upstream이 이 값을 사용자 식별에 사용하므로 Nginx에서 client 값을 덮어쓰고, backend 직접 접근을 차단하며, backend에서도 internal credential을 검증하도록 구성했다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
|
||||
identity header를 신뢰하기 위한 조건을 Nginx, oauth2-proxy, backend 설정과 요청 결과로 확인했다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
Forward-Auth에서는 proxy session cookie와 identity header를 JWT와 구분해 다룬다.
|
||||
- **OAuth/OIDC 인증 패턴 선택 기준**
|
||||
OAuth 처리는 edge에서 끝내고 upstream은 검증된 identity header를 사용하도록 구성한 Case다.
|
||||
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
|
||||
edge가 user와 email만 전달한다는 사실이 이 질문의 출발점이다.
|
||||
|
||||
## 문제
|
||||
|
||||
앞단 proxy가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다.
|
||||
|
||||
upstream은 `X-Auth-Request-User`를 사용자 식별에 사용한다.
|
||||
이 헤더는 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다.
|
||||
upstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문제가 된다.
|
||||
|
||||
backend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면
|
||||
공격자가 인증된 사용자처럼 보낼 수 있다.
|
||||
|
||||
client가 같은 이름의 헤더를 보낼 수 있기 때문에 upstream만으로는 `X-Auth-Request-User`가 edge에서 생성됐는지 판단할 수 없다.
|
||||
|
||||
## 결론
|
||||
|
||||
헤더를 믿으려면 서로 독립된 곳에서 방어를 해야 된다.
|
||||
|
||||
host port 닫힘 : 외부에서 upstream·proxy로 바로 가는 경로를 막는다
|
||||
Nginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다
|
||||
upstream internal token : edge를 거치지 않은 내부 요청을 막는다
|
||||
|
||||
network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 문제를 막지 못한다.
|
||||
backend의 internal credential 검증과 network 수준의 직접 접근 차단은 각각 별도로 적용한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
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 in local HTTP fixture
|
||||
expire : 1 hour in proxy configuration
|
||||
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이 아니라 실제 authenticated user여야 함.
|
||||
|
||||
6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인.
|
||||
|
||||
7. host의 4180과 8081에 접근할 수 없는지 확인.
|
||||
|
||||
8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고,
|
||||
둘 다 맞으면 200인지 확인.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 같은 이름의 헤더
|
||||
|
||||
:::evidence key="ap4-edge-trust-1cff2399" alt="왼쪽 외부 영역의 브라우저에 AP4_SESSION과 점선으로 표시된 client 제공 header가 있다. 가운데 Nginx는 8088만 공개하고 header 덮어쓰기를 맡는다. 오른쪽 점선 영역은 host port가 닫혀 있고 oauth2-proxy와 Spring upstream이 들어 있다. Nginx가 oauth2-proxy에 auth_request를 보내 user와 email을 받고, nginx-owned header와 internal token으로 upstream 요청을 만든다." caption="" zoom="true"
|
||||
:::
|
||||
|
||||
`X-Auth-Request-User`는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.
|
||||
|
||||
client가 같은 이름의 헤더를 보낼 수 있기 때문에 upstream만으로는 `X-Auth-Request-User`가 edge에서 생성됐는지 판단할 수 없다.
|
||||
|
||||
## 위조 요청의 모양
|
||||
|
||||
로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다.
|
||||
|
||||
## 세 개의 독립된 경계
|
||||
|
||||
현재 OAuth2-Proxy 구성에서는 세 단계에서 위조 요청을 차단한다.
|
||||
|
||||
| 위치 | 차단 대상 |
|
||||
|---|---|
|
||||
| host port 닫힘 | 외부에서 upstream·proxy로 가는 직접 경로 |
|
||||
| Nginx header 덮어쓰기 | client가 보낸 동명 헤더 |
|
||||
| upstream internal token | edge를 거치지 않은 내부 요청 |
|
||||
|
||||
host port를 외부에 열면 edge를 거치지 않고 backend에 접근할 수 있다. Nginx가 동명 헤더를 덮어쓰지 않으면 client가 보낸 identity 값이 upstream에 전달될 수 있다. backend의 internal credential 검증은 edge를 거치지 않은 내부 요청을 구분하는 데 사용한다.
|
||||
|
||||
network isolation과 internal credential 검증은 서로 다른 요청 경로를 통제하므로 둘 다 적용한다.
|
||||
|
||||
## Nginx가 헤더를 만드는 경계
|
||||
|
||||
Nginx는 먼저 internal subrequest를 만든다.
|
||||
`location = /oauth2/auth`는 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있다.
|
||||
|
||||
```nginx label="upstream을 부르기 전에 먼저 물어본다"
|
||||
auth_request /oauth2/auth;
|
||||
```
|
||||
|
||||
oauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다.
|
||||
|
||||
```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`로 다시 매핑되고, 세 헤더는 **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>
|
||||
```
|
||||
|
||||
그럼 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다.
|
||||
|
||||
## upstream은 무엇을 확인하나
|
||||
|
||||
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는다.
|
||||
|
||||
1. `X-Auth-Request-User`를 읽고 비어 있는지 확인한다.
|
||||
2. `X-Internal-Auth-Token`을 읽어 설정값과 `MessageDigest.isEqual`로 비교한다.
|
||||
|
||||
두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다.
|
||||
|
||||
```json label="정상 응답 — 4가지 필드"
|
||||
{
|
||||
"pattern": "AP4-edge-forward-auth",
|
||||
"user": "regular-user",
|
||||
"email": "regular-user@example.test",
|
||||
"identityHeader": "X-Auth-Request-User"
|
||||
}
|
||||
```
|
||||
|
||||
하나라도 다르면 401이 된다.
|
||||
|
||||
```json label="user 헤더가 없거나 internal token이 틀릴 때"
|
||||
{
|
||||
"error": "trusted edge authentication is required"
|
||||
}
|
||||
```
|
||||
|
||||
internal token은 `MessageDigest.isEqual`로 비교했다. 문자열을 앞에서부터 비교하다 중단하는 방식보다 입력에 따른 비교 시간 차이를 줄이기 위한 선택이다.
|
||||
|
||||
:::danger
|
||||
|
||||
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 보호 되지 않는다.
|
||||
|
||||
:::
|
||||
|
||||
운영에서는 controller마다 같은 검사를 반복하지 않도록 filter, interceptor, security chain 등 공통 경로에서 검증하도록 구성해야 한다.
|
||||
|
||||
## 경로마다 달라지는 결과
|
||||
|
||||
같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다.
|
||||
|
||||
| 외부 입력 | 인증 상태 | 결과 |
|
||||
|---|---|---|
|
||||
| `GET /` | 미인증 | `/oauth2/start` 302 |
|
||||
| `GET /api/edge` | 미인증 | redirect 없는 401 |
|
||||
| `GET /oauth2/auth` | 무관 | 404 |
|
||||
| `GET /` + 위조 헤더 | 정상 session | 실제 user 200 |
|
||||
| `/edge/me` + user 헤더만 | edge token 없음 | 401 |
|
||||
| `/edge/me` + 틀린 token | token 불일치 | 401 |
|
||||
|
||||
아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 `Location` 없는 401을 받아야 한다.
|
||||
|
||||
**redirect 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.**
|
||||
다른 경로는 로그인 redirect 규칙을 따른다.
|
||||
|
||||
셋째 줄도 중요하다. 외부에서 `/oauth2/auth`를 직접 부르면 404다. `internal` 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다.
|
||||
|
||||
## 브라우저가 가지고 있는 것
|
||||
|
||||
OAuth2-Proxy 구조는 server-side session store를 두지 않는다.
|
||||
|
||||
```text label="AP4_SESSION cookie 설정"
|
||||
name = AP4_SESSION
|
||||
HttpOnly = true
|
||||
SameSite = Lax
|
||||
Secure = false in local HTTP fixture
|
||||
expire = 1 hour in proxy configuration
|
||||
```
|
||||
|
||||
`session-cookie-minimal=true`에서는 oauth2-proxy가 필요한 최소 session 정보만 cookie에 저장한다. 이 cookie는 HttpOnly로 설정되어 JavaScript에서 읽지 않고, 브라우저가 다음 요청에 자동으로 전송한다.
|
||||
|
||||
지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다.
|
||||
|
||||
## endpoint를 외부용과 내부용으로 나눈 이유
|
||||
|
||||
브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다.
|
||||
이 구성에서는 자동 discovery를 사용하지 않고 필요한 endpoint 주소를 각각 지정한다.
|
||||
|
||||
```text label="issuer는 브라우저가 접속하는 부분"
|
||||
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이 우리가 기대한 값과 같은지 검증하기 위한 기준값이다. 반면 token url, userinfo url 같은 경우는 실제로 내부에서 oauth2-proxy가 요청을 보내기 위해 사용되는 내부 네트워크 주소다.
|
||||
|
||||
따라서 둘다 keycloak realm을 가리키지만 용도가 다르고 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없기에 로그인에는 `localhost:8080`을 사용하고 컨테이너는 자신의 `localhost:8080`이 keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 사용한다.
|
||||
|
||||
## upstream이 JWT를 받지 않는다
|
||||
|
||||
앞의 3가지 구조에서는 Resource Server는 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 `/edge/me`는 **JWT를 입력으로 받지 않는다.**
|
||||
|
||||
| 무엇을 믿나 | AP1~AP3 | AP4 |
|
||||
|---|---|---|
|
||||
| 서명된 JWT | o | x |
|
||||
| network topology | x | o |
|
||||
| internal token | x | o |
|
||||
| edge의 user·email | x | o |
|
||||
|
||||
upstream은 edge가 검증한 결과와 edge가 추가한 헤더를 신뢰한다. 따라서 backend 직접 접근과 client가 보낸 동명 identity header를 차단하는 설정이 이 구조의 전제다.
|
||||
|
||||
## 헤더를 늘릴 때 정해야 하는 것
|
||||
|
||||
현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다.
|
||||
|
||||
- claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가
|
||||
- allowlist : Nginx가 어느 응답 헤더만 복사하는가
|
||||
- 덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
|
||||
- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가
|
||||
- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지
|
||||
- 갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가
|
||||
|
||||
|
||||
## 확인한 것과 확인하지 않은 것
|
||||
|
||||
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분** 이다.
|
||||
|
||||
| 항목 | 확인한 부분 |
|
||||
|---|---|
|
||||
| cookie 없는 root의 302 | o |
|
||||
| cookie 없는 `/api/edge`의 401 | o |
|
||||
| `edge-proxy` + S256 challenge | o |
|
||||
| `AP4_SESSION` HttpOnly · SameSite=Lax | o |
|
||||
| 브라우저 요청에 token endpoint 없음 | o |
|
||||
| Web Storage 비어 있고 cookie 읽기 불가 | o |
|
||||
| 위조 헤더를 보내도 실제 user로 200 | o |
|
||||
| 외부 `/oauth2/auth` 404 | o |
|
||||
| host의 4180 · 8081 접근 불가 | o |
|
||||
| user 헤더 없음 · token 없음 · token 불일치 401 | o |
|
||||
| role 전달 | x |
|
||||
| 새 endpoint의 공통 강제 | x |
|
||||
| 상태 변경 요청의 CSRF | x |
|
||||
| session 갱신 | x |
|
||||
| replica 간 secret 공유 | x |
|
||||
| internal secret 교체 | x |
|
||||
|
||||
일곱째 줄이 핵심이다. 요청이 실패하는지 보는 것이 아니라, **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다.
|
||||
|
||||
## 증명하지 않는 것
|
||||
|
||||
현재 설정은 `/api/edge`와 `/` 요청을 모두 `/edge/me`로 전달한다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy는 검증하지 않았으며 path, method, body, streaming, websocket 동작도 이번 Case의 검증 범위에 포함하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user