프론트 프록시를 지나면 백엔드는 클라이언트 IP 를 못 봅니다
Amplify rewrite 는 리다이렉트가 아니라 서버측 프록시이고, Spring Boot 는 ECS 를 감지하면 XFF 신뢰를 자동으로 활성화합니다
사내망에서만 동작하는 기능을 개발했는데 사무실에서도 전부 거절됐습니다. 허용 대역에 사무실 IP 가 등록돼 있는데도 그랬습니다.
사내망 밖 요청 거절 — ip 3.38.x.x
사내망 밖 요청 거절 — ip 3.39.x.x
10초 사이 두 요청의 IP 가 다르고 둘 다 AWS 서울 대역이었습니다. 브라우저에서 확인한 제 공인 IP 와는 아무 관계가 없는 값이었습니다.
원인이 두 가지라서 하나만 알면 진단을 더 진행할 수 없습니다.
1. Amplify rewrite 는 리다이렉트가 아니라 서버측 프록시입니다
프론트가 /api/<*> 를 백엔드 도메인으로 전달하는 rewrite 규칙을 사용하고 있었습니다. status: 200 rewrite 는 브라우저에게 "저기로 다시 가라" 고 안내하는 것이 아니라 Amplify 가 대신 요청을 새로 전송하는 동작입니다.
브라우저(내 공인 IP)
→ CloudFront (Amplify 호스팅)
→ Amplify rewrite /api/<*> → https://<백엔드 도메인>/<*>
→ ALB
→ ECS 태스크
ALB 가 인식하는 TCP 상대는 브라우저가 아니라 Amplify 입니다. 그래서 ALB 는 X-Forwarded-For 오른쪽에 Amplify 의 송신 IP 를 기록합니다. AWS 관리 대역이라 요청마다 변경되고, 당연히 어떤 사무실 대역에도 일치하지 않습니다.
2. Spring Boot 는 ECS 를 감지하면 XFF 신뢰를 자동으로 활성화합니다
여기서 한 번 더 혼동했습니다. forward-headers-strategy 를 설정에 기재한 적이 없는데도 getRemoteAddr() 이 ALB 의 사설 IP 가 아니라 공인 IP 를 반환하고 있었습니다.
Boot 의 클라우드 플랫폼 감지 때문입니다.
CloudPlatform.AWS_ECS.isDetected()는 환경변수AWS_EXECUTION_ENV가AWS_ECS로 시작하면 true 입니다. Fargate 는AWS_ECS_FARGATE를 주입합니다.AWS_ECS는isUsingForwardHeaders()를 오버라이드하지 않아 기본값true이고, 그러면server.forward-headers-strategy가NATIVE로 설정됩니다.- 그 결과 Tomcat
RemoteIpValve가 XFF 를 참조해getRemoteAddr()을 덮어씁니다. 오른쪽부터 사설 대역만 제거하므로 첫 공인 IP 에서 멈추는데, 그 값이 Amplify 송신 IP 였습니다.
Boot 2.x 에는 이 감지가 없습니다. 같은 회사의 다른 서비스에 문제가 없었던 이유가 여기 있었습니다. 그쪽은 Boot 2.7 이고 프론트가 API 를 프록시하지 않아서, XFF 전체를 참조하는 코드여도 값이 하나뿐이라 정확했습니다.
RemoteIpValve 는 헤더를 재작성합니다
진단에서 제일 혼동한 지점입니다. 애플리케이션이 참조하는 X-Forwarded-For 는 원본이 아닙니다. 밸브가 사용한 값을 헤더에서 삭제합니다.
ip=15.164.x.x
xff=2001:4430:...,13.124.x.x
ip= 값이 xff= 목록에 없습니다. 실제 헤더는 세 개(클라이언트, 13.124.x, 15.164.x)였고, 밸브가 맨 오른쪽 값을 참조해 사용한 뒤 삭제한 결과입니다.
그래서 로그에 기록된 xff= 의 개수가 곧 앞에 남아 있는 프록시 홉 수가 됩니다.
xff= | 뜻 |
|---|---|
| 비어 있음 | ALB 한 홉. ip= 가 브라우저 IP 입니다 |
| 값 1개 | 프록시가 하나 더 있습니다 |
| 값 2개 | 프록시가 둘 더 있습니다 (CloudFront + rewrite) |
이 규칙을 알고 나서야 로그만 보고 경로를 파악할 수 있었습니다.
고친 방식
브라우저가 ALB 를 직접 호출하게 하고 CORS 를 허용했습니다. 게이트 코드와 getRemoteAddr() 은 그대로 유지했습니다. 요청 경로가 변경되면 올바른 값이 자동으로 전달됩니다.
이 구성에서는 XFF 를 추가하는 주체가 ALB 하나뿐이라 값이 정확합니다. 클라이언트가 XFF 를 위조해 전송해도 ALB 가 append 모드로 자기 관측값을 오른쪽에 추가하므로, 위조한 값은 왼쪽에 남고 판정에 사용되지 않습니다.
XFF 맨 왼쪽 값을 참조하는 방식은 사용하지 않았습니다. 코드 한 줄로 즉시 수정할 수 있지만 클라이언트가 헤더를 삽입해 위조할 수 있습니다. 기록용 로그라면 감수할 수 있어도, IP 로 승인을 내리는 게이트에는 사용할 수 없습니다.
그리고 XFF 신뢰를 설정에 명시했습니다. 프레임워크 자동 감지에 의존하면 ECS 밖에서 실행될 때 경고 없이 비활성화돼서 게이트가 모든 요청을 거절합니다.
server:
forward-headers-strategy: NATIVE
배포 순서가 있습니다
- 백엔드에 CORS 배포. 이것만으로는 아무 변화가 없습니다. FE 가 아직 프록시를 경유하기 때문입니다.
- FE 환경변수 추가 + 재빌드
- rewrite 규칙 삭제
2 를 먼저 하면 CORS 가 없는 백엔드로 크로스오리진 요청이 전달돼 앱 전체가 로드되지 않습니다. 3 을 먼저 하면 환경변수 없이 빌드된 기존 번들이 404 응답을 수신합니다. 3 은 연기해도 되고, 유지하면 환경변수만 삭제하고 재빌드하는 롤백 경로가 됩니다.
겪은 문제 세 가지
Amplify 환경변수는 빌드 시점에 번들에 포함됩니다. 변수를 추가하고 재빌드하지 않으면 배포가 성공해도 아무것도 변경되지 않습니다. 실제로 FE 코드가 머지·배포됐는데 환경변수가 없어서 폴백 경로로 빌드됐고, 로그는 여전히 프록시 IP 였습니다. 확인은 두 가지로 합니다.
# 앱·브랜치 양쪽을 봅니다. 브랜치 레벨에만 있는 경우가 있습니다
aws amplify get-app --app-id <id> --query 'app.environmentVariables'
aws amplify list-branches --app-id <id> --query 'branches[].{b:branchName,env:environmentVariables}'
# 배포된 번들에 값이 실제로 박혔는지
curl -s https://<fe-domain>/ | grep -o '/assets/index-[A-Za-z0-9_-]*\.js'
curl -s https://<fe-domain>/assets/index-XXXX.js | grep -c '<api-domain>'
XFF 에 IPv6 가 확인되는 것은 CloudFront 때문입니다. CloudFront 는 dualstack 이라 IPv6 로 연결을 수신합니다. ALB 가 ipAddressType: ipv4 면 브라우저가 직접 호출할 때 IPv4 로 연결됩니다. ALB 를 dualstack 으로 변경하면 IPv6 클라이언트가 그대로 유입돼서, IPv4 CIDR 만 등록된 게이트가 다시 거절합니다.
iCloud Private Relay 를 활성화한 아이폰 Safari 는 사무실 와이파이여도 거절됩니다. 트래픽이 Apple 대역으로 송출됩니다. 다른 서비스의 접속 이력에도 이 대역 주소가 실제로 기록돼 있었습니다. IP 로 판정하는 한 잔존하는 문제입니다.
정리하면 증상은 "사무실인데 사내망 밖", 원인은 프론트 rewrite 가 서버측 프록시라는 것과 Boot 의 ECS 자동 감지 두 가지, 해결은 rewrite 프록시를 제거해 브라우저가 ALB 를 직접 호출하게 한 것입니다. 로그에서 ip= 와 xff= 를 같이 기록하지 않았다면 홉이 몇 개인지 파악할 수 없었을 겁니다.