Host 헤더 오염(Host Header Poisoning)으로 인한 이메일 링크 변조

신뢰하지 않는 Host 값으로 생성한 이메일 링크의 토큰 노출

설명

애플리케이션이 이메일 본문에 절대 URL을 만들 때 클라이언트가 보낸 Host 헤더(예: req.host, req.hostname, req.get('host'), headers().get('host'))를 그대로 사용하면 발생합니다. 공격자는 요청의 Host 헤더를 임의의 도메인으로 바꿔 전송하여, 비밀번호 재설정·이메일 인증 링크가 공격자 도메인으로 생성되게 만들 수 있습니다. 이렇게 생성된 링크를 사용자가 클릭하면 토큰(패스워드 리셋 토큰, 로그인 마법 링크 등)이 공격자에게 전송되어 계정 탈취나 피싱으로 악용될 수 있습니다.

잠재적 영향

  • 계정 탈취: 비밀번호 재설정 토큰이나 이메일 인증 토큰이 공격자 도메인으로 전송되어 사용자의 계정을 장악할 수 있습니다.
  • 민감 토큰 유출: 일회용 토큰, 세션 연계 값 등이 공격자에게 노출되어 추가 공격(세션 하이재킹 등)으로 이어질 수 있습니다.
  • 피싱/브랜드 사칭: 합법적인 발신자의 이메일 안에 공격자 도메인이 포함되어 사용자를 속이기 쉬워집니다.
  • 서비스 신뢰도 저하: 잘못된 링크 유포로 고객 신뢰가 하락하고 CS/보안 사고 비용이 증가합니다.

해결 방법

  • 신뢰 가능한 베이스 URL을 구성에서만 사용: 환경 변수나 설정 파일에서 APP_BASE_URL(예: https://example.com)을 읽어 절대 URL을 생성하세요. 요청의 Host 값은 사용하지 마세요.
  • Host 기반 값 금지: 이메일 링크 생성 시 req.host, req.hostname, req.headers.host, req.get('host'), headers().get('host')를 사용하지 마세요.
  • 허용 목록 검증: 멀티 테넌트 등으로 도메인이 여러 개인 경우, 테넌트별로 사전에 등록된 도메인 목록과 일치할 때만 사용하세요. 저장된 구성에서만 가져오고, 요청에서 파생하지 마세요.

예시

변경 전

javascript
const crypto = require("crypto");
const express = require("express");
const nodemailer = require("nodemailer");
const app = express();
app.use(express.json());

// 예시용 저장소입니다. 운영 환경에서는 만료 인덱스가 있는 공유 DB를 사용합니다.
const resetTokens = new Map();

function tokenHash(token) {
  return crypto.createHash("sha256").update(token).digest("hex");
}

function issueSingleUseResetToken(email) {
  const token = crypto.randomBytes(32).toString("base64url");
  resetTokens.set(tokenHash(token), { email, expiresAt: Date.now() + 15 * 60 * 1000 });
  return token;
}

function consumeResetToken(token) {
  const key = tokenHash(token);
  const record = resetTokens.get(key);
  resetTokens.delete(key); // 성공 여부와 관계없이 재사용 방지
  return record && record.expiresAt > Date.now() ? record.email : null;
}

app.post("/forgot-password", async (req, res) => {
  const transporter = nodemailer.createTransport({ sendmail: true });
  const token = issueSingleUseResetToken(req.body.email);

  // 취약: 클라이언트가 조작 가능한 Host 헤더 사용
  const resetUrl = `https://${req.get("host")}/reset/${token}`;

  await transporter.sendMail({
    to: req.body.email,
    subject: "Reset your password",
    html: `<a href="${resetUrl}">Reset Password</a>`,
  });

  res.json({ message: "email sent" });
});

변경 후

javascript
const crypto = require("crypto");
const express = require("express");
const nodemailer = require("nodemailer");
const app = express();
app.use(express.json());

// 예시용 저장소입니다. 운영 환경에서는 만료 인덱스가 있는 공유 DB를 사용합니다.
const resetTokens = new Map();

function tokenHash(token) {
  return crypto.createHash("sha256").update(token).digest("hex");
}

function issueSingleUseResetToken(email) {
  const token = crypto.randomBytes(32).toString("base64url");
  resetTokens.set(tokenHash(token), { email, expiresAt: Date.now() + 15 * 60 * 1000 });
  return token;
}

function consumeResetToken(token) {
  const key = tokenHash(token);
  const record = resetTokens.get(key);
  resetTokens.delete(key); // 성공 여부와 관계없이 재사용 방지
  return record && record.expiresAt > Date.now() ? record.email : null;
}

// 안전: 신뢰 가능한 구성에서 베이스 URL 로드
const ALLOWED_BASE_URLS = new Set([
  "https://accounts.example.com",
  "https://accounts.example.kr",
]);
const BASE_URL = process.env.APP_BASE_URL; // 배포 시 설정
if (!ALLOWED_BASE_URLS.has(BASE_URL)) {
  throw new Error("Invalid APP_BASE_URL configuration");
}

app.post("/forgot-password", async (req, res) => {
  const transporter = nodemailer.createTransport({ sendmail: true });
  const token = issueSingleUseResetToken(req.body.email);

  // 안전: 요청 Host를 사용하지 않고, 구성된 베이스 URL로 절대 링크 생성
  const resetUrl = new URL(`/reset/${token}`, BASE_URL).toString();

  await transporter.sendMail({
    to: req.body.email,
    subject: "Reset your password",
    html: `<a href="${resetUrl}">Reset Password</a>`,
  });

  res.json({ message: "email sent" });
});

변경 전에는 조작된 Host 값이 이메일 링크의 목적지가 됩니다. 사용자가 링크를 열면 재설정 토큰이 공격자에게 전달될 수 있습니다.

변경 후에는 신뢰하는 APP_BASE_URL을 허용 목록과 비교하므로 요청의 Host가 링크 목적지를 바꾸지 못합니다. 두 예시의 메모리 저장소와 토큰 함수는 설명용입니다. 운영 환경에서는 공유 저장소의 원자적인 단일 사용 처리, 만료와 발급 요청 제한을 적용하고, 실제 비밀번호 변경 전에 토큰과 대상 계정을 검증하세요.

참조