Files
document-haness/.run/keycloak-four-patterns/records/case-ap4-identity-header-trust.json
T

13 lines
14 KiB
JSON

{
"kind": "CASE",
"title": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
"slug": "identity-header-trust",
"summary": "`X-Auth-Request-User`는 edge가 인증 결과로 추가하는 헤더지만 client도 같은 이름의 헤더를 보낼 수 있다. upstream이 이 값을 사용자 식별에 사용하므로 Nginx에서 client 값을 덮어쓰고, backend 직접 접근을 차단하며, backend에서도 internal credential을 검증하도록 구성했다.",
"problem": "앞단 proxy가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다.\n\nupstream은 `X-Auth-Request-User`를 사용자 식별에 사용한다.\n이 헤더는 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다.\nupstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문제가 된다.\n\nbackend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면\n공격자가 인증된 사용자처럼 보낼 수 있다.\n\nclient가 같은 이름의 헤더를 보낼 수 있기 때문에 upstream만으로는 `X-Auth-Request-User`가 edge에서 생성됐는지 판단할 수 없다.",
"conclusion": "헤더를 믿으려면 서로 독립된 곳에서 방어를 해야 된다.\n\nhost port 닫힘 : 외부에서 upstream·proxy로 바로 가는 경로를 막는다\nNginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다\nupstream internal token : edge를 거치지 않은 내부 요청을 막는다\n\nnetwork isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 문제를 막지 못한다.\nbackend의 internal credential 검증과 network 수준의 직접 접근 차단은 각각 별도로 적용한다.",
"environment": "Keycloak 26.7.0, oauth2-proxy 7.15.2\n\nclient : edge-proxy\nconfidential, PKCE S256 : o\n\n외부 공개\nNginx : 8088\napp 8081, oauth2-proxy 4180 : Compose network에 expose만, host publish x\n\nNginx\nauth_request /oauth2/auth\nlocation = /oauth2/auth : internal\nauth_request_set으로 user, email, Set-Cookie 복사\nclient 제공 동명 헤더 : 덮어쓰기\ntrusted proxy : 단일 IP\n\nupstream\nEdgeIdentityController.currentUser(HttpServletRequest)\nX-Internal-Auth-Token 비교 : MessageDigest.isEqual\nSecurityConfig의 /edge/** : permitAll\n\nAP4_SESSION\nHttpOnly : true\nSameSite : Lax\nSecure : false in local HTTP fixture\nexpire : 1 hour in proxy configuration\nsession-cookie-minimal : true\n\nserver-side session store : x\nautomatic discovery : x\nlogin, token, JWKS, userinfo URL을 각각 관리.\n\nHTTP : o",
"reproduction": "1. cookie 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인.\n\n2. cookie 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인.\n\n3. authorization request에 client_id=edge-proxy와 code_challenge_method=S256이 있는지 확인.\n\n4. 로그인 뒤 cookie가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인.\n브라우저 요청 목록에 Keycloak token endpoint가 없어야 함.\nWeb Storage가 비어 있고 document.cookie로 session cookie를 읽을 수 없어야 함.\n\n5. 정상 session에 다음 헤더를 얹어 GET /api/edge를 보냄.\nX-Auth-Request-User : spoofed-admin\nX-Auth-Request-Email : spoofed-admin@example.test\nX-Internal-Auth-Token : attacker-controlled-token\n\n응답은 200이고 user는 spoofed-admin이 아니라 실제 authenticated user여야 함.\n\n6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인.\n\n7. host의 4180과 8081에 접근할 수 없는지 확인.\n\n8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고,\n둘 다 맞으면 200인지 확인.",
"lastVerifiedOn": "2026-08-25",
"bodyMarkdown": "## 같은 이름의 헤더\n\n:::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\"\n:::\n\n`X-Auth-Request-User`는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.\n\nclient가 같은 이름의 헤더를 보낼 수 있기 때문에 upstream만으로는 `X-Auth-Request-User`가 edge에서 생성됐는지 판단할 수 없다.\n\n## 위조 요청의 모양\n\n로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자.\n\n```http label=\"공격자가 보낸 요청\"\nGET http://localhost:8088/api/edge\nCookie: AP4_SESSION=<opaque-session>\nX-Auth-Request-User: spoofed-admin\nX-Auth-Request-Email: spoofed-admin@example.test\nX-Internal-Auth-Token: attacker-controlled-token\n```\n\n이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다.\n\n## 세 개의 독립된 경계\n\n현재 OAuth2-Proxy 구성에서는 세 단계에서 위조 요청을 차단한다.\n\n| 위치 | 차단 대상 |\n|---|---|\n| host port 닫힘 | 외부에서 upstream·proxy로 가는 직접 경로 |\n| Nginx header 덮어쓰기 | client가 보낸 동명 헤더 |\n| upstream internal token | edge를 거치지 않은 내부 요청 |\n\nhost port를 외부에 열면 edge를 거치지 않고 backend에 접근할 수 있다. Nginx가 동명 헤더를 덮어쓰지 않으면 client가 보낸 identity 값이 upstream에 전달될 수 있다. backend의 internal credential 검증은 edge를 거치지 않은 내부 요청을 구분하는 데 사용한다.\n\nnetwork isolation과 internal credential 검증은 서로 다른 요청 경로를 통제하므로 둘 다 적용한다.\n\n## Nginx가 헤더를 만드는 경계\n\nNginx는 먼저 internal subrequest를 만든다. \n`location = /oauth2/auth`는 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있다.\n\n```nginx label=\"upstream을 부르기 전에 먼저 물어본다\"\nauth_request /oauth2/auth;\n```\n\noauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다.\n\n```text label=\"auth_request_set — 값의 출처가 여기서 고정\"\n$auth_user ← oauth2-proxy X-Auth-Request-User\n$auth_email ← oauth2-proxy X-Auth-Request-Email\n$auth_cookie ← oauth2-proxy Set-Cookie\n```\n\n그 다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 세 헤더는 **merge가 아니라 덮어쓰기**로 채워진다.\n\n```http label=\"upstream이 실제로 받는 요청\"\nGET http://app:8081/edge/me\nX-Auth-Request-User: <oauth2-proxy-authenticated-user>\nX-Auth-Request-Email: <oauth2-proxy-authenticated-email>\nX-Internal-Auth-Token: <nginx-environment-secret>\n```\n\n그럼 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다.\n\n## upstream은 무엇을 확인하나\n\n`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는다.\n\n1. `X-Auth-Request-User`를 읽고 비어 있는지 확인한다.\n2. `X-Internal-Auth-Token`을 읽어 설정값과 `MessageDigest.isEqual`로 비교한다.\n\n두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다.\n\n```json label=\"정상 응답 — 4가지 필드\"\n{\n \"pattern\": \"AP4-edge-forward-auth\",\n \"user\": \"regular-user\",\n \"email\": \"regular-user@example.test\",\n \"identityHeader\": \"X-Auth-Request-User\"\n}\n```\n\n하나라도 다르면 401이 된다.\n\n```json label=\"user 헤더가 없거나 internal token이 틀릴 때\"\n{\n \"error\": \"trusted edge authentication is required\"\n}\n```\n\ninternal token은 `MessageDigest.isEqual`로 비교했다. 문자열을 앞에서부터 비교하다 중단하는 방식보다 입력에 따른 비교 시간 차이를 줄이기 위한 선택이다.\n\n:::danger\n\n현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 보호 되지 않는다.\n\n:::\n\n운영에서는 controller마다 같은 검사를 반복하지 않도록 filter, interceptor, security chain 등 공통 경로에서 검증하도록 구성해야 한다.\n\n## 경로마다 달라지는 결과\n\n같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다.\n\n| 외부 입력 | 인증 상태 | 결과 |\n|---|---|---|\n| `GET /` | 미인증 | `/oauth2/start` 302 |\n| `GET /api/edge` | 미인증 | redirect 없는 401 |\n| `GET /oauth2/auth` | 무관 | 404 |\n| `GET /` + 위조 헤더 | 정상 session | 실제 user 200 |\n| `/edge/me` + user 헤더만 | edge token 없음 | 401 |\n| `/edge/me` + 틀린 token | token 불일치 | 401 |\n\n아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 `Location` 없는 401을 받아야 한다.\n\n**redirect 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.** \n다른 경로는 로그인 redirect 규칙을 따른다.\n\n셋째 줄도 중요하다. 외부에서 `/oauth2/auth`를 직접 부르면 404다. `internal` 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다.\n\n## 브라우저가 가지고 있는 것\n\nOAuth2-Proxy 구조는 server-side session store를 두지 않는다.\n\n```text label=\"AP4_SESSION cookie 설정\"\nname = AP4_SESSION\nHttpOnly = true\nSameSite = Lax\nSecure = false in local HTTP fixture\nexpire = 1 hour in proxy configuration\n```\n\n`session-cookie-minimal=true`에서는 oauth2-proxy가 필요한 최소 session 정보만 cookie에 저장한다. 이 cookie는 HttpOnly로 설정되어 JavaScript에서 읽지 않고, 브라우저가 다음 요청에 자동으로 전송한다.\n\n지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다.\n\n## endpoint를 외부용과 내부용으로 나눈 이유\n\n브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다. \n이 구성에서는 자동 discovery를 사용하지 않고 필요한 endpoint 주소를 각각 지정한다.\n\n```text label=\"issuer는 브라우저가 접속하는 부분\"\nissuer expected value = http://localhost:8080/realms/keycloak-patterns\nlogin URL = http://localhost:8080/.../auth\nredeem/token URL = http://keycloak:8080/.../token\nJWKS/userinfo URL = http://keycloak:8080/...\n```\n\nissuer는 실제로 요청을 보내기 위한 주소가 아니라 KeyCloak이 발급한 토큰의 iss claim이 우리가 기대한 값과 같은지 검증하기 위한 기준값이다. 반면 token url, userinfo url 같은 경우는 실제로 내부에서 oauth2-proxy가 요청을 보내기 위해 사용되는 내부 네트워크 주소다.\n\n따라서 둘다 keycloak realm을 가리키지만 용도가 다르고 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없기에 로그인에는 `localhost:8080`을 사용하고 컨테이너는 자신의 `localhost:8080`이 keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 사용한다.\n\n## upstream이 JWT를 받지 않는다\n\n앞의 3가지 구조에서는 Resource Server는 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 `/edge/me`는 **JWT를 입력으로 받지 않는다.**\n\n| 무엇을 믿나 | AP1~AP3 | AP4 |\n|---|---|---|\n| 서명된 JWT | o | x |\n| network topology | x | o |\n| internal token | x | o |\n| edge의 user·email | x | o |\n\nupstream은 edge가 검증한 결과와 edge가 추가한 헤더를 신뢰한다. 따라서 backend 직접 접근과 client가 보낸 동명 identity header를 차단하는 설정이 이 구조의 전제다.\n\n## 헤더를 늘릴 때 정해야 하는 것\n\n현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다.\n\n- claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가\n- allowlist : Nginx가 어느 응답 헤더만 복사하는가\n- 덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가\n- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가\n- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지\n- 갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가\n\n\n## 확인한 것과 확인하지 않은 것\n\n아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분** 이다.\n\n| 항목 | 확인한 부분 |\n|---|---|\n| cookie 없는 root의 302 | o |\n| cookie 없는 `/api/edge`의 401 | o |\n| `edge-proxy` + S256 challenge | o |\n| `AP4_SESSION` HttpOnly · SameSite=Lax | o |\n| 브라우저 요청에 token endpoint 없음 | o |\n| Web Storage 비어 있고 cookie 읽기 불가 | o |\n| 위조 헤더를 보내도 실제 user로 200 | o |\n| 외부 `/oauth2/auth` 404 | o |\n| host의 4180 · 8081 접근 불가 | o |\n| user 헤더 없음 · token 없음 · token 불일치 401 | o |\n| role 전달 | x |\n| 새 endpoint의 공통 강제 | x |\n| 상태 변경 요청의 CSRF | x |\n| session 갱신 | x |\n| replica 간 secret 공유 | x |\n| internal secret 교체 | x |\n\n일곱째 줄이 핵심이다. 요청이 실패하는지 보는 것이 아니라, **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다.\n\n## 증명하지 않는 것\n\n현재 설정은 `/api/edge`와 `/` 요청을 모두 `/edge/me`로 전달한다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy는 검증하지 않았으며 path, method, body, streaming, websocket 동작도 이번 Case의 검증 범위에 포함하지 않았다."
}