Hostヘッダーポイズニングによるメールリンクの改ざん

信頼できないHost値から作るメールリンクによるトークン漏えい

説明

メールの絶対URLを、クライアントが指定するreq.host、req.hostname、req.get('host')、headers().get('host')などから作ると、Hostヘッダーポイズニングが生じるおそれがあります。攻撃者がHost値を変更し、パスワード再設定やメール認証のリンクを自分のドメインへ向ける攻撃です。受信者がそのリンクを開くと、再設定トークンやログイン用トークンが攻撃者に送られ、アカウントの乗っ取りやフィッシングに使われる可能性があります。

想定される影響

  • 再設定・認証トークンの漏えいにより、アカウントを乗っ取られるおそれがあります。
  • ワンタイムトークンやセッション関連の値が、追加の攻撃に使われる可能性があります。
  • 正規のメールに攻撃者のリンクが含まれ、フィッシングやなりすましを助けるおそれがあります。
  • 誤ったリンクの配布により顧客の信頼が失われ、問い合わせや事故対応の負担が増える可能性があります。

対処方法

  • APP_BASE_URL=https://example.comなど、信頼する設定のベースURLから絶対URLを作ってください。リクエストのHost値を使わないでください。
  • メールリンクの生成にreq.host、req.hostname、req.headers.host、req.get('host')、headers().get('host')などの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で送信先を変えられません。メモリー内ストアとトークン関数は説明用です。本番では共有ストアで使用済みへの変更をアトミックに処理し、有効期限と発行回数を制限してください。実際のパスワード変更前にトークンと対象アカウントを検証してください。

参考資料