333 lines
15 KiB
Markdown
333 lines
15 KiB
Markdown
---
|
|
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: 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를 신뢰하면 안 되는 이유
|
|
|
|
X-Auth-Request-User는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.
|
|
upstream이 받는 요청에서는 두 경우의 모양이 같다.
|
|
그래서 header overwrite, backend direct path 차단, internal credential 검증을 서로 독립된 세 곳에 둔다.
|
|
|
|
## 관계
|
|
|
|
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
|
|
이 기준의 다섯 조건이 실제로 어떻게 구성되는지 코드와 설정으로 확인한 자리다.
|
|
- **OAuth Token과 Application Session을 구분하는 기준**
|
|
proxy session cookie와 identity 헤더를 JWT와 구분해야 하는 실례다.
|
|
- **OAuth/OIDC 인증 패턴 선택 기준**
|
|
OAuth를 모르는 upstream 앞의 공통 관문을 얻고 network·헤더 신뢰 계약을 내주는 경우다.
|
|
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
|
|
edge가 user와 email만 전달한다는 사실이 이 질문의 출발점이다.
|
|
|
|
## 문제
|
|
|
|
앞단 proxy가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다.
|
|
|
|
대신 upstream은 X-Auth-Request-User 하나로 사용자를 판단하게 된다.
|
|
이 헤더는 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다.
|
|
upstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문제가 된다.
|
|
|
|
backend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면
|
|
공격자가 인증된 사용자처럼 보낼 수 있다.
|
|
|
|
그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다.
|
|
|
|
## 결론
|
|
|
|
헤더를 믿으려면 서로 독립된 세 곳에서 막아야 한다.
|
|
|
|
host port 닫힘 : 외부에서 upstream·proxy로 바로 가는 경로를 막는다
|
|
Nginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다
|
|
upstream internal token : edge를 거치지 않은 내부 요청을 막는다
|
|
|
|
network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 문제를 막지 못한다.
|
|
controller의 공유 token만으로는 외부 직접 접근이 어려워지는 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가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.
|
|
|
|
그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다.
|
|
|
|
## 위조 요청의 모양
|
|
|
|
로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자.
|
|
|
|
```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가 열려 있으면 헤더 검사만으로 막을 수 없고, 덮어쓰기가 없으면 인증을 안 거친 헤더가 그대로 upstream에 들어가고, internal token이 없으면 내부 workload가 edge처럼 동작할 수 있는 여지가 생긴다.
|
|
|
|
**network isolation만으로는 내부 위조를 막지 못한다. controller의 공유 token만으로는 외부 직접 접근을 막지 못한다.**
|
|
|
|
## 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를 추가하면서 같은 메서드를 부르지 않으면 그 endpoint는 보호되지 않는다.
|
|
|
|
:::
|
|
|
|
운영으로 넘어갈 때는 이 검사를 filter나 interceptor, security chain처럼 **대상 endpoint 전체에 걸리는 공통 경계**로 옮겨야 한다.
|
|
|
|
## 경로마다 달라지는 결과
|
|
|
|
같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다.
|
|
|
|
| 외부 입력 | 인증 상태 | 결과 |
|
|
|---|---|---|
|
|
| `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 규칙을 따른다.
|
|
|
|
셋째 줄은 auth endpoint다. 외부에서 `/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`를 쓰면 cookie에는 access·refresh·ID token 대신 edge가 필요한 최소 정보만 남는다. 브라우저에 남는 것은 JavaScript로 읽을 수 없고 다음 요청에 자동으로 붙는 opaque cookie 하나뿐이다.
|
|
|
|
지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다.
|
|
|
|
## endpoint를 외부용과 내부용으로 나눈 이유
|
|
|
|
브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다.
|
|
그래서 자동 discovery를 끄고 네 주소를 각각 관리한다.
|
|
|
|
```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가 내부에서 실제로 요청을 보내는 network 주소다.
|
|
|
|
둘 다 같은 Keycloak realm을 가리키지만 쓰임이 다르다. 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없어서 로그인에는 `localhost:8080`을 쓴다. 컨테이너 안에서는 자기 `localhost:8080`이 Keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 쓴다.
|
|
|
|
## upstream이 JWT를 받지 않는다
|
|
|
|
앞의 세 구조에서는 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 |
|
|
|
|
오른쪽 열이 AP4가 신뢰하는 입력이다. edge가 인증 경계가 되므로, backend 직접 경로나 사용자 제공 헤더를 허용하면 다른 사용자처럼 요청을 보낼 수 있게 된다.
|
|
|
|
## 헤더를 늘릴 때 정해야 하는 것
|
|
|
|
현재 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 |
|
|
|
|
일곱째 줄의 assertion은 요청이 실패하는지가 아니다. **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다.
|
|
|
|
## 증명하지 않는 것
|
|
|
|
현재 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾼다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy가 아니다. 그래서 path, method, body, streaming, websocket 같은 큰 헤더 동작은 입증하지 못했다.
|
|
|
|
<!-- body:end -->
|