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')orheaders().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
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
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.