From 2d58dea5e10925b081ea57ca2982856e1705b6b5 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 14:59:47 +0900 Subject: [PATCH] feat(ap4): defend forwarded identity headers --- .env.example | 1 + README.md | 3 +- .../EdgeIdentityController.java | 40 ++++++++++++------- backend/src/main/resources/application.yml | 3 ++ .../keycloakpattern/ApiSecurityTest.java | 28 ++++++++++--- docker-compose.yml | 7 +++- docs/ap4-edge-forward-auth.md | 19 +++++++++ e2e/pattern4.mjs | 28 ++++++++----- frontend/Dockerfile | 2 +- .../{nginx.conf => default.conf.template} | 2 + scripts/verify-pattern4.sh | 17 +++++++- 11 files changed, 116 insertions(+), 34 deletions(-) rename frontend/{nginx.conf => default.conf.template} (94%) diff --git a/.env.example b/.env.example index 41b85fc..a07f735 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ TOKEN_MEDIATING_CLIENT_SECRET=change-me-token-mediating-client-secret BFF_CLIENT_SECRET=change-me-bff-client-secret EDGE_PROXY_CLIENT_SECRET=change-me-edge-proxy-client-secret OAUTH2_PROXY_COOKIE_SECRET=generate-a-base64-encoded-32-byte-secret +INTERNAL_AUTH_TOKEN=generate-a-long-random-edge-to-backend-token ADMIN_USER_PASSWORD=change-me-admin-user-password REGULAR_USER_PASSWORD=change-me-regular-user-password diff --git a/README.md b/README.md index dfc2351..5bd2225 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다. OIDC redirect/PKCE/callback과 forwarded-user를 분리 확인합니다. 두 번째 feature부터 `http://localhost:8088` Nginx가 단일 진입점이며, 내부 `auth_request`는 브라우저 요청을 login 302로, API 요청을 JSON 401로 -구분합니다. 최종 feature에서는 backend의 호스트 노출도 제거합니다. +구분합니다. 최종 feature에서는 backend와 oauth2-proxy의 호스트 노출을 +제거하고 Nginx 헤더 덮어쓰기와 내부 토큰 검증으로 spoofing을 막습니다. 자세한 내용은 [`docs/ap4-edge-forward-auth.md`](docs/ap4-edge-forward-auth.md)를 참고하세요. diff --git a/backend/src/main/java/com/example/keycloakpattern/EdgeIdentityController.java b/backend/src/main/java/com/example/keycloakpattern/EdgeIdentityController.java index 733596a..facb714 100644 --- a/backend/src/main/java/com/example/keycloakpattern/EdgeIdentityController.java +++ b/backend/src/main/java/com/example/keycloakpattern/EdgeIdentityController.java @@ -1,44 +1,56 @@ package com.example.keycloakpattern; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.LinkedHashMap; import java.util.Map; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class EdgeIdentityController { + private final byte[] internalAuthToken; + + EdgeIdentityController(@Value("${edge.internal-auth-token}") String internalAuthToken) { + if (!hasText(internalAuthToken)) { + throw new IllegalStateException("edge.internal-auth-token must be configured"); + } + this.internalAuthToken = internalAuthToken.getBytes(StandardCharsets.UTF_8); + } + @GetMapping("/edge/me") ResponseEntity> currentUser(HttpServletRequest request) { String authRequestUser = request.getHeader("X-Auth-Request-User"); - String forwardedUser = request.getHeader("X-Forwarded-User"); - String user = hasText(authRequestUser) ? authRequestUser : forwardedUser; - if (!hasText(user)) { + if (!hasText(authRequestUser) || !hasValidInternalToken(request)) { return ResponseEntity.status(401).body(Map.of( "error", - "trusted edge identity header is required" + "trusted edge authentication is required" )); } Map response = new LinkedHashMap<>(); response.put("pattern", "AP4-edge-forward-auth"); - response.put("user", user); - response.put("email", firstNonBlank( - request.getHeader("X-Auth-Request-Email"), - request.getHeader("X-Forwarded-Email") - )); - response.put("identityHeader", hasText(authRequestUser) - ? "X-Auth-Request-User" - : "X-Forwarded-User"); + response.put("user", authRequestUser); + response.put("email", request.getHeader("X-Auth-Request-Email")); + response.put("identityHeader", "X-Auth-Request-User"); return ResponseEntity.ok(response); } - private static String firstNonBlank(String first, String second) { - return hasText(first) ? first : second; + private boolean hasValidInternalToken(HttpServletRequest request) { + String suppliedToken = request.getHeader("X-Internal-Auth-Token"); + if (!hasText(suppliedToken)) { + return false; + } + return MessageDigest.isEqual( + internalAuthToken, + suppliedToken.getBytes(StandardCharsets.UTF_8) + ); } private static boolean hasText(String value) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 0046b63..86b8e83 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,6 +1,9 @@ server: port: ${SERVER_PORT:8081} +edge: + internal-auth-token: ${EDGE_INTERNAL_AUTH_TOKEN:} + spring: application: name: keycloak-pattern-api diff --git a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java index 2df132b..2d91c27 100644 --- a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java +++ b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java @@ -11,7 +11,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; -@SpringBootTest +@SpringBootTest(properties = "edge.internal-auth-token=test-internal-edge-token") @AutoConfigureMockMvc class ApiSecurityTest { @@ -42,18 +42,34 @@ class ApiSecurityTest { } @Test - void edgeEndpointRejectsMissingIdentityHeader() throws Exception { + void edgeEndpointRejectsMissingTrustedHeaders() throws Exception { mockMvc.perform(get("/edge/me")) .andExpect(status().isUnauthorized()); } @Test - void edgeEndpointCurrentlyTrustsForwardedUserHeader() throws Exception { + void edgeEndpointRejectsForgedIdentityWithoutInternalToken() throws Exception { mockMvc.perform(get("/edge/me") - .header("X-Forwarded-User", "regular-user") - .header("X-Forwarded-Email", "regular-user@example.test")) + .header("X-Auth-Request-User", "spoofed-admin")) + .andExpect(status().isUnauthorized()); + } + + @Test + void edgeEndpointRejectsWrongInternalToken() throws Exception { + mockMvc.perform(get("/edge/me") + .header("X-Auth-Request-User", "spoofed-admin") + .header("X-Internal-Auth-Token", "wrong-token")) + .andExpect(status().isUnauthorized()); + } + + @Test + void edgeEndpointAcceptsIdentityFromTrustedEdge() throws Exception { + mockMvc.perform(get("/edge/me") + .header("X-Auth-Request-User", "regular-user") + .header("X-Auth-Request-Email", "regular-user@example.test") + .header("X-Internal-Auth-Token", "test-internal-edge-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.user").value("regular-user")) - .andExpect(jsonPath("$.identityHeader").value("X-Forwarded-User")); + .andExpect(jsonPath("$.identityHeader").value("X-Auth-Request-User")); } } diff --git a/docker-compose.yml b/docker-compose.yml index fc3c819..5592f89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,8 +69,9 @@ services: SERVER_PORT: "8081" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs - ports: - - "127.0.0.1:8081:8081" + EDGE_INTERNAL_AUTH_TOKEN: ${INTERNAL_AUTH_TOKEN:?set INTERNAL_AUTH_TOKEN in .env} + expose: + - "8081" depends_on: keycloak: condition: service_healthy @@ -141,6 +142,8 @@ services: nginx: build: context: ./frontend + environment: + INTERNAL_AUTH_TOKEN: ${INTERNAL_AUTH_TOKEN:?set INTERNAL_AUTH_TOKEN in .env} ports: - "127.0.0.1:${NGINX_PORT:-8088}:80" depends_on: diff --git a/docs/ap4-edge-forward-auth.md b/docs/ap4-edge-forward-auth.md index 89b30cd..8f3e196 100644 --- a/docs/ap4-edge-forward-auth.md +++ b/docs/ap4-edge-forward-auth.md @@ -49,3 +49,22 @@ Nginx 컨테이너 IP를 전용 Compose subnet에서 고정하고 oauth2-proxy trusted proxy를 그 단일 IP로 제한합니다. 다만 이 단계에서는 backend 8081이 로컬 호스트에 열려 있어 신뢰 헤더를 직접 위조할 수 있습니다. 그 재현 조건은 마지막 feature에서 제거합니다. + +## 마지막 단계: 신뢰 경계와 헤더 스푸핑 방어 + +`feature/keycloak-header-spoofing-defense`에서는 신뢰 경계를 실제 +네트워크와 application 양쪽에서 강제합니다. + +1. backend 8081과 oauth2-proxy 4180은 host에 publish하지 않습니다. + 브라우저가 접근 가능한 application 포트는 Nginx 8088뿐입니다. +2. Nginx는 client가 보낸 `X-Auth-Request-User`, email, 내부 토큰을 + 그대로 전달하지 않고 oauth2-proxy 결과와 server-side 토큰으로 + 항상 덮어씁니다. +3. backend는 `X-Auth-Request-User`와 `X-Internal-Auth-Token`이 모두 + 유효할 때만 edge identity를 받아들이며 token은 constant-time으로 + 비교합니다. + +shared token은 방어 심층화 수단입니다. 운영에서는 Secret Manager나 +orchestrator secret으로 주입하고 주기적으로 교체해야 합니다. 서비스 +간 mTLS 또는 service mesh identity를 사용할 수 있다면 단순 shared +token보다 강한 workload identity로 대체하는 편이 좋습니다. diff --git a/e2e/pattern4.mjs b/e2e/pattern4.mjs index b225586..463d36a 100644 --- a/e2e/pattern4.mjs +++ b/e2e/pattern4.mjs @@ -97,6 +97,20 @@ try { assert.deepEqual(storage.sessionStorage, []); assert.equal(storage.readableCookies.includes("AP4_SESSION"), false); + const spoofAttempt = await page.evaluate(async () => { + const response = await fetch("/", { + headers: { + "X-Auth-Request-User": "spoofed-admin", + "X-Auth-Request-Email": "spoofed-admin@example.test", + "X-Internal-Auth-Token": "attacker-controlled-token", + }, + }); + return { status: response.status, body: await response.json() }; + }); + assert.equal(spoofAttempt.status, 200); + assert.equal(spoofAttempt.body.user, edgeIdentity.user); + assert.notEqual(spoofAttempt.body.user, "spoofed-admin"); + const externalAuthSubrequest = await fetch(`${edgeBaseUrl}/oauth2/auth`); assert.equal(externalAuthSubrequest.status, 404); @@ -111,17 +125,13 @@ try { "oauth2-proxy must not be published on the host", ); - const missingHeader = await fetch("http://localhost:8081/edge/me"); - assert.equal(missingHeader.status, 401); - const directSpoof = await fetch("http://localhost:8081/edge/me", { - headers: { "X-Auth-Request-User": "spoofed-admin" }, - }); - assert.equal(directSpoof.status, 200); - const spoofedIdentity = await directSpoof.json(); - assert.equal(spoofedIdentity.user, "spoofed-admin"); + await assert.rejects( + fetch("http://localhost:8081/edge/me"), + "backend must not be published on the host", + ); console.log( - "pattern4 nginx auth_request verified: internal subrequest, browser redirect, API 401, forwarded identity", + "pattern4 hardened edge verified: auth_request, no backend publish, spoofed headers overwritten", ); } finally { await browser.close(); diff --git a/frontend/Dockerfile b/frontend/Dockerfile index de7eb7e..4fcc703 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,4 @@ FROM nginx:1.29-alpine -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY default.conf.template /etc/nginx/templates/default.conf.template COPY index.html /usr/share/nginx/html/index.html diff --git a/frontend/nginx.conf b/frontend/default.conf.template similarity index 94% rename from frontend/nginx.conf rename to frontend/default.conf.template index 3d6e5d4..635f72f 100644 --- a/frontend/nginx.conf +++ b/frontend/default.conf.template @@ -46,6 +46,7 @@ server { proxy_pass http://app:8081/edge/me; proxy_set_header X-Auth-Request-User $auth_user; proxy_set_header X-Auth-Request-Email $auth_email; + proxy_set_header X-Internal-Auth-Token "${INTERNAL_AUTH_TOKEN}"; } location / { @@ -60,6 +61,7 @@ server { proxy_pass http://app:8081/edge/me; proxy_set_header X-Auth-Request-User $auth_user; proxy_set_header X-Auth-Request-Email $auth_email; + proxy_set_header X-Internal-Auth-Token "${INTERNAL_AUTH_TOKEN}"; } location @oauth2_signin { diff --git a/scripts/verify-pattern4.sh b/scripts/verify-pattern4.sh index dbfef39..3ed1886 100755 --- a/scripts/verify-pattern4.sh +++ b/scripts/verify-pattern4.sh @@ -18,9 +18,24 @@ docker compose exec -T nginx nginx -V 2>&1 | docker compose exec -T nginx nginx -T 2>&1 | grep -q 'proxy_pass_request_body off' +app_container_id="$(docker compose ps -q app)" +published_app_port="$(docker inspect "$app_container_id" \ + --format '{{with (index .NetworkSettings.Ports "8081/tcp")}}{{json .}}{{end}}')" +if [ -n "$published_app_port" ]; then + echo "backend port 8081 must not be published on the host" >&2 + exit 1 +fi + +if docker compose exec -T nginx wget -q -O /dev/null \ + --header 'X-Auth-Request-User: spoofed-admin' \ + http://app:8081/edge/me 2>/dev/null; then + echo "backend accepted a forged identity without the internal token" >&2 + exit 1 +fi + npm --prefix e2e ci E2E_USERNAME=regular-user \ E2E_PASSWORD="$REGULAR_USER_PASSWORD" \ npm --prefix e2e run test:pattern4 -echo "AP4 Nginx auth_request edge flow verified" +echo "AP4 hardened Nginx auth_request edge flow verified"