X-Forwarded-For 유무로 내부 전용 서비스의 외부 진입을 막았습니다
ALB 를 경유한 요청에만 추가되는 헤더를 신뢰 신호로 삼아, 서비스 안의 서블릿 필터 하나로 외부 진입을 차단했습니다
내부 호출만 수신한다고 전제하고 개발한 서비스가 있었습니다. 인증이 없고 공유 시크릿도 없습니다. 서비스 간 호출은 전부 내부 DNS 로 직결되니 그래도 된다는 전제였습니다.
전제가 성립하려면 외부에서 진입할 길이 없어야 합니다. 확인해 보니 인터넷에 연결된 로드밸런서에 그 서비스로 요청을 전달하는 리스너가 조건 없이 설정돼 있었고, 보안그룹 인바운드도 전 대역을 허용하고 있었습니다. 정상 트래픽 중에는 그 경로로 유입되는 요청이 하나도 없는데, 외부에서 접근할 수 있는 경로만 존재하는 상태였습니다.
멱등키는 방어가 아닙니다
처음엔 "값을 변경해가며 반복 호출해도 멱등키가 방지해 주지 않나" 싶었는데, 그렇지 않았습니다.
멱등키는 선택값이고 호출자가 결정합니다. 비워서 전송하면 멱등 가드 블록을 건너뛰고 매번 새 레코드를 생성합니다. 매 요청 값을 변경해도 항상 신규입니다. DB 에 unique 인덱스가 있어서 같은 키의 동시 중복은 방지하지만, 키를 변경하거나 제외하면 중복을 방지하지 못합니다.
멱등키의 목적은 "우리 호출자의 재시도가 중복 처리를 유발하지 않게" 하는 것입니다. 인증이나 rate limit 을 대체하지 못합니다. 이건 그 자리에서 가장 먼저 확인해야 할 구분이었습니다.
차단하기 전에 호출자 목록을 만들었습니다
차단을 적용하기 전에 실제로 누가 호출하는지 전수로 집계했습니다. 이 단계를 건너뛰면 배포 후에 어떤 호출이 실패할지 알 수 없습니다.
| 호출자 | 호출 여부 | 경유 |
|---|---|---|
| 서비스 A (두 군데) | 호출함 | 내부 DNS 직결 |
| 서비스 B·C | 메서드만 있고 호출부 0건 (dead) | 해당 없음 |
| 어드민 | 호출 안 함 (다른 경로를 사용합니다) | 해당 없음 |
| 프론트 | 호출 안 함 | 해당 없음 |
공통 설정의 호스트 값도 모든 환경에서 내부 DNS 였습니다. 정상 트래픽 중 로드밸런서를 경유하는 요청은 하나도 없다는 것이 확인되고 나서야 판정 기준을 선택할 수 있었습니다.
X-Forwarded-For 유무를 신뢰 신호로 삼았습니다
결정은 다음과 같습니다. X-Forwarded-For 헤더가 포함된 요청을 거부합니다. 판정과 차단을 서비스 레포 안의 서블릿 필터 하나로 완료하고, 인프라 설정과 공용 라이브러리는 변경하지 않습니다.
이 방법이 성립하는 근거는 세 가지입니다.
- 두 환경의 ALB 모두 XFF 처리 모드가
append입니다(describe-load-balancer-attributes로 확인). ALB 를 경유한 요청에는 예외 없이 XFF 가 추가되고, 클라이언트가 값을 삽입해 전송해도 거기에 관측한 IP 를 추가합니다 - 내부 직결 호출에 사용하는 WebClient 는 XFF 를 생성하지 않습니다. 어드민의 axios 호출도 마찬가지입니다
- 해당 태스크는 퍼블릭 IP 할당이 비활성화돼 있어서 ALB 가 유일한 외부 진입로입니다
그래서 "XFF 없음 = ALB 를 경유하지 않음" 이 되고, 공격자가 헤더를 제거해서 우회할 수 없습니다. 헤더를 추가하는 주체가 클라이언트가 아니라 ALB 이기 때문입니다.
고르지 않은 대안들
| 대안 | 왜 안 골랐나 |
|---|---|
| 보안그룹 인바운드 룰 삭제 | 제 IAM 에 ec2:RevokeSecurityGroupIngress 가 없었습니다(실제로 호출해 UnauthorizedOperation 확인). 권한이 부여되면 이게 1순위 보강입니다 |
| 리스너 규칙으로 403 고정응답 | 권한은 있었습니다. 그런데 AWS 설정 변경 자체를 하지 않기로 했습니다. 영향 범위를 사전에 다 알 수 없다는 판단이었습니다 |
| 공용 모듈에 공유 시크릿 헤더 | 공용 모듈 변경은 이미지 재빌드를 거쳐 의존 서비스 전체의 다음 빌드에 파급됩니다. 버전 고정이 없어서 파급 범위를 이 변경 하나로 통제할 수 없습니다. 수신측만 검증을 활성화하면 아직 헤더를 전송하지 않는 호출자의 요청이 실패하므로 배포 순서 조율도 필요했습니다 |
| 일회용 티켓 (한쪽이 발급, 한쪽이 소비) | 인증과 동등한 복잡도인데 요청마다 왕복이 증가하고 배포 순서 문제가 발생합니다. 얻는 성질은 시크릿 헤더와 같습니다 |
| Spring Security 도입 | 이 서비스에는 세션·롤·JWT 개념이 없습니다. 판단은 "헤더 하나가 맞나" 뿐인데 SecurityFilterChain 과 permitAll 매트릭스 관리 면적이 증가하고 기존 CORS 필터와 순서 문제가 발생합니다 |
채택안의 장점은 발신측 변경이 0 이라는 것입니다. 다른 서비스는 전부 무변경이고, 이 서비스만 배포하면 즉시 적용되며 롤백도 단독입니다.
관찰 모드로 먼저 배포했습니다
플래그를 하나 두고 2단으로 배포했습니다.
block: false: 차단 조건에 해당하는 요청에 경고 로그만 기록합니다. 정상 호출이 차단 조건에 해당하지 않는지, 사내에서 ALB 를 경유해 이 서비스의 Swagger 를 사용하는 사람이 없는지 이 단계에서 확인합니다- 경고가 외부 스캐너 요청뿐이면
block: true로 전환합니다 - 다음 환경에도 같은 순서로 적용합니다
호출자 목록을 다 작성했어도 이 단계를 추가했습니다. 호출자 목록은 코드를 분석해서 작성한 것이고, 관찰 모드는 실제 트래픽으로 확인하는 것이라 성격이 다릅니다.
헬스체크 경로를 예외로 둘 때 로그를 남기게 했습니다
헬스체크 경로는 플래그와 무관하게 통과시킵니다. 차단되면 타깃이 unhealthy 로 판정돼 태스크가 종료되는데, 통과시킬 때의 비용은 서비스가 실행 중이라는 사실이 노출되는 것뿐이라 차단할 때의 비용이 훨씬 컸습니다.
원칙적으로는 이 예외가 없어도 됩니다. ALB 헬스체크는 리스너를 거치지 않고 타깃에 직접 도달하므로 XFF 가 없어야 하고, 그러면 첫 조건에서 그냥 통과합니다. 그래서 예외를 shouldNotFilter 로 제외하지 않고 필터 본문에 두고, XFF 가 포함된 헬스체크 요청은 경고를 기록하게 했습니다.
shouldNotFilter 로 제외하면 그 경로가 로그를 아예 기록하지 않습니다. 그러면 "헬스체크에는 XFF 가 없다" 는 예외의 전제를 관찰로 검증할 수 없습니다. 30초 주기로 경고가 기록되면 전제가 틀린 것이고, 외부에서 직접 호출한 요청만 기록되면 예외를 삭제해도 된다는 판정 기준이 확보됩니다.
정리하면 전제(내부 전용)와 실제 구성(외부 도달 가능)이 일치하지 않았고, 인증을 새로 추가하는 대신 ALB 를 경유했는지를 헤더 유무로 판정해 요청을 거부했습니다. 이 방법이 성립하려면 세 가지 조건이 필요합니다. ALB 가 XFF 를 append 하고, 내부 호출 경로가 XFF 를 생성하지 않고, ALB 말고 다른 외부 진입로가 없어야 합니다. 셋 중 하나라도 변경되면 다시 검토해야 합니다.