비밀번호 찾기를 만들어달라고 하면 나오는 줄
"비밀번호 재설정 메일 보내는 기능 넣어줘." Claude Code나 Codex에 이렇게 시키면 Node 쪽에서는 대개 이 모양이 나온다.
const token = crypto.randomBytes(32).toString('hex');
const link = `https://${req.headers.host}/reset?token=${token}`;
await sendMail(user.email, `비밀번호 재설정: ${link}`);
Django로 짜면 이렇게 나온다.
link = f"https://{request.get_host()}/reset/{token}/"
둘 다 잘 돈다. 로컬에서 돌리면 localhost:3000이 들어가고, 배포하면 진짜 도메인이 들어간다. 나중에 도메인을 바꿔도 이 코드는 안 건드려도 된다. 테스트로도 안 걸린다. 테스트를 돌리는 쪽은 늘 정직하게 자기 주소를 적어 보내기 때문이다.
그런데 저 한 줄은 서버가 자기 주소를 읽어온 게 아니다. 요청을 보낸 쪽이 적어 보낸 값을 그대로 쓴 것이다.
서버는 자기 이름을 요청에서 배운다
Host 헤더가 왜 있는지부터 보자.
IP 주소 하나에 웹사이트가 하나씩 있던 시절이라면 이 헤더는 필요 없다. 지금은 반대다. 서버 한 대가 도메인 수십 개를 받고, 그 앞에 리버스 프록시나 로드밸런서가 한 겹 더 있다. TCP 연결만 봐서는 이 요청이 어느 사이트로 온 건지 알 수 없다. 목적지 IP는 다 같기 때문이다.
아파트 공동현관과 비슷하다. 건물 주소는 하나인데 안에는 집이 여러 개다. 택배기사가 공동현관에 대고 "301호요"라고 말하면 그 말을 듣고 연결해준다. 건물 입장에서 호수를 알아낼 다른 방법이 없다.
그래서 HTTP는 요청 안에 목적지 이름을 적어 보내게 했다.
GET /reset HTTP/1.1
Host: myapp.com
MDN에 적힌 규칙이 이렇다. Host 헤더는 모든 HTTP/1.1 요청에 반드시 들어가야 하고, 없거나 두 개 들어 있으면 서버는 400을 돌려줄 수 있다. 그만큼 기본적인 값이다.
여기서 한 번 멈춰야 한다. Host는 요청 메시지 안에 들어 있다. 요청 안에 있는 것은 전부 클라이언트가 쓴 값이다. User-Agent도, Referer도, 요청 본문도 마찬가지고 Host도 예외가 아니다. 브라우저가 알아서 채워주니까 서버가 정한 값처럼 보이는 것이다. curl로는 아무 값이나 넣어 보낼 수 있다.
PortSwigger의 Web Security Academy는 이 문제를 이렇게 정리했다. Host 헤더 공격은 대개 코딩 실수가 아니라 설정에서 나온다. 도메인이 필요한 자리에 손에 잡히는 값이 Host밖에 없어서 그걸 썼고, 그 값이 어디서 왔는지 따져본 사람이 없었던 것이다.
여기서 자주 나오는 질문 하나. HTTPS를 쓰면 중간에서 헤더를 못 건드리는 것 아니냐. 맞다. 다만 그건 전송 구간 이야기다. TLS는 오가는 내용을 중간에서 읽거나 바꾸지 못하게 가려주지, 요청을 만드는 쪽이 거기에 뭘 적을지는 정하지 못한다. 공격자는 중간에 끼어드는 게 아니라 자기 손으로 요청을 만들어 보낸다. curl -H "Host: ..." 한 줄이면 되고, 그 요청은 정상적인 TLS 연결 위에서 그대로 서버에 도착한다.
진짜 메일, 진짜 토큰, 남의 도메인
앞의 세 줄이 어떻게 계정 탈취가 되는지 보자.
공격자가 피해자의 이메일 주소로 비밀번호 재설정을 요청한다. 이때 Host만 attacker.example로 바꿔서 보낸다. 서버는 정상적으로 토큰을 만들고, 링크를 조립하고, 피해자에게 메일을 보낸다. 피해자 받은편지함에 도착한 메일은 진짜다. 서비스가 보낸 게 맞고, 브랜딩도 그대로고, 토큰도 유효하다. 링크의 도메인 한 조각만 공격자 것이다.
피해자가 그 링크를 누르는 순간 브라우저가 토큰을 공격자 서버로 실어 나른다. 공격자는 받아둔 토큰을 진짜 사이트에 넣고 비밀번호를 바꾼다. 피해자는 링크를 한 번 눌렀을 뿐이다.
2026년에 나온 사례 두 건이 정확히 이 구조다.
phpBB에서 나온 CVE-2026-29199는 3.0.0부터 3.3.15까지, 그리고 4.0.0-a1이 대상이다. force_server_vars 설정이 꺼져 있으면 재설정 링크를 만들 때 호스트명을 HTTP Host 헤더에서 뽑아 쓴다. CVSS 점수는 8.1이고 3.3.16에서 고쳐졌다.
인터넷 라우팅 레지스트리 서버인 IRRd에서도 CVE-2026-28681이 같은 8.1로 나왔다. 4.4.0부터 4.4.4, 그리고 4.5.0이 대상이다. 비밀번호 재설정과 계정 생성 양쪽에서 Host를 바꿀 수 있었고, 링크를 열기만 해도 토큰이 넘어갔다.
IRRd의 수정 내역이 볼 만하다. 무언가를 걸러내는 필터를 넣은 게 아니라, 들어온 Host가 설정 파일에 적힌 server.http.url과 다르면 요청 자체를 거절하게 바꿨다. 서버는 이미 자기 주소를 알고 있었다는 뜻이다. 설정 파일에 적혀 있는 값을 놔두고 요청에서 읽어 쓰고 있었던 것이다. 그리고 업그레이드 시점에 발급돼 있던 재설정 토큰을 전부 무효화했다. 이미 새어나간 토큰까지 같이 죽여야 사고가 끝나기 때문이다.
한 가지 덧붙이면, 2단계 인증을 켜둔 계정은 이 공격에도 살아남았다. 비밀번호가 넘어가도 두 번째 관문이 남아 있어서다.

Django와 Rails는 막고 있었다. 우리가 열었다
여기서 억울한 쪽이 있다. Django와 Rails는 이 공격을 처음부터 막고 있었다.
Django의 ALLOWED_HOSTS가 그 장치다. 기본값은 빈 리스트다. request.get_host()는 이 목록과 대조해서 안 맞으면 SuspiciousOperation을 던지고, 그 결과 요청이 400으로 떨어진다. DEBUG = True이고 목록이 비어 있을 때만 .localhost, 127.0.0.1, [::1] 세 개를 임시로 허용한다. 개발 중에는 편하게 두고 배포할 때 도메인을 적으라는 설계다.
Rails에는 config.hosts와 ActionDispatch::HostAuthorization이 있다. 개발 환경 기본값에는 0.0.0.0/0, ::/0, localhost가 들어 있다. IP 주소로 접속하거나 localhost로 접속하면 통과한다는 뜻이지, 아무 이름이나 받아준다는 뜻이 아니다. 도메인 이름은 저 세 가지 중 어디에도 안 걸리므로 개발 환경에서도 막힌다. Docker나 터널링 도구를 붙였을 때 "Blocked hosts"라는 화면을 본 적이 있다면 그게 이 방어다. 개발 이외의 환경에서는 목록이 아예 비어 있고, 안 맞는 호스트로 요청이 오면 403을 돌려주면서 로그에 남긴다.
문제는 이 방어가 배포 직후에 정확히 방해물처럼 느껴진다는 데 있다. 도메인을 연결하고 접속하면 400이나 403이 뜬다. 원인을 모르는 상태에서 검색하거나 에이전트한테 물어보면 답이 하나로 모인다. ALLOWED_HOSTS = ['*']. 이 한 줄을 넣으면 즉시 뚫리고, 뚫리는 순간 다들 다음 작업으로 넘어간다.
웹 분석 도구인 Shynet에서 나온 CVE-2026-35507이 그 결말이다. 기본 설정이 ALLOWED_HOSTS = *로 배포돼 있어서 Django의 검사가 사실상 꺼져 있었고, 그 위에서 재설정 링크 포이즈닝이 성립했다. 코드에는 아무 문제가 없었다. 설정값 하나가 검문소를 통째로 치웠다.
Express와 Koa 쪽은 사정이 다르다. 여기엔 애초에 그런 장치가 없다. req.headers.host는 들어온 값을 그대로 준다. 끌 방어가 없는 대신 직접 넣어야 하는데, 요구하지 않으면 에이전트도 사람도 그 자리를 비워둔다.
프록시 뒤에서는 X-Forwarded-Host가 하나 더 생긴다
프록시 뒤에 서버를 두면 이야기가 한 겹 더 복잡해진다.
Cloudflare나 nginx 뒤에 앱을 올리면 앱이 보는 Host는 프록시가 넘겨준 값이다. 그래서 원래 클라이언트가 뭘 요청했는지 알려주려고 X-Forwarded-Host라는 헤더를 쓴다. 이름부터 짐작이 가겠지만, 이 시리즈에서 이미 한 번 다룬 X-Forwarded-For와 같은 계열이고 같은 함정을 갖고 있다.
Express 공식 문서가 이 함정을 그대로 적어놨다. trust proxy 설정의 기본값은 false다. 이걸 켜면 req.hostname이 X-Forwarded-Host 값에서 나오는데, 문서의 표현이 이렇다. "클라이언트가 설정할 수도 있고 프록시가 설정할 수도 있는" 값이라고. 그리고 바로 다음 줄에 경고가 붙어 있다. 맨 앞의 프록시가 X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto를 지우거나 덮어쓰지 않으면 클라이언트가 아무 값이나 넣을 수 있다고.
Django에도 같은 스위치가 있다. USE_X_FORWARDED_HOST를 켜면 get_host()가 Host 대신 X-Forwarded-Host를 본다. 프록시가 그 헤더를 덮어쓰지 않는 구성이라면 ALLOWED_HOSTS 검사도 공격자가 고른 값 위에서 돌아가게 된다.
폴백을 잘못 넣으면 검사 자체가 무력해지기도 한다. Python 프레임워크인 Litestar의 CVE-2026-48061이 그 경우다. 2.22.0 이전 버전의 AllowedHostsMiddleware는 Host 헤더가 없으면 X-Forwarded-Host를 대신 읽었다. 신뢰할 만한 프록시를 거쳐 왔는지는 확인하지 않았다. 그래서 Host를 아예 빼고 X-Forwarded-Host에 허용목록에 있는 도메인을 적어 보내면 검사를 그냥 통과했다. CVSS 점수는 5.9이고 2.22.0에서 폴백이 빠졌다.
Host를 자르는 코드가 틀리면 허용목록도 같이 틀린다
허용목록을 제대로 넣었다고 끝이 아니다. 그 값을 파싱하는 코드가 틀리면 검사도 같이 틀린다.
Koa에서 나온 CVE-2026-27959가 이 문제를 잘 보여준다. 문제가 된 건 ctx.hostname이었다. Host 헤더에는 포트가 붙을 수 있으니 콜론 앞까지만 잘라 쓰면 될 것 같다. 실제로 그렇게 구현돼 있었다.
그런데 URL의 주소 부분 문법은 그보다 넓다. RFC 3986은 이 자리를 [userinfo "@"] host [":" port]로 정의한다. 예전에 ftp://user:pass@example.com 같은 주소를 본 적이 있다면 그 user:pass 부분이 userinfo다. 콜론과 점이 들어갈 수 있어서 겉보기에는 호스트명과 구별이 안 된다.
그래서 이런 값을 보낼 수 있다.
Host: evil.com:fake@legitimate.com:3000
문법적으로 멀쩡한 값이다. 실제 호스트는 @ 뒤의 legitimate.com이라 요청은 진짜 서버로 간다. 그런데 콜론 앞만 자르는 코드는 evil.com을 돌려준다. @를 찾아보지 않으니 userinfo를 호스트명으로 착각한 것이다. CVSS 점수는 7.5이고 3.1.2와 2.16.4에서 고쳐졌다.
이 사례가 말하는 건 하나다. Host를 쓰지 않는 것과 Host를 검사해서 쓰는 것 사이에는 여전히 파서 하나가 끼어 있다.
메일 말고도 새는 곳
Host를 믿는 코드가 재설정 메일에만 있는 건 아니다.
첫째는 캐시다. CDN이나 리버스 프록시는 응답을 저장해뒀다가 같은 요청이 오면 재사용한다. 이때 무엇을 같은 요청으로 볼지 정하는 게 캐시 키다. 보통 경로와 쿼리스트링 정도가 들어가고, 나머지 헤더는 대부분 안 들어간다. 캐시 키에 안 들어가는 헤더를 unkeyed 헤더라고 부르는데 X-Forwarded-Host가 대표적이다.
여기서 두 시스템의 판단이 어긋난다. 앱은 이 헤더를 보고 페이지 안에 들어갈 절대 URL을 만든다. 캐시는 이 헤더를 안 보고 "같은 경로면 같은 응답"이라고 판단한다. 가장 흔한 경로가 스크립트 주소다.
<script src="https://{X-Forwarded-Host 값}/static/app.js"></script>
공격자가 이 헤더에 자기 도메인을 적어 한 번 요청하면, 그 도메인이 박힌 HTML이 캐시에 저장된다. 그 뒤로 정상적으로 접속한 사람들은 공격자 서버에서 자바스크립트를 받아다 실행한다. 공격자는 요청 한 번 보내고 나가면 되고, 피해자 쪽에서는 주소창도 인증서도 전부 정상이다.
둘째는 서버가 자기 판단에 Host를 쓰는 경우다. 여기가 더 나쁘다.
Next.js에서 나온 CVE-2026-64649는 Server Actions가 대상이다. 14.1.1부터 15.5.20까지, 그리고 16.0.0부터 16.2.10까지가 해당된다. 들어온 Host 헤더가 신뢰할 값으로 고정돼 있지 않으면 서버가 공격자가 지정한 호스트로 요청을 내보내게 된다.
이게 왜 심각한지는 그 요청이 어디서 출발하는지를 보면 된다. 공격자의 브라우저가 아니라 우리 서버가 보내는 요청이다. 방화벽 안쪽에서 출발하니 밖에서는 닿을 수 없는 주소에 닿는다. 클라우드 인스턴스의 메타데이터 주소나 내부망에만 열어둔 관리 API가 그런 곳이다. 이런 공격을 SSRF라고 부르고, 이 시리즈에서 한 번 다룬 적이 있다. CVSS 점수는 8.3이고 15.5.21과 16.2.11에서 고쳐졌다.
여기서 갈리는 지점이 배포 방식이다. Vercel 같은 관리형 호스팅이나 next start, 14.2 이상의 standalone 출력으로 띄운 경우는 기본 설정이 막아준다. 문제가 되는 건 커스텀 서버를 직접 짜서 self-host하는 구성, 즉 "직접 서버에 올려보자"고 마음먹은 쪽이다. 권고안에 적힌 완화책도 두 가지다. 엣지나 프록시에서 Host와 X-Forwarded-Host를 고정하거나, __NEXT_PRIVATE_ORIGIN 환경변수에 실제 오리진을 박아두는 것.
LiteLLM에서 나온 CVE-2026-49468은 CVSS 점수가 9.5다. 이쪽은 인증 자체가 뚫렸다. LiteLLM 프록시의 인증 계층이 어떤 라우트로 들어온 요청인지 판단할 때 Host 헤더를 봤는데, 그러다 보니 FastAPI가 실제로 연결한 라우트와 인증 계층이 판단한 라우트가 어긋날 수 있었다. Host를 잘 조합하면 관리용 라우트에 인증 없이 들어갈 수 있었다는 뜻이다. 1.84.0에서 고쳐졌다.
LiteLLM을 굳이 넣은 이유가 있다. 여러 모델 API를 하나로 묶어주는 프록시라 AI 앱을 만드는 사람들이 직접 서버에 올려 쓰는 물건이다. 그 앞에 Cloudflare나 Host를 검사하는 리버스 프록시를 둔 경우는 대부분 영향을 받지 않았다. 반대로 말하면 EC2에 올리고 포트만 열어둔 구성이 정확히 대상이었다.

내 서버가 믿고 있는지 확인하기
확인은 요청 한 번이면 된다. Host를 엉뚱한 값으로 바꿔서 보내본다.
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Host: evil.example" http://localhost:3000/
curl -s -H "X-Forwarded-Host: evil.example" \
http://localhost:3000/ | grep -o 'evil.example'
첫 번째에서 400이나 403이 오면 서버가 Host를 검사하고 있는 것이다. 200이 오면 아무 이름으로나 불러도 응답한다는 뜻이다. 두 번째는 응답 본문에 evil.example이 박혀 나오는지 보는 것인데, 한 줄이라도 잡히면 그 값으로 URL을 만들고 있다는 증거다.
여기서 조준점을 조심해야 한다. 배포된 도메인에 대고 쏘면 앞단의 CDN이나 로드밸런서가 먼저 걸러서 403을 돌려줄 수 있다. 앱은 여전히 Host를 믿고 있는데 안전하다고 오해하게 된다. 내가 확인하려는 건 앱의 동작이니 로컬 개발 서버나 오리진 주소에 직접 보내야 한다.
메일 쪽은 더 직접적이다. 개발 환경에서 자기 계정으로 재설정을 요청하되 Host만 바꿔서 보내고, 도착한 메일의 링크 도메인을 눈으로 확인하면 된다.
고치는 순서
절대 URL은 Host에서 만들지 않는다. 가장 확실한 방법이고 대체로 가장 쉽다. 서비스 주소는 배포할 때 이미 정해져 있으니 환경변수에 넣어두고 그걸 쓰면 된다.
const BASE_URL = process.env.PUBLIC_BASE_URL; // https://myapp.com
const link = `${BASE_URL}/reset?token=${token}`;
Django라면 request.get_host() 대신 설정에 둔 값을 쓰고, Next.js self-host라면 __NEXT_PRIVATE_ORIGIN을 지정한다. 이렇게 하면 요청에 뭐가 적혀 오든 링크는 바뀌지 않는다. 검사를 정교하게 만드는 것보다 값을 안 읽는 쪽이 낫다.
그래도 Host는 검사한다. URL을 안 만들어도 Host를 보는 코드가 어딘가에 남아 있을 수 있고, 라이브러리가 볼 수도 있다. Django면 ALLOWED_HOSTS에 실제 도메인을 적고, Rails면 config.hosts에 넣는다. Express나 Koa처럼 장치가 없는 쪽은 미들웨어를 하나 두고 완전 일치로 비교한다. 부분 문자열 비교는 쓰지 않는다. host.endsWith("myapp.com") 같은 코드를 넣으면 evil-myapp.com이 통과한다.
X-Forwarded-Host는 맨 앞에서 정리한다. 이 헤더를 앱이 읽는 구성이라면, 외부에서 들어온 값은 엣지에서 지우거나 덮어써야 한다. nginx라면 proxy_set_header X-Forwarded-Host $host;처럼 프록시가 직접 채우게 한다. 그러지 않으면 클라이언트가 적어 보낸 값이 그대로 뒤로 넘어간다.
에이전트한테 인증 관련 기능을 시킬 때는 이 문장을 요청에 같이 적어주면 대체로 맞게 나온다. "메일이나 리다이렉트에 들어갈 절대 URL은 req.headers.host가 아니라 환경변수 PUBLIC_BASE_URL로 만들고, Host 헤더는 허용목록과 완전 일치로 검사해줘."
정리
요청 안에 들어 있는 값은 전부 클라이언트가 쓴 것이다. 이 규칙에 예외는 없는데 Host만 오래 예외처럼 다뤄져 왔다. 다른 헤더는 부가 정보처럼 생겼지만 Host는 주소처럼 생겼기 때문이다. 주소는 남이 정해주는 게 아니라는 감각이 있어서, 그 값을 서버가 아는 사실처럼 쓰게 된다.
서버가 자기 도메인을 아는 방법은 하나뿐이다. 배포할 때 우리가 적어주는 것. IRRd가 고친 방식이 그랬고 Next.js가 권고한 방식도 그랬다. 둘 다 이미 알고 있던 값을 꺼내 쓰도록 바꿨을 뿐이다.
이 값을 읽는 코드는 대체로 눈에 잘 안 띄는 자리에 있다. 비밀번호 재설정, 이메일 인증, OAuth 콜백, 결제 완료 후 돌아올 주소. 한 번 만들어놓고 다시 안 여는 곳이면서 하나같이 절대 URL이 필요한 곳이다. 에이전트가 짜준 코드라면 headers.host, get_host, X-Forwarded-Host 세 패턴을 한 번 검색해보는 것으로 충분하다.