Email link manipulation through Host header poisoning

Token exposure through email links built from an untrusted Host value

Description

Host header poisoning occurs when an application builds absolute email links using a client-supplied Host value, such as req.host, req.hostname, req.get('host') or headers().get('host'). An attacker may change that value so a password-reset or email-verification link points to the attacker's domain. If the recipient opens the link, a reset token or magic-login token may be sent to the attacker and used for account takeover or phishing.

Potential impact

  • A leaked reset or verification token may allow account takeover.
  • One-time tokens and session-related values may support further attacks.
  • An attacker-controlled link inside legitimate email may facilitate phishing or impersonation.
  • Incorrect links may damage customer trust and increase support and incident-response costs.

Remediation

  • Build absolute URLs from a trusted configured base URL, such as APP_BASE_URL=https://example.com. Do not use the request's Host value.
  • Avoid Host-derived values such as req.host, req.hostname, req.headers.host, req.get('host') or headers().get('host') when constructing email links.
  • For multiple tenant domains, use a domain registered for the tenant in trusted configuration and validate it against an allow-list. Do not derive the destination from the request.

Examples

Before

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

// Illustrative store; use a shared database with expiration indexing in production.
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); // Prevent reuse regardless of the result
  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);

  // Use a Host header that the client can manipulate
  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" });
});

After

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

// Illustrative store; use a shared database with expiration indexing in production.
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); // Prevent reuse regardless of the result
  return record && record.expiresAt > Date.now() ? record.email : null;
}

// Load the base URL from trusted configuration
const ALLOWED_BASE_URLS = new Set([
  "https://accounts.example.com",
  "https://accounts.example.kr",
]);
const BASE_URL = process.env.APP_BASE_URL; // Set during deployment
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);

  // Build the absolute link from configuration, not the request Host
  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" });
});

The first example uses a manipulated Host as the email link destination. Opening that link can disclose the reset token to the attacker.

The second checks the trusted APP_BASE_URL against an allow-list, preventing the request's Host from changing the destination. The in-memory store and token functions are illustrative. In production, enforce atomic single use, expiration and issuance limits in a shared store, and verify the token and target account before changing a password.

References