X-Forwarded-For 에 /32가 붙어 IP allowlist가 깨질 때
허용 IP 목록에 분명히 있는데 계속 차단되던 원인은 req.ip 끝에 붙은 /32 였습니다. 감사 로그로 원인을 찾고 IP 를 정규화해 해결한 과정입니다.
관리 콘솔에 공인 IP allowlist 게이트를 걸었습니다. 허용 IP 목록(["203.0.113.7"])에 든 IP에서만 들어올 수 있고, 아니면 404로 존재 자체를 숨기는 방식입니다. ALB와 CDN 뒤에 있는 Express 앱이고, app.set("trust proxy", 1)로 req.ip가 실제 클라이언트 IP를 가리키게 해 뒀습니다.
그런데 제 공인 IP가 분명히 목록에 있는데도 계속 차단됐습니다.
값은 맞는데 왜 막힐까
거부 로그에 찍힌 클라이언트 IP를 보니 이랬습니다.
203.0.113.7/32끝에 /32(CIDR 마스크)가 붙어 있었습니다. 게이트는 단순 문자열 비교였으니 당연히 안 맞습니다.
allowedIps.includes(clientIp) // ["203.0.113.7"].includes("203.0.113.7/32") → false앞단 프록시 체인 어딘가에서 X-Forwarded-For에 클라이언트 IP를 203.0.113.7/32 형태로, 마스크까지 붙여 전달했습니다. Express req.ip도 그 값을 그대로 받아 /32가 남은 겁니다. IP는 맞는데 표기 한 글자 차이로 allowlist와 안 맞은 거죠.
req.ip가 항상 깨끗한 a.b.c.d일 거라고 가정하면 안 됩니다. 프록시 체인이 XFF에 무엇을 넣느냐에 따라 ::ffff:(IPv4-mapped IPv6), 포트, CIDR 마스크 같은 게 섞여 들어올 수 있습니다.
결정적 단서는 감사 로그
원인을 바로 찾은 건 감사 로그 덕분이었습니다. 어느 검사가, 어떤 실측 IP로 막았는지를 남겨 뒀거든요.
"거부됨"만 찍었다면 값이 맞는데 왜 막히는지 한참 헤맸을 겁니다. reason=ip와 서버가 실제로 본 IP 문자열을 같이 남겨 둔 덕에 /32를 바로 발견했습니다.
정규화로 풀기
IP 정규화 단계에서 CIDR 접미사를 제거해 호스트 표기로 통일했습니다. 비교 로직과 allowlist는 그대로 두고 들어오는 IP만 정규화합니다. 위조 방어에는 영향이 없습니다.
function normalizeClientIp(req: Request): string {
let ip = req.ip || req.socket?.remoteAddress || "";
if (ip.startsWith("::ffff:")) ip = ip.slice(7); // IPv4-mapped IPv6 → IPv4
const slash = ip.indexOf("/");
if (slash !== -1) ip = ip.slice(0, slash); // CIDR 마스크(/32, /128) 제거
return ip;
}급하게 복구해야 한다면 코드 배포 없이 allowlist에 게이트가 실제로 보는 값(203.0.113.7/32)을 그대로 넣어 푸는 우회도 가능합니다. 다만 표기에 기대는 임시방편이라, 정석은 정규화입니다.