no-host-header-in-links
Disallow building absolute URLs (reset/verification links) from the Host or X-Forwarded-Host request header
Keywords: host header poisoning, password reset poisoning, X-Forwarded-Host, CWE-640, account takeover, reset link, verification link, express security, nodemailer, sendMail
Disallow building absolute URLs (password-reset and verification links) from the Host or X-Forwarded-Host request header.
CWE: CWE-640
OWASP: A07:2021 – Identification and Authentication Failures
Detects Host-header poisoning: absolute URLs — most critically password-reset and account-verification links — built from req.headers.host, req.headers['x-forwarded-host'], or req.get('host'). Both headers are attacker-controlled on any request that reaches the app directly, so a mailed link built from them can point the recovery token at an attacker-owned server. This rule is part of eslint-plugin-express-security and provides LLM-optimized error messages.
🚨 Security rule | 💡 Provides LLM-optimized guidance | ⚠️ Set to error in recommended
Quick Summary
| Aspect | Details |
|---|---|
| CWE Reference | CWE-640 (Weak Password Recovery) |
| Severity | 🔴 HIGH (account takeover via reset-link poisoning) |
| Auto-Fix | ❌ Not available (💡 suggestion: use a configured origin) |
| Category | Security |
| ESLint MCP | ✅ Optimized for ESLint MCP integration |
| Best For | Express apps that email password-reset, invite, or verification links |
Vulnerability and Risk
Vulnerability: The Host header (and X-Forwarded-Host, which most proxies pass through untouched) is chosen by the client. When a reset link is built as 'https://' + req.headers.host + '/reset?token=' + token, an attacker requests a reset for the victim's email with Host: evil.example — and the victim receives a legitimate-looking email whose link delivers the reset token straight to the attacker.
Risk:
- Account takeover: The leaked reset token lets the attacker set a new password for the victim's account.
- Silent exploitation: The victim initiated nothing; a forged reset request plus one click on a real email from the real sender is enough.
- Cache poisoning amplification: Host-header-derived URLs stored in caches or emails persist the poisoned origin beyond a single request.
Rule Details
The rule flags a host-header read (or a variable assigned from one in the same file) that participates in URL-building string concatenation or a template literal. "URL-building" means a static fragment contains :// or starts with //, OR the string is an argument of a mail-send call (sendMail / send by default).
Error Message Format
The rule provides LLM-optimized error messages with actionable security guidance:
🔒 CWE-640 | Host-Header Poisoning (CWE-640) | HIGH
A URL is built from req.headers['host'], which the client controls.
Fix: Build absolute links from a server-side constant (e.g. process.env.PUBLIC_ORIGIN), never from request headers. | https://cwe.mitre.org/data/definitions/640.htmlConfiguration
| Option | Type | Default | Description |
|---|---|---|---|
allowedHosts | string[] | [] | Trusted literal hosts — an if-guard comparing against one suppresses reports |
checkMailCallees | string[] | ['sendMail', 'send'] | Callee names treated as mail-send sinks |
Example Configuration
{
"rules": {
"express-security/no-host-header-in-links": [
"error",
{
"allowedHosts": ["app.example.com"],
"checkMailCallees": ["sendMail", "send", "deliver"]
}
]
}
}Examples
❌ Incorrect
// ❌ Reset link built from the Host header
const resetUrl = 'https://' + req.headers.host + '/reset?token=' + token;
// ❌ X-Forwarded-Host is just as attacker-controlled
const origin = req.headers['x-forwarded-host'] || req.headers.host;
await mailer.sendMail({
to: user.email,
text: 'Recover here: https://' + origin + '/recover/' + token,
});
// ❌ Template literals are flagged too
const link = `https://${req.get('host')}/verify?code=${code}`;✅ Correct
// ✅ Origin is a deployment constant
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || 'https://app.example.com';
const resetUrl = PUBLIC_ORIGIN + '/reset?token=' + encodeURIComponent(token);
// ✅ Host used only for logging — no URL building
console.log('incoming host: ' + req.headers.host);
// ✅ Host validated against an allowlist (with allowedHosts configured)
const host = req.headers.host;
if (host === 'app.example.com') {
const url = 'https://' + host + '/reset';
}Security Impact
| Vulnerability | CWE | OWASP | CVSS | Impact |
|---|---|---|---|---|
| Weak Password Recovery | 640 | A07:2021 | 8.1 | Account takeover |
| Open Redirect (related) | 601 | A01:2021 | 6.1 | Phishing amplification |
| Cache Poisoning (related) | 444 | A05:2021 | 6.5 | Persistent poisoned responses |
Why This Matters
Real-World Exploits
Password-reset poisoning via the Host header is a classic, repeatedly rediscovered bug class — it has been reported against Django, WordPress plugins, and countless bespoke Express apps. The attack needs no victim interaction beyond clicking a genuine email from the genuine sender, which makes it far more convincing than ordinary phishing.
Prevention Strategy
- Configured origin: Read the public origin from configuration (
process.env.PUBLIC_ORIGIN) — never from the request. - Host allowlist at the edge: Reject requests whose
Hostis not in a known set before they reach application code. - Proxy hygiene: Strip or overwrite
X-Forwarded-Hostat the first trusted proxy.
Known False Negatives
The following patterns are not detected due to static analysis limitations:
Cross-function flow
Why: Tracking is same-file, single-hop (variable assigned directly from a host read).
// ❌ NOT DETECTED
function origin(req) {
return req.headers.host;
}
const url = 'https://' + origin(req);Computed access chains
Why: req['headers'].host and req.headers[name] are not resolved.
// ❌ NOT DETECTED
const h = req['headers'].host;URL construction APIs
Why: Only string concatenation and template literals are analyzed.
// ❌ NOT DETECTED
const url = new URL(path, 'https://' + req.headers.host);Related Rules
no-user-controlled-redirect- Prevents open redirects from request data.no-error-details-in-response- Prevents leaking stack traces.no-sensitive-data-in-query- Prevents secrets in query strings.
Further Reading
- CWE-640: Weak Password Recovery Mechanism
- OWASP: Forgot Password Cheat Sheet
- PortSwigger: Password reset poisoning
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.
no-graphql-introspection-production
This rule detects GraphQL servers with introspection enabled in production
no-idor-resource-access
This rule detects a resource fetched by an identifier taken straight from the request inside a handler that never mentions the authenticated principal — the classic IDOR shape