헤더를 믿는 서비스는 아직 클러스터에 올라간 적이 없었습니다
게이트웨이가 사용자 토큰을 검증하고, 서비스는 게이트웨이가 넣어 준 사용자 정보 헤더를 믿는 구조까지 만들었습니다. 흐름은 이렇습니다.
- 게이트웨이는 사용자 토큰을 검증한 뒤, 바깥에서 들어온
X-User-*헤더를 지우고 검증한 값으로X-User-Id·X-User-Role·X-User-Family-Id를 다시 넣습니다. - 다섯 서비스는 이 헤더를 검증 없이 믿습니다.
- 서비스로 곧장 가는 길은 ClusterIP와 NetworkPolicy로 막았습니다. 정책은 게이트웨이 Pod와 Prometheus만 8080 포트로 들여보냅니다.
서비스의 인증 필터도 이 구조를 전제로 만들었습니다.
/**
* Gateway 가 검증 후 주입한 신뢰 헤더(X-User-Id/X-User-Role/X-User-Family-Id)로
* 인증을 세우는 필터. 서명 검증은 하지 않는다 — Gateway 가 이미 검증했고,
* 외부 유입 헤더는 Gateway 가 strip 후 재주입하며, 직접 경로는 NetworkPolicy 로 차단된다.
*/
public class HeaderAuthenticationFilter extends OncePerRequestFilter {
헤더가 진짜라는 보장은 어디서 왔을까요?
헤더를 믿어도 되는 근거는 게이트웨이 말고는 서비스에 닿을 수 없다는 것 하나였고, 그것을 지키는 장치는 NetworkPolicy뿐이었습니다.
NetworkPolicy가 제 역할을 못 해도 서비스는 알아채지 못합니다. 정책이 강제되지 않거나 라벨이 어긋나 엉뚱한 Pod를 고르더라도, 서비스는 그대로 헤더를 믿습니다. NetworkPolicy가 제대로 동작할 때도 빈틈이 있습니다. 정책이 들여보내는 게이트웨이 Pod 안에서 보낸 요청은 NetworkPolicy에 걸리지 않습니다. 그 위치에 있는 공격자라면 아래 요청 하나로 관리자 행세를 할 수 있습니다.
# 예시: 정책이 강제되지 않을 때의 아무 Pod, 또는 정책이 허용한 게이트웨이 Pod 에서 보내는 요청
curl -X POST http://product-service:8080/api/v1/admin/products \
-H 'X-User-Id: 1' -H 'X-User-Role: ADMIN' \
-H 'Content-Type: application/json' -d '{...}'
상품 관리 API는 hasRole('ADMIN')으로 보호하고 있지만, 서비스는 이 요청을 1번 관리자가 보낸 것으로 받아들입니다. 이 curl은 설명용 예시이고 실제로 보내 보지는 않았습니다. X-User-Role: ADMIN이 ROLE_ADMIN 권한으로 바뀐다는 필터 단위 테스트와 @PreAuthorize 설정을 근거로 한 추론이며, 실제로 측정한 것 중 가장 가까운 결과는 뒤에서 다룹니다.
평문 헤더가 유일한 인증 수단이 되기 직전이었습니다
원래 계획한 다음 단계는 게이트웨이가 서비스로 함께 넘기던 사용자 토큰(Authorization)을 끊고, 서비스에 남은 이전 토큰 검증기를 지우는 것이었습니다. 이 단계를 마치면 평문 헤더가 유일한 인증 근거가 됩니다. 넘어가는 조건은 “실제 클러스터에서 NetworkPolicy가 강제되고 위조 헤더가 막히는가”였는데, 아직 한 번도 확인하지 못한 상태였습니다. 인증 전체가 확인하지 않은 정책 하나에 걸려 있었습니다.
다행히 이 구조는 아직 클러스터에 배포하지 않았습니다. 배포 전이니 인증 방식을 바꿀 여지가 있었습니다.
네트워크 격리 하나에 기대던 신뢰를 무엇으로 받칠까요? 그리고 새로 세운 방어가 실제로 막는다는 것은 어떻게 보일 수 있을까요?
어떻게 접근할 수 있을까요?
NetworkPolicy와 서명은 막는 것이 다릅니다
NetworkPolicy는 어떤 Pod가 서비스에 요청을 보낼 수 있는지를 정하고, 서명 검증은 요청에 담긴 사용자 정보를 누가 만들었는지 확인합니다.
NetworkPolicy 하나로 충분할까요?
NetworkPolicy와 서명 검증을 켜고 끄면 세 요청이 다시 출발합니다. 위조한 ADMIN 헤더가 어디서 막히는지 보세요.
글의 표(NetworkPolicy와 서명이 각각 막는 것)를 그림으로 옮긴 모형입니다. 클러스터에서 실측한 것은 NetworkPolicy와 서명 검증이 모두 켜진 상태의 결과(위조·평문 401, 정상 200, 게이트웨이가 아닌 Pod 000)와, 게이트웨이 Pod 안에서 보낸 평문 헤더가 서명만 받는 모드에서 401·평문도 받는 모드에서 200이 된 것입니다. 나머지 조합의 결과와 ADMIN 위조 요청은 설정과 테스트를 근거로 만든 예시이고, 실제로 보내 보지는 않았습니다. "담 꺼짐"은 정책이 강제되지 않는 상태입니다. 000은 curl이 HTTP 응답 상태를 받지 못했다는 뜻입니다.
| 갖춘 것 | 막는 것 | 뚫리는 것 |
|---|---|---|
| NetworkPolicy만 | 바깥이나 다른 Pod에서 서비스를 직접 호출하는 요청 | 정책이 강제되지 않거나 허용된 Pod 안이라면 헤더 한 줄로 아무 신원이나 만들 수 있습니다 |
| 서명만 | 위조한 신원. 개인키 없이는 유효한 신원을 만들 수 없습니다 | 아무 Pod나 서비스에 닿습니다. 인증이 필요 없는 경로는 열리고, 대량 요청도 막지 못합니다 |
| 둘 다 | 신원을 위조하려면 NetworkPolicy와 개인키를 함께 뚫어야 합니다 |
당시 PeekCart는 첫 줄 상태였습니다. 네트워크 규칙을 더 촘촘히 짜도 같은 줄에 머무니, 신원 자체를 증명하는 두 번째 레이어가 필요했습니다. NetworkPolicy는 그대로 둡니다. 인증 없이 들어오는 직접 요청을 걸러 공격 표면을 줄이는 일은 여전히 NetworkPolicy가 합니다.
신원을 증명하는 방법은 여럿입니다
지금 구조를 그대로 두는 것까지 포함해 다섯 가지를 검토했습니다.
| 방법 | 얻는 것 | 잃는 것 |
|---|---|---|
| 평문 헤더 유지 | 추가 작업이 없고 지연도 가장 낮습니다 | 신뢰가 여전히 NetworkPolicy 하나에 걸립니다 |
| shared secret(HMAC)으로 헤더 서명 | 빠르고, secret 하나만 배포하면 됩니다 | 모든 서비스가 서명용 secret을 가지므로, 서비스 하나만 뚫려도 누구의 신원이든 만들 수 있습니다 |
| 서비스 메시의 mTLS | 진짜 게이트웨이가 보냈다는 것을 전송 계층에서 증명합니다 | 사이드카와 컨트롤 플레인을 새로 들여야 합니다 |
| 사용자 토큰을 그대로 넘기고 서비스마다 다시 검증 | 신뢰 헤더가 필요 없습니다 | 검증 로직이 다섯 번 중복되고, 서비스마다 차단 목록을 Redis에서 다시 조회합니다. 검증을 게이트웨이로 모으면서 없앤 중복입니다 |
| 게이트웨이가 개인키로 서명한 내부 토큰 | 서명은 게이트웨이만 하고, 서비스는 공개키로 검증만 합니다 | 요청마다 RSA 검증이 한 번 늘고, 게이트웨이가 관리할 개인키가 하나 생깁니다 |
shared secret과 비대칭 서명은 뚫렸을 때의 피해 범위가 다릅니다. shared secret은 검증하는 쪽도 서명할 수 있지만, 비대칭 서명에서는 서비스가 뚫려도 공개키만 나갑니다. 신원을 위조할 수 있는 곳이 게이트웨이 하나로 줄어듭니다. mTLS는 이번 단계에서 하기엔 규모가 컸고, 메시 없이도 위조 방어의 핵심은 얻을 수 있어서 이후 후보로 남겼습니다.
내부 토큰은 이렇게 생겼습니다
게이트웨이는 사용자 토큰 검증에 성공하면 평문 헤더 대신 X-Internal-Auth 헤더 하나를 넣습니다. 값은 게이트웨이 개인키로 서명한 짧은 JWT입니다.
| 항목 | 값 |
|---|---|
| 알고리즘 | RS256만 허용 |
발행자(iss) | peekcart-gateway |
| 신원 | sub(사용자 ID), role(USER · ADMIN), fid(refresh token family) |
| TTL | 게이트웨이는 30초로 발행합니다. 서비스는 TTL이 120초를 넘는 토큰을 거부하고, clock skew는 5초까지만 허용합니다 |
| 키 | 사용자 토큰 서명키와 다른, 게이트웨이 전용 키 쌍 |
이름들은 설정으로 바꿀 수 없게 코드 상수로 고정했습니다. 발행자 이름을 설정으로 바꿀 수 있으면, 설정 하나로 발행자 확인을 우회할 수 있습니다.
public static final String HEADER = "X-Internal-Auth";
/** 내부 토큰의 유일한 발행자. 검증측은 이 값으로 iss 를 핀한다(사용자 access token 오용 차단). */
public static final String ISSUER = "peekcart-gateway";
/** 서명 알고리즘. 검증측은 이 값만 허용한다(alg 혼동/HS fallback 차단). */
public static final String ALGORITHM = "RS256";
서비스는 서명에 더해 발행자와 키 ID(kid)도 게이트웨이 값으로 고정해서 확인합니다. 사용자 access token도 제대로 서명된 RS256 JWT라서, 서명만 확인하면 사용자 토큰을 내부 토큰 자리에 넣어도 통과합니다. 그러면 게이트웨이를 거치지 않은 토큰으로도 인증을 받게 됩니다.
로그아웃 확인과 탈취 차단은 게이트웨이에 그대로 둡니다. 서비스는 30초짜리 토큰의 서명만 확인하므로 Redis를 다시 조회하지 않습니다. 공개키 로딩과 JWT 파싱은 사용자 토큰 검증 코드를 대부분 재사용했습니다.
평문 단계는 건너뛰고 바로 서명으로 갑니다
평문 신뢰를 먼저 배포했다가 나중에 바꿀 이유는 없었습니다. 다만 무중단 전환을 가정하면, 서비스가 평문과 서명을 둘 다 받는 구간이 잠깐 필요합니다. 순서는 이렇게 정했습니다.
- 실제 클러스터에서 NetworkPolicy가 강제되는지부터 확인합니다. 확인되지 않으면 여기서 멈춥니다.
- 서비스가 평문과 서명 토큰을 둘 다 받게 배포합니다.
- 게이트웨이가 서명 토큰을 넣게 배포합니다.
- 서비스가 서명 토큰만 받게 바꿉니다.
- 위조 토큰과 평문 헤더가 막히는지 다시 확인합니다.
- 게이트웨이가 넘기던 사용자 토큰을 끊고, 서비스의 이전 검증기를 지웁니다.
1번을 맨 앞에 둔 것은 2번 때문입니다. 2번 구간의 서비스는 평문도 받으므로, 그때 NetworkPolicy가 제대로 강제되지 않으면 평문 위조가 그대로 통합니다. 단계마다 모든 Pod가 새 상태로 바뀐 것을 확인하고 나서 다음으로 넘어갑니다.
해 보려니 이런 문제가 있었습니다
계획의 전제가 코드와 달랐습니다
코드를 쓰기 전에 계획이 전제로 삼은 사실을 지금 코드와 다시 맞춰 봤더니, 세 가지가 달랐습니다.
| 계획의 전제 | 실제 코드 | 바뀐 것 |
|---|---|---|
| 이전 토큰 검증 필터가 살아 있어, 전환 중에 사용자 토큰으로 게이트웨이를 우회할 수 있다 | 다섯 서비스 모두 이미 헤더 신뢰 필터만 썼고, 이전 필터는 어디에도 연결되지 않은 죽은 코드였습니다 | 계획에서 가장 크게 본 위험이 실제로는 없었습니다. 이전 검증기 삭제는 순서와 상관없는 정리 작업이 됐습니다 |
| 롤백하려면 이전 검증기와 새 필터를 함께 담은 전용 이미지가 필요하다 | 같은 이유로 필요 없었습니다 | 되돌릴 대상은 직전 릴리스 이미지입니다 |
| user-service 개인키를 배포하는 매니페스트가 있다 | 없었습니다. 앞선 GKE 세션에서는 임시 k8s Secret으로 버텼습니다 | 사용자 토큰 개인키도 키 배포를 매니페스트로 정리하는 범위에 넣었습니다 |
그대로 따를 수 없는 계획도 있었습니다. 기존 계획은 게이트웨이 공개키를 서비스가 쓰던 공개키 목록(app.jwt.rs256.public-keys)에 추가하는 것이었는데, user-service는 이 목록을 JWKS 엔드포인트로 통째로 공개합니다.
@GetMapping("/.well-known/jwks.json")
public Map<String, List<Map<String, String>>> jwks() {
List<Map<String, String>> keys = publicKeyRegistry.all().entrySet().stream()
계획대로 했다면 내부 토큰 검증에 쓰는 게이트웨이 공개키가 외부에 공개하는 엔드포인트에 함께 실렸을 것입니다.
발행하는 쪽과 검증하는 쪽이 코드를 공유할 수 없었습니다
내부 토큰은 게이트웨이(WebFlux)가 만들고 공용 인증 모듈(servlet)이 검증합니다. 게이트웨이가 공용 모듈에 의존하면 servlet이 딸려 들어와 부팅이 깨집니다. 게이트웨이를 처음 붙일 때 검증기를 게이트웨이 안에 따로 만들어야 했던 것도 같은 이유였습니다. 헤더·발행자·claim 이름을 양쪽에 문자열로 따로 적으면 한쪽만 바뀌어도 아무 경고 없이 어긋나고, 두 모듈을 한 테스트에서 함께 띄울 수도 없었습니다.
클러스터가 없으면 검사할 대상도 없었습니다
새 게이트웨이는 개인키 파일이 없으면 부팅하지 않습니다. 개인키는 Secret Manager에서 CSI 드라이버로 마운트할 계획이었으니, 이 인프라가 없으면 매니페스트를 만들어도 게이트웨이를 띄울 수 없었습니다. 그런데 당시 클러스터는 0개였습니다. 앞선 GKE 세션을 마치면서 모두 지웠기 때문입니다.
작업 순서도 걸렸습니다. “개인키는 그 키를 소유한 워크로드 하나만 마운트한다”를 검사하는 lint를 매니페스트보다 먼저 짜면, 검사할 대상이 없으니 항상 통과합니다. 아무것도 확인하지 않고 통과하는 상태(vacuous-green)입니다.
롤아웃 계획도 그대로 쓸 수 없었습니다. 클러스터가 비어 있으니 교체할 이전 이미지도, 흐르는 트래픽도 없습니다. 결국 여섯 단계는 빈 클러스터 위에서 절차를 한 번 돌려 보는 리허설로 할 수밖에 없었습니다.
Workload Identity를 쓰기로 했는데, 쓸 계정이 없었습니다
키 배포 매니페스트를 만들고 한 달 뒤, 클러스터 세션을 준비하며 착수 전 확인을 다시 돌리다가 하나를 더 찾았습니다. 기존 계획은 개인키를 마운트하는 SecretProviderClass의 노드 인증을 Workload Identity로 하고, 정적 자격증명(nodePublishSecretRef)은 lint로 막는 것이었습니다.
Workload Identity는 Pod의 k8s ServiceAccount를 GCP 서비스 계정에 연결해, 그 신원으로 Secret Manager에 접근하게 하는 방식입니다. 그런데 k8s/ 전체에서 kind: ServiceAccount도 serviceAccountName도 0건이었습니다. 모든 Pod가 네임스페이스의 default 계정으로 뜨고 있었습니다.
이대로 클러스터에 올렸다면 둘 중 하나였을 것입니다. CSI가 키를 가져오지 못해 게이트웨이가 뜨지 않고, 클러스터를 켜 둔 채 고치는 동안 비용이 나갑니다. 아니면 급한 대로 default 계정에 권한을 줘서, 네임스페이스의 모든 Pod가 서명 개인키를 꺼낼 수 있게 됩니다.
기존 lint가 이것을 놓친 이유도 있습니다. lint는 개인키 볼륨을 누가 마운트하는지는 꼼꼼히 봤습니다. 하지만 Secret Manager 접근 권한은 볼륨에 붙지 않고 Pod의 신원에 붙습니다. 권한 있는 계정으로 뜬 Pod는 볼륨 없이도 gcloud secrets versions access로 키를 읽을 수 있습니다.
검증 도구가 먼저 틀렸습니다
막는 장치와 함께, 그 장치가 실제로 막는지 확인할 도구도 만들었습니다. 그런데 리뷰에서 나온 결함 중 상당수가 도구 쪽에 있었습니다.
| 도구 | 결함 | 결과 |
|---|---|---|
| ”사용자 토큰을 서비스에 직접 보내면 거부” 테스트 | 처음부터 깨진 문자열을 보냈습니다 | 이전 검증기가 되살아나도 통과합니다 |
| 부팅 시 필터 구성 검사 | 보안 필터 체인이 0개면 그냥 넘어갔습니다 | 체인이 사라진 상태를 통과시킵니다 |
| 키 도메인 분리 검사 | 테스트용 키 하나만 비교했습니다 | 다른 키가 섞여도 통과합니다 |
| 개인키 마운트·소유 lint | SecretProviderClass와 워크로드를 이름으로만 봤고, 개인키 파일을 이름 정규식으로 찾았습니다 | 가리키는 키만 바꾸는 우회, Job/gateway, bundle.pem 속 개인키가 통과합니다 |
| rollout gate 스크립트 | 안에 들어 있던 python 코드가 이스케이프 문제로 파싱조차 되지 않았습니다 | 클러스터가 없어 한 번도 실행된 적이 없고, 실제 세션에서 처음 돌렸다면 그 자리에서 실패했을 코드였습니다 |
판정 기준이 틀린 곳도 있었습니다. “게이트웨이로 요청해 200이 나오면 서명 토큰을 넣고 있다”는 판정은 서비스가 서명만 받을 때만 맞습니다. 평문도 받는 구간에서는 평문을 넣어도 200이 나옵니다.
서명 토큰 구현보다 검증 쪽에 남은 일이 더 많았습니다. 클러스터 없이 확인할 수 있는 것과 없는 것을 나누고, 각 검사가 실제로 문제를 잡는지 보여야 했습니다.
좀 더 적절한 해결책은 무엇일까요?
클러스터 없이 통과할 수 있는지로 나눴습니다
한 PR로 묶으면 코드는 CSI 인프라를 기다려야 하고, lint는 검사할 대상이 없습니다. 그래서 작업을 클러스터 없이 끝까지 통과할 수 있는가를 기준으로 나눴습니다.
| 단위 | 담은 것 | 클러스터 없이 확인한 것 | 일부러 남긴 것 |
|---|---|---|---|
| 코드 | 내부 토큰 발행·검증, 이전 검증기 삭제 | 10개 모듈 빌드·테스트, 키 도메인 lint | 개인키 마운트가 없어 게이트웨이는 배포 불가 |
| 매니페스트·lint·스크립트 | 개인키 CSI 마운트, 공개키 ConfigMap, 키 소유 lint, rollout gate, smoke 테스트 확장 | 렌더, lint 통과, 일부러 틀린 입력에서 lint가 실패하는지 | Secret Manager에 키가 없어 실제 적용 불가 |
| 클러스터 확인 | 실제 키 주입, 롤아웃 리허설, 위조 차단 측정 | 없음. 이 단위만 클러스터에서 합니다 |
단위마다 같은 원칙을 지켰습니다. 렌더가 통과한 것을 배포 가능으로 기록하지 않았고, 확인하지 못한 것은 확인하지 못했다고 남기고, 다음 단위를 시작하는 조건으로 넘겼습니다. 매니페스트를 먼저 만들고 lint는 그 뒤에 붙인다는 순서도 정해 두었습니다.
Workload Identity 연결은 원래 클러스터 확인 단위에 들어갈 일이었습니다. 하지만 클러스터 없이 끝낼 수 있는 일이고, 클러스터를 켜 둔 채 고치면 그 시간만큼 비용이 듭니다. 그래서 단위 경계를 넘는 이유를 계획에 남기고, 클러스터를 켜기 전에 별도 PR로 냈습니다.
리허설도 건너뛰지 않았습니다. 건너뛰면 전환 절차, rollout gate의 완료 판정, 롤백 방법이 한 번도 실행해 보지 않은 문서로 남습니다. 평문과 서명을 함께 받는 모드가 제대로 동작하는지도 모른 채 그 모드를 지우게 됩니다. 대신 결과를 정리할 때 “무중단 전환을 실증했다”고는 쓰지 않고, **“그 절차를 빈 클러스터 위에서 재현했다”**까지만 쓰기로 했습니다.
이름은 한 곳에 두고, 키는 fingerprint로 비교했습니다
게이트웨이와 공용 모듈이 서로 의존할 수 없으니, 둘 다 의존할 수 있는 모듈을 하나 더 뒀습니다. 헤더·발행자·claim 이름만 담고 프레임워크에 의존하지 않는 순수 Java 모듈(internal-token-contract)입니다. 게이트웨이의 servlet 의존 금지 검사에는 이 모듈만 예외로 열고, 그 예외로 다른 의존이 따라 들어오지 않도록 이 모듈이 다른 프로젝트 모듈이나 Spring에 의존하면 실패하는 검사를 추가했습니다. 이 모듈의 규칙대로 서명한 토큰은 공용 테스트 fixture로 커밋해, 양쪽이 각자 그 토큰으로 테스트합니다. 한쪽만 바뀌면 다른 쪽 테스트가 깨집니다.
게이트웨이 공개키는 사용자 토큰 공개키 목록과 분리된 전용 목록(app.internal-token.public-keys)에 둡니다. 두 목록이 섞이지 않았는지를 키 ID로 확인하면, 같은 키를 다른 ID로 넣었을 때 잡지 못합니다. 그래서 공개키 DER 인코딩의 SHA-256 fingerprint로 비교했습니다. 같은 키는 어떤 ID와 형식으로 들어와도 fingerprint가 같습니다. lint와 다섯 서비스의 통합 테스트 양쪽에서 두 목록의 fingerprint가 하나도 겹치지 않는지 확인합니다.
개인키는 etcd를 거치지 않게 했습니다
k8s Secret은 etcd에 base64로 저장될 뿐이라, kubectl get secret 권한만 있으면 원문을 볼 수 있습니다. 내부 토큰 개인키는 NetworkPolicy가 뚫렸을 때 남는 마지막 방어선이고, 새어 나가면 모든 서비스에 대해 신원을 위조할 수 있습니다.
그래서 개인키는 Secret Manager에 두고, CSI 드라이버가 노드의 메모리 파일시스템(tmpfs)에 바로 마운트하게 했습니다. 받아 온 secret을 다시 k8s Secret으로 만드는 옵션(secretObjects)과 정적 자격증명 옵션(nodePublishSecretRef)은 이 이점을 없애므로 lint로 막았습니다. 사용자 토큰 개인키도 같은 방식으로 옮겼습니다.
공개키는 secret이 아니지만 이미지에 넣지 않고 ConfigMap에 뒀습니다. 키를 로테이션하는 동안 공개키 목록은 1개에서 2개로, 다시 1개로 바뀝니다. 이미지에 넣어 두면 로테이션할 때마다 빌드를 거쳐야 합니다. ConfigMap 수정과 재시작만으로 끝나야 “새 공개키 배포 → 전체 반영 확인 → 서명키 전환” 순서를 지키기 쉽습니다.
볼륨 대신 ServiceAccount를 검사했습니다
개인키를 쓰는 게이트웨이와 user-service에 각각 전용 ServiceAccount를 만들고, 서로 다른 GCP 서비스 계정에 연결했습니다. 각 GCP 계정에는 자기 secret 하나를 읽는 권한만 줬습니다. 한 계정에 두 권한을 몰아주면 CSI 마운트를 나눠 놓아도 계정 권한으로 서로의 키에 접근할 수 있게 되고, 이것은 렌더 결과만 봐서는 드러나지 않습니다.
키 소유 lint에는 검사 두 개를 추가했습니다. 키를 소유한 워크로드가 전용 ServiceAccount를 쓰고 그 계정이 승인된 GCP 계정에 정확히 연결됐는지, 그리고 그 ServiceAccount를 다른 워크로드가 빌려 쓰지 않는지입니다. 두 번째 검사가 제대로 잡는지는 틀린 입력 하나로 확인했습니다. 볼륨은 하나도 마운트하지 않고 게이트웨이의 ServiceAccount만 빌려 쓰는 Job입니다. 기존 볼륨 검사는 모두 통과하고 새 검사만 실패합니다.
차단 검사에는 양성 대조를 붙였습니다
“막혔다”를 확인하는 검사는 막을 대상이 처음부터 없어도 통과합니다. 이 함정은 서명 작업을 시작하기 전, 미뤄 둔 GKE 보안 smoke 테스트를 돌릴 때 먼저 만났습니다.
검사 하나는 “서비스로 가는 직접 경로 5개에 모두 닿지 않는다”였습니다. 그런데 새로 만든 클러스터에는 직접 경로가 처음부터 없었고, 로드밸런서도 모두 내부용이라 VPC 밖에서 돌리면 어느 주소에서도 응답이 오지 않습니다. 그대로 돌렸다면 아무것도 확인하지 않고 통과했을 것입니다. 그래서 앞서 지운 서비스 노출 설정을 git에서 되살려 직접 경로를 실제로 만들고, VPC 안의 VM에서 세 상태를 차례로 측정했습니다.
| 상태 | 직접 경로 | NetworkPolicy | 결과 |
|---|---|---|---|
| ① | 있음 | 없음 | 200. 검사가 닿는 경로를 잡아낼 수 있음을 보여 주는 양성 대조 |
| ② | 있음 | 있음 | 000. NetworkPolicy 하나만의 효과 |
| ③ | 없음(ClusterIP) | 있음 | 000. 노출 자체를 없앤 상태 |
000은 curl이 HTTP 응답 상태를 받지 못했다는 뜻입니다. ①에서 ③으로 바로 가면 노출 제거와 정책 적용이 한꺼번에 일어나 무엇 때문에 막혔는지 가를 수 없어서, 중간에 ②를 넣었습니다. 이 smoke 테스트는 barrier(막혀야 하는 요청) 5개와 canary(통과해야 하는 요청) 3개를 모두 통과했고, 롤아웃 1단계의 조건을 채웠습니다.
이후 모든 검사에 같은 원칙을 적용했습니다. 막혔는지 확인하는 검사 옆에는, 그 검사가 통과를 잡아낼 수 있음을 보이는 양성 대조를 둡니다.
- 위조 토큰이 401이라는 확인 옆에는 정상 서명 토큰은 200이라는 대조를 둡니다. 모든 요청을 거부하는 고장 난 상태를 통과로 착각하지 않기 위해서입니다.
- “사용자 토큰을 서비스에 직접 보내면 거부” 테스트는 진짜 유효한 사용자 토큰을 보내고, 서비스 쪽에 그 토큰의 검증키를 일부러 등록해 둡니다. 이전 검증기가 되살아나면 테스트가 실제로 깨집니다.
- 양성 대조의 기대값은 405 · 200 · 404처럼 구체적인 상태 코드로 고정합니다. “401만 아니면 된다”로 두면 403이나 5xx도 통과합니다.
- lint self-test는 오류 ID가 나왔는지만 보지 않고, 오류 ID마다 몇 번 나와야 하는지까지 봅니다. 틀린 입력 사례는 35종에서 49종으로 늘었습니다.
- 서명 주입 판정은 먼저 서비스가 서명만 받는 상태인지 확인하고, 그렇지 않으면 판정하지 않습니다.
최종적으로 이렇게 됐습니다
NetworkPolicy와 서명이 각각 무엇을 막는지 클러스터에서 확인했습니다
클러스터 확인은 NetworkPolicy를 강제하는 Dataplane V2와 Workload Identity를 켠 GKE 클러스터에서 두 번의 세션으로 진행했습니다. 빈 클러스터에서 시작했으니 운영 중인 서비스를 전환해 본 것은 아닙니다. 빈 클러스터 위의 리허설이고, 결과 기록 첫머리에도 그렇게 밝혀 두었습니다.
먼저 NetworkPolicy를 다시 확인했습니다. 세 상태 측정에서 직접 경로 5개는 ①에서 모두 200, ②와 ③에서 모두 000이었습니다. 게이트웨이가 아닌 Pod에서 서비스로 보낸 요청도 막혔습니다.
다음은 서명입니다. 서비스가 서명 토큰만 받는 상태에서 네 가지 요청을 보냈습니다.
| 보낸 것 | 결과 | 의미 |
|---|---|---|
아무 키로나 서명한 위조 X-Internal-Auth | 401 | 게이트웨이 개인키 없이는 신원을 만들 수 없습니다 |
평문 X-User-* | 401 | 평문 헤더로는 더 이상 인증되지 않습니다 |
| 정상 로그인 토큰 | 200 | 양성 대조. 위의 401은 모든 요청을 막아서 나온 결과가 아닙니다 |
| 게이트웨이 Pod 안에서 서비스로 직접 보낸 유효한 사용자 토큰 | 401 | 서비스에 사용자 토큰 검증기가 남아 있지 않습니다 |
앞에서 허용된 게이트웨이 Pod 안에서는 헤더를 마음대로 넣을 수 있다고 했습니다. 이 경로도 측정했습니다. 원래는 “서명만 받는 모드에서 평문도 받는 모드로 되돌리는 롤백”을 보려던 측정이었습니다. 그런데 게이트웨이를 거치면 바깥에서 온 헤더는 항상 지워지니, 모드와 상관없이 401이 나옵니다. 그래서 게이트웨이 Pod 안에서 직접 보내는 쪽으로 측정 위치를 옮겼습니다.
| 서비스 모드 | 게이트웨이 Pod → order-service, 평문 X-User-* |
|---|---|
| 서명만 받음 | 401 |
| 평문도 받음(환경 변수 하나로 전환) | 200 |
| 다시 서명만 받음 | 401 |
세 번 모두 order-service 이미지 digest는 같았습니다. 평문도 받는 모드에서는 허용된 Pod에서 보낸 평문 헤더가 그대로 인증됐고, 서명만 받는 모드에서는 거부됐습니다. 다만 셸에서 평문 헤더를 보낸 측정이라, 게이트웨이 Pod를 장악해 개인키까지 읽어 가는 경우는 다루지 않습니다.
키 보관 상태도 확인했습니다. 게이트웨이 Pod 안의 키 파일은 읽기 전용이었고, 네임스페이스의 모든 k8s Secret에서 PRIVATE KEY 문자열은 0건이었습니다. Workload Identity 연결은 권한을 빼 보는 방식으로 확인했습니다. GCP 계정에서 secret 읽기 권한을 빼자 게이트웨이 Pod는 PermissionDenied를 내며 ContainerCreating에서 멈췄고, 권한을 돌려주자 다시 떴습니다.
이 음성 대조에서도 함정을 만났습니다. 권한을 뺀 직후에 배포한 Pod는 정상으로 떴습니다. IAM 권한 변경이 퍼지는 데 시간이 걸렸기 때문입니다. Pod를 지워서 다시 마운트하게 하자 그제야 막혔습니다. 거기서 결론을 냈다면 “권한을 빼도 뜨니 이 연결은 쓸모없다”고 잘못 기록했을 것입니다.
리허설 중에 찾은 문제
리허설은 서명 도입 전 상태에서 시작해야 했지만, 빈 클러스터에 그대로 배포하면 커밋된 기본값 때문에 곧장 “서명만 받음” 상태가 됩니다. 그래서 시작 상태를 직접 만들었습니다. 다섯 서비스를 먼저 “평문도 받음”으로 올린 다음, 게이트웨이를 서명 도입 전 이미지로 내렸습니다. 순서를 바꾸면 서명만 받는 서비스가 평문을 거부해 모든 요청이 401이 됩니다. 현재 이미지에 평문 주입 기능을 잠시 되살리는 방법은 쓰지 않았습니다. 확인을 위해 이미 없앤 공격 표면을 다시 여는 일이기 때문입니다.
공개키 누락은 양성 대조를 돌리고 나서야 드러났습니다. 사용자 토큰 개인키는 Secret Manager의 새 키로 바뀌었는데 공개키는 이미지에 들어 있던 개발용 키 그대로여서, 서명은 되는데 검증은 실패하는 상태였습니다. 공개 경로 200과 “토큰 없이 보호 경로에 가면 401”은 키가 어긋나도 똑같이 나오므로 barrier는 모두 통과했습니다. 토큰을 들고 보호 경로에 가면 200인지 확인하고 나서야 문제가 보였습니다. 사용자 토큰 쪽에는 공개키를 바꿔 넣을 방법이 없어서, 첫 세션에서는 개발용 키 쌍을 Secret Manager에 올려 넘어갔고 운영 전용 키로 돌렸다는 것은 증명하지 못한 채 남겼습니다. 같은 세션에서 kubectl apply -k를 할 때마다 게이트웨이 replica가 HPA 최소값(2) 아래로 줄어드는 결함도 찾았습니다. 두 결함을 별도 PR로 고친 뒤, 두 번째 세션에서는 사용자 토큰과 내부 토큰 모두 운영 전용 키로 돌렸습니다.
두 번째 세션에서는 내부 토큰 서명키를 실제로 로테이션했습니다.
| 단계 | 결과 |
|---|---|
| ① 새 공개키를 먼저 배포(이전 키 유지) | ConfigMap에 키 쌍 추가 |
| ② 다섯 서비스 반영 확인 | 모든 서비스가 두 kid를 함께 받음 |
③ 개인키와 active-kid 전환 | 로그인 200, 보호 경로 200. 끊김 없음 |
| ④ 이전 토큰이 만료되기를 기다림 | 80초(TTL 30초 + clock skew 5초 + 여유) |
| ⑤ 이전 키 제거 | 이전 kid가 실제로 사라짐 |
⑤에서 이전 kid가 정말 사라질지는 확실하지 않았습니다. 착수 전에는 Spring이 설정 목록을 항목별로 합친다고 생각했는데, 테스트해 보니 우선순위가 가장 높은 설정 소스가 목록을 통째로 덮어썼습니다. 덕분에 신뢰할 kid 목록은 ConfigMap 한 곳에서만 정해지고, 거기서 키를 빼면 실제로 폐기됩니다. 클러스터에서도 같았습니다.
새 공개키를 먼저 배포하지 않으면 어떻게 되는지도 확인했습니다. 게이트웨이부터 새 키로 바꾸자, 이전 Pod와 새 Pod가 섞여 있는 동안에는 401 200 200처럼 일부 요청만 실패했고 모든 Pod가 바뀐 뒤에야 전부 401이 됐습니다. rollout gate는 이미지·replica·이전 ReplicaSet 정리가 모두 정상이라 이 상태를 “반영 완료”로 통과시켰습니다. 이 상태는 Pod마다 실제로 요청을 보내 서명을 확인하는 probe만 잡아냈습니다.
롤백 절차에도 잘못된 부분이 있었습니다. active-kid를 이전 값으로 되돌렸는데도 401이 계속 나왔습니다. Secret Manager의 versions/latest가 여전히 새 개인키를 가리키고 있어서, 새 키로 서명하고 이전 kid를 붙인 토큰이 나가고 있었습니다. kid와 개인키는 짝이라 둘 다 되돌려야 합니다. 새 키 버전을 비활성화하자 상황이 오히려 나빠졌습니다.
FailedPrecondition desc = Secret Version [.../versions/3] is in DISABLED state.
versions/latest는 비활성화된 버전을 건너뛰지 않아서, 401이 나던 상태에서 Pod가 아예 뜨지 못하는 상태로 나빠졌습니다. 이전 키를 새 버전으로 다시 올리자 복구됐습니다. 같은 로테이션 방식을 쓰는 사용자 토큰도 기존 롤백 절차는 active-kid만 이전 kid로 되돌리고 재시작하는 것이었습니다. 이번 측정 결과를 근거로 같은 PR에서 그 절차를 고쳤습니다.
사용자 토큰 로테이션은 로컬에서 한 가지만 따로 확인했습니다
사용자 토큰은 키를 알게 되는 경로가 다릅니다.
| 내부 토큰 | 사용자 토큰 | |
|---|---|---|
| 공개키를 쓰는 쪽 | 다섯 서비스 | 게이트웨이 하나 |
| 키를 알게 되는 경로 | ConfigMap, 부팅할 때 | user-service의 JWKS를 HTTP로 조회. 모르는 kid를 만난 순간 |
게이트웨이는 user-service Service를 통해 JWKS를 조회합니다. 로테이션 중에 그 조회가 아직 새 키를 모르는 이전 Pod로 가면, 정상적인 새 토큰도 401이 됩니다. 기존 계획은 로테이션 전에 모든 Pod의 JWKS를 Pod IP로 직접 확인해서 이 위험을 막는 것이었지만, 위험도 사전 확인도 실제로 돌려 본 적은 없었습니다.
이 문제는 클라우드 쪽보다 k8s Service 동작에서 생기고, Secret Manager와 Workload Identity 쪽은 두 번째 세션에서 이미 확인했습니다. 그래서 minikube에서 확인했고, 사용자 토큰 개인키는 k8s Secret으로 대신 넣었습니다. 이 drill로 말할 수 있는 것은 이 문제 하나를 재현하고 확인했다는 데까지입니다. 사용자 토큰 로테이션을 운영 환경에서 끝까지 해 본 것은 아닙니다.
첫 시도는 실패했습니다. 모든 Pod에 새 키가 퍼지기 전에 로테이션하며 1초 간격으로 요청을 보냈는데 401이 나오지 않았습니다. 새 토큰이 처음 발급된 시점에는 이미 모든 Pod가 새 키를 알고 있었습니다. 타이밍에 따라 결과가 달라지는 경쟁 상태라 한 번의 시도로는 판단할 수 없어서, 상태를 고정해 매번 같은 결과가 나오게 재현하기로 했습니다.
이전 키만 아는 Pod 3개와 새 키도 아는 Pod 1개를 띄워 두고, 게이트웨이를 다섯 번 재시작하며 121번 로그인했습니다. 121번 모두 이전 키 Pod가 응답했고, 새 Pod는 한 번도 응답하지 않았습니다. 요청마다 무작위로 나뉜다면 이렇게 될 확률은 0.75^121, 약 10^-15입니다. 같은 Service를 요청마다 새 커넥션으로 부르자 20번 중 7번이 새 Pod로 갔습니다. 게이트웨이는 커넥션을 재사용하므로 처음 연결된 Pod에 계속 붙어 있게 됩니다. 어느 Pod가 응답할지 모른다는 기존 예상보다 나쁜 동작입니다.
- 한 번 이전 Pod에 붙으면 JWKS 재조회 대기(10초)가 지나도 같은 커넥션을 쓰므로 저절로 회복되지 않습니다.
- 새 Pod에 붙은 게이트웨이 인스턴스는 계속 정상이라 일부 사용자만 401을 받습니다. 모든 요청이 401이 되는 것을 장애 신호로 삼으면 이 상황을 놓칩니다.
원인을 확인하려고 로그인 경로와 JWKS 경로를 각각 Pod IP로 고정하고, JWKS에 응답하는 Pod만 바꿨습니다.
| 로그인 | JWKS 조회 | 결과 | |
|---|---|---|---|
| D1 | 새 Pod | 이전 Pod | 새 kid 토큰 401 × 10/10 |
| D2 | 새 Pod | 새 Pod | 새 kid 토큰 200 × 10/10 |
토큰의 kid는 양쪽이 같고 달라진 것은 JWKS에 응답한 Pod 하나뿐이니, 401은 그 Pod 때문입니다. 같은 상태에서 로테이션 전 사전 확인을 돌리자 이전 kid만 가진 Pod 3개를 정확히 짚어 로테이션을 막았습니다. 정상 순서로 로테이션했을 때는 전환하는 동안 1초 간격으로 보낸 요청 79번 중 401이 한 번도 없었습니다.
인증 실패를 세기로 했는데, 세는 코드가 없었습니다
관측성 계획에는 5월부터 인증 실패를 게이트웨이와 user-service에서 센다는 항목이 있었지만, 게이트웨이에는 그 메트릭을 세는 코드가 하나도 없었습니다. 마무리 PR에서 거부 사유별 카운터를 넣었습니다. 응답은 invalid_token 하나로 통일하고, 서명 오류·만료·허용되지 않은 알고리즘 같은 세부 사유는 메트릭에서만 나눕니다. 응답에 세부 사유를 담으면 위조를 시도하는 쪽에 힌트를 주게 되기 때문입니다. 두 번째 세션에서 위조 토큰을 보내 카운터가 실제로 오르는 것을 확인했고, 정상 요청 31,873건에서 인증 실패는 0건이었습니다.
인증 경로의 비용은 경로 전체 기준으로만 측정했습니다. 경로당 15 req/s에서 인증 경로 p95는 23.61ms, 공개 경로는 13.67ms였습니다. 이 차이 9.9ms에는 서명 외에도 JWT 파싱, Redis 차단 조회, 다운스트림 홉이 모두 들어 있어서 서명만의 비용으로 볼 수는 없습니다.
더 좋은 방법은 없었을까요?
여기부터는 작업을 마친 뒤 돌아본 사후 분석입니다. 작업하는 동안에는 이 관점들을 대안으로 검토하지 않았고, 이후의 측정 결과와 외부 자료에 비춰 다시 살펴본 내용입니다.
서명으로는 토큰을 어느 서비스에 보냈는지까지 증명할 수 없습니다
내부 토큰에는 받을 서비스를 적는 aud도, 토큰마다 고유한 jti도 없습니다. 클러스터 안에서 X-Internal-Auth 하나를 가로채면 유효 기간 동안 다섯 서비스 어디로든 다시 보낼 수 있습니다. 그 기간은 TTL 30초에 clock skew 5초를 더한 최대 약 35초이고, 게이트웨이에서 서비스로 가는 구간은 평문 HTTP입니다. 예를 들어 order-service로 가던 관리자 요청의 토큰을 가로채면, 그 35초 동안 상품 관리 API에도 관리자로 들어갈 수 있습니다. claim 구성과 검증 규칙으로 따져 본 시나리오이고, 실제로 해 보지는 않았습니다.
JWT 모범 사례를 정리한 RFC 8725는 이 공격을 따로 다룹니다.
“There are attacks in which one recipient will be given a JWT that was intended for it and will attempt to use it at a different recipient for which that JWT was not intended.” (§2.7 Substitution Attacks)
자기 앞으로 발행된 JWT를 받은 수신자가, 그 JWT를 받을 대상이 아닌 다른 수신자에게 쓰려는 공격이 있습니다.
“If the same issuer can issue JWTs that are intended for use by more than one relying party or application, the JWT MUST contain an “aud” (audience) claim …” (§3.9)
한 발행자가 여러 relying party나 애플리케이션이 쓸 JWT를 발행할 수 있다면, JWT에는 반드시
audclaim이 있어야 합니다.
PeekCart의 내부 토큰이 이 조건에 해당합니다. 발행자는 게이트웨이 하나이고 받는 쪽은 다섯 서비스입니다. 게이트웨이는 라우트마다 어느 서비스로 보내는지 알고 있으니, 그 이름을 aud에 넣고 서비스가 자기 이름과 비교하게 할 수 있었을 것입니다. 같은 서비스로 다시 보내는 것까지 막으려면 jti를 기억해 둘 저장소가 필요한데, 그러면 서비스에서 Redis 조회를 없앤 이점이 사라집니다. 보내는 쪽 자체를 확인하는 일은 보류한 mTLS가 맡을 부분입니다.
다만 이 공격은 클러스터 안의 트래픽을 엿볼 수 있는 위치에 있어야 가능합니다. 서명이 막으려던 위협(헤더를 직접 지어내는 것)보다 한 단계 더 깊이 들어온 경우이고, 이 위협을 얼마나 무겁게 볼지는 아직 판단하지 않았습니다.
허용된 Pod 자체가 뚫리면 개인키도 함께 나갑니다
서명으로 막은 것은 클러스터 안이나 허용된 Pod에서 헤더를 지어내는 경로입니다. 게이트웨이 Pod 자체를 장악하면 마운트된 개인키를 읽어 갈 수 있고, 그 키가 있으면 Pod 밖에서도 키를 로테이션하기 전까지 얼마든지 서명할 수 있습니다.
키 저장 방식을 정할 때 이쪽을 막을 대안이 이미 있었습니다. Cloud KMS의 비대칭 서명입니다. Cloud KMS 문서의 설명입니다.
“For security reasons, the raw cryptographic key material represented by a
CryptoKeyVersioncan never be viewed or exported.”보안상의 이유로
CryptoKeyVersion의 raw key material은 조회하거나 밖으로 꺼낼 수 없습니다.
지금 구조에 KMS를 적용해 보면, 탈취한 Pod에서 서명 API를 호출할 수는 있어도 키를 가지고 나갈 수는 없습니다. 공격자는 Pod에 대한 접근을 잃는 순간 서명 능력도 함께 잃습니다. 대신 치러야 할 비용이 있습니다. 게이트웨이는 요청마다 내부 토큰에 서명하므로 요청마다 KMS 호출이 붙고, 인증 경로가 KMS API의 지연과 가용성에 묶입니다. 캐싱 같은 변형은 따져 보지 않았습니다. 처음 게이트웨이를 설계할 때 KMS는 “서명 지연을 측정한 뒤 판단”하기로 미뤘고, 이제 그 측정값(로컬 서명 p95 1.80ms, 클러스터 인증 경로 차이 +9.9ms)은 생겼습니다. 하지만 이 값을 가지고 KMS 도입 여부를 판단하지는 않았습니다.
Prometheus 경로도 같은 시각으로 다시 볼 수 있습니다. 도메인 서비스는 관리 엔드포인트를 업무 API와 같은 8080 포트에 두고, NetworkPolicy는 Prometheus에 8080을 열어 둡니다. 구성만 보면 평문 신뢰 시절에는 Prometheus Pod도 헤더를 넣을 수 있는 위치였고, 서명을 도입한 뒤에는 인증 없이 공개 API를 부르는 정도로 줄어듭니다. 이 경로는 따로 분석하거나 측정하지 않았습니다.
클러스터에서 찾은 결함 중 무엇을 더 일찍 잡을 수 있었을까요?
GKE 세션에서 찾은 결함을 클러스터 없이 잡을 수 있었는지 기준으로 다시 나눠 봤습니다. IAM 전파 지연은 GCP에서만 일어나는 동작이라 미리 잡을 수 없었습니다. versions/latest 동작은 GKE 없이 GCP만으로도 볼 수 있었을 것입니다. 공개키 누락은 새 키로 한 번만 돌려 봐도 드러나지만, 당시 로컬에서는 CSI 없이 게이트웨이를 띄울 수 없었습니다. 이 셋은 실제로 해 보지 않고 내린 판단입니다.
나머지 하나, apply -k가 HPA 최소값 아래로 replica를 줄인 결함은 이 글을 쓰면서 뒤늦게 확인했습니다. 별도 minikube profile(minikube v1.38.1, k8s v1.35.1)에 HPA(minReplicas: 2)를 붙인 nginx Deployment 두 개를 띄우고 세 번 다시 apply했습니다. replicas: 1을 선언한 쪽만 매번 Pod 하나가 실제로 내려갔다가 약 4~12초 뒤 HPA가 2로 되돌렸고, 선언하지 않은 쪽은 매번 unchanged였습니다. 결함 직전 커밋의 GKE overlay를 렌더해 보기만 해도 gateway가 spec.replicas=1, minReplicas=2로 나옵니다. 렌더 단계 lint 하나로 잡을 수 있었던 결함이고, 실제로 고칠 때 그런 lint를 추가했습니다. 이 실험은 같은 조합을 최소 구성으로 만든 것이고, 복구 시간은 HPA 동기화 주기에 따라 달라질 수 있습니다.
돌아보면 “클러스터 없이 통과할 수 있는가”는 검사를 어디서 돌릴 수 있는지로 작업을 나눈 기준이었고, 렌더 결과에 이미 드러나는 조합을 검사하는 lint는 따로 고민하지 않았습니다. 개인키를 CSI로만 받게 설계한 탓에 로컬에서 리허설해 볼 중간 단계도 처음부터 없었습니다. 나중에 사용자 토큰 drill용으로 만든 k8s Secret overlay가 클러스터 세션 전에 있었다면, 공개키 누락은 GKE를 켜기 전에 드러났을 가능성이 크다고 봅니다.
표준 패턴과 비교하면 어떨까요?
게이트웨이가 바깥 토큰을 검증하고 안쪽에서 쓸 토큰을 새로 만들어 넘기는 구조는 이미 여러 곳에서 다룹니다. RFC 8693(OAuth 2.0 Token Exchange) §1은 리소스 서버가 받은 액세스 토큰을 백엔드 호출에 쓸 새 토큰으로 바꾸는 경우를 예로 듭니다.
“An OAuth resource server, for example, might assume the role of the client during token exchange in order to trade an access token that it received in a protected resource request for a new token that is appropriate to include in a call to a backend service.” (§1)
예를 들어 OAuth 리소스 서버는 토큰 교환에서 클라이언트 역할을 맡아, 보호된 리소스 요청으로 받은 액세스 토큰을 백엔드 서비스 호출에 넣기 알맞은 새 토큰으로 바꿀 수 있습니다.
Curity가 정리한 phantom token 패턴은 게이트웨이 구조에 더 가깝습니다. 클라이언트는 opaque token(by-reference token)만 갖고 있고, 게이트웨이가 그것을 JWT로 바꿔 넘깁니다.
“The reverse proxy replaces the by-reference token with the by-value token in the actual request to the microservice.”
리버스 프록시는 마이크로서비스로 가는 실제 요청에서 by-reference token을 by-value token으로 바꿔 넣습니다.
| RFC 8693 | phantom token | PeekCart | |
|---|---|---|---|
| 클라이언트가 가진 것 | 액세스 토큰 | opaque token | 사용자 JWT |
| 안쪽 토큰을 만드는 곳 | 별도 토큰 서비스(STS) | 토큰 서비스. 게이트웨이는 조회만 합니다 | 게이트웨이가 직접 서명 |
| 받을 서비스 지정 | audience·resource 파라미터 | 이 자료에서는 다루지 않음 | 없음 |
안쪽 토큰이 바깥으로 나가지 않는 점은 같습니다. PeekCart 게이트웨이도 바깥에서 들어온 X-Internal-Auth는 항상 지웁니다. 가장 크게 다른 점은 안쪽 토큰을 누가 만드느냐입니다. PeekCart는 게이트웨이가 직접 서명해서 거치는 곳이 하나 줄었지만, 그만큼 게이트웨이가 서명 개인키를 들고 있게 됐습니다. 앞에서 본 두 문제, 개인키 유출과 받을 서비스가 정해지지 않은 토큰의 replay는 모두 여기서 나옵니다.
이미 따르고 있는 권고도 있습니다. RFC 8725 §3.12는 종류가 다른 JWT가 섞이지 않게 하는 방법 중 하나로 이것을 꼽습니다.
“Use different keys for different kinds of JWTs. Then the keys used to validate one kind of JWT will fail to validate other kinds of JWTs.”
종류가 다른 JWT에는 서로 다른 키를 씁니다. 그러면 한 종류를 검증하는 키로는 다른 종류의 JWT가 검증되지 않습니다.
사용자 토큰과 내부 토큰을 다른 키로 서명하고, 발행자를 고정하고, 두 공개키 목록이 fingerprint 기준으로 겹치지 않게 강제한 것이 이 권고에 해당합니다.
정리하며
평문 헤더에 대한 신뢰를 NetworkPolicy 하나에 맡겨도 될까요? 이번 작업에서 내린 답은 NetworkPolicy는 그대로 두고, 신원은 서명으로 따로 증명하는 것이었습니다. 서명한 내부 토큰을 더하자 신원을 위조하려면 NetworkPolicy와 게이트웨이 개인키를 함께 뚫어야 합니다. 빈 클러스터 위의 리허설에서 위조 토큰과 평문 헤더는 401, 정상 토큰은 200이었고, NetworkPolicy가 허용한 게이트웨이 Pod 안에서 보내도 서명만 받는 서비스는 평문을 받아들이지 않았습니다.
막는 장치를 만드는 데 든 만큼, 그 장치가 실제로 막는다는 것을 보이는 데도 손이 많이 갔습니다. 검사할 대상이 없는 lint, 무엇을 넣어도 통과하는 테스트, 권한 변경이 퍼지기 전에 돌린 음성 대조 때문에 잘못된 통과를 기록할 뻔했고, 그때마다 양성 대조를 먼저 세워서 막았습니다.
남은 과제는 이렇습니다.
- 서명만으로는 막지 못하는 것이 있습니다. 가로챈 내부 토큰의 replay(
aud·jti없음)와, 게이트웨이 Pod를 장악당했을 때의 개인키 유출입니다. 둘 다 작업 당시 따로 분석하거나 결정하지 않았고, Cloud KMS로 옮길지도 판단하지 않았습니다. - 실제 트래픽이 흐르는 상태에서의 전환은 확인하지 못했습니다. 롤아웃은 빈 클러스터 위의 리허설이었습니다.
- 사용자 토큰 로테이션을 GKE 운영 구성으로 끝까지 해 보지는 않았습니다. drill은 minikube에서 Secret Manager·CSI·Workload Identity 없이 한 가지 문제만 확인했습니다. drill용 overlay는 “개인키는 소유한 워크로드만 마운트한다”는 규칙을 어기도록 만든 것이라 운영 매니페스트로 올리지 않습니다. 또 운영 base의 user-service는 replica 1개인데, drill에서는 3~4개를 띄웠습니다.
- 평소 롤아웃에서 401 구간이 얼마나 자주 생기는지는 모릅니다. 6번 시도에서 한 번도 나오지 않았고, 생기면 어떻게 되는지까지만 확인했습니다.
- 관측과 부하 측정에도 빈 곳이 있습니다. 게이트웨이 event-loop 지연은 직접 재지 않고 관리 포트 probe로 대신했습니다. 부하 측정에는 같은 노드에 띄운 k6와의 자원 경합이 섞여 있고, 인증 실패 알림 기준은 아직 정하지 않았습니다.
- NetworkPolicy가 허용한 다른 경로는 살펴보지 않았습니다. Prometheus Pod 경로를 위조 경로로 분석하거나 측정하지 않았습니다. 허용 라벨(
app: gateway)을 흉내 낸 Pod는 매니페스트 단계의 lint만 막아 줍니다. 실행 중인 클러스터에서 그 라벨로 Pod를 띄우는 것까지 막는 장치는 없습니다.