Topic: mcp server host header injection security
MCP server Host header injection security — password reset poisoning, cache poisoning, and internal routing abuse
The HTTP Host header tells a server which virtual host a request is targeting. It is set by the client, not the server — which means it is attacker-controlled input. MCP server applications that use the Host header to construct absolute URLs (password reset links, webhook callbacks, OAuth redirect URIs) or to route requests to internal services are vulnerable to Host header injection: an attacker substitutes an adversary-controlled domain and poisons every URL the application generates from it.
Attack 1 — password reset link poisoning
The most directly exploitable Host header injection target is password reset email generation. When an application constructs the reset URL from the incoming Host header, an attacker who can send a password reset request with a forged Host header poisons the reset link sent to the victim's email address:
// Vulnerable Node.js password reset endpoint
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
const token = crypto.randomBytes(32).toString('hex');
await db.collection('reset_tokens').insertOne({ email, token, expires: Date.now() + 3600000 });
// VULNERABLE: Host header used to construct reset URL
const host = req.headers['host']; // attacker controls this
const resetUrl = `https://${host}/auth/reset?token=${token}`;
await sendEmail(email, 'Password reset', `Click here: ${resetUrl}`);
res.json({ message: 'Reset email sent' });
});
Attacker flow:
- Send
POST /auth/forgot-passwordwith body{"email":"victim@example.com"}and headerHost: attacker.com - The server stores a valid reset token and emails the victim a link pointing to
https://attacker.com/auth/reset?token=TOKEN - When the victim clicks the link, the attacker's server receives the valid reset token and can use it to change the victim's password
Attack 2 — X-Forwarded-Host and internal routing bypass
Many MCP deployments run behind a reverse proxy that sets X-Forwarded-Host to convey the original Host header to the back-end. If the application trusts X-Forwarded-Host from any source without validating that it arrived via the trusted proxy, an attacker who can reach the back-end directly (or who can inject headers at the proxy layer) can manipulate internal routing:
// Vulnerable internal routing via X-Forwarded-Host
app.use((req, res, next) => {
// Route requests to different internal services based on host
const host = req.headers['x-forwarded-host'] || req.headers['host'];
if (host.startsWith('admin.')) {
// Route to admin service — no authentication required here because
// "only the proxy can set x-forwarded-host"
return proxyTo('http://internal-admin:8080', req, res);
}
next();
});
// Attack: send X-Forwarded-Host: admin.internal
// → bypasses authentication, routes directly to the admin service
The vulnerability is that X-Forwarded-Host is a regular HTTP header that any client can set. The assumption that "only the proxy sets it" is only valid if requests cannot reach the back-end without passing through the proxy — a guarantee that is often broken by internal network access, misconfigured security groups, or a shared VPC.
Attack 3 — reverse proxy cache poisoning via Host header
Reverse proxies like Varnish, Nginx, and CloudFront use the Host header as part of the cache key. If the application generates response content that includes the Host value (in absolute links, canonical URLs, or og:url meta tags), and the cache key includes a header that the attacker controls, the attacker can poison the cache with responses containing attacker-controlled URLs:
# Nginx caching configuration — vulnerable
proxy_cache_key "$scheme$host$request_uri";
# If the attacker can control $host via a request, the poisoned response
# is stored under the attacker's chosen key and served to subsequent users
The attack requires the proxy to use an attacker-controlled header as part of the cache key, and the back-end to reflect that header's value in the cacheable response body. When both conditions are true, a single request poisons cached pages for all subsequent users.
Fix 1 — hard-code the base URL; never trust Host for URL generation
The definitive fix for password reset poisoning is to configure the application's base URL statically and never derive it from incoming request headers:
// Safe — base URL from configuration, not from request
const BASE_URL = process.env.APP_BASE_URL; // e.g., "https://skillaudit.dev"
if (!BASE_URL) throw new Error('APP_BASE_URL must be set in environment');
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
const token = crypto.randomBytes(32).toString('hex');
await db.collection('reset_tokens').insertOne({ email, token, expires: Date.now() + 3600000 });
// Safe: Host header not consulted
const resetUrl = `${BASE_URL}/auth/reset?token=${token}`;
await sendEmail(email, 'Password reset', `Click here: ${resetUrl}`);
res.json({ message: 'Reset email sent' });
});
Fix 2 — validate the Host header against an allowlist
For applications that need to serve multiple domains and must use the Host header for routing, validate it against a strict allowlist of known-good hostnames before using it for any purpose:
const ALLOWED_HOSTS = new Set([
'skillaudit.dev',
'www.skillaudit.dev',
'api.skillaudit.dev'
]);
// Middleware — reject requests with unrecognized Host headers
app.use((req, res, next) => {
const host = req.headers['host'];
// Strip port from host header for comparison
const hostname = host ? host.split(':')[0] : '';
if (!ALLOWED_HOSTS.has(hostname)) {
return res.status(400).json({ error: 'Invalid Host header' });
}
// Safe to use hostname for routing after allowlist check
req.validatedHost = hostname;
next();
});
Fix 3 — strip X-Forwarded-Host at the proxy layer; never trust it from untrusted sources
Configure the reverse proxy to strip and re-set X-Forwarded-Host on every incoming request, so the back-end can trust that its value came from the proxy rather than from the client:
# Nginx — strip client-provided X-Forwarded-Host, set it from $host
location /mcp/ {
# Remove any client-provided value
proxy_set_header X-Forwarded-Host "";
# Set to the authenticated proxy's host value
proxy_set_header X-Forwarded-Host $host;
proxy_pass http://mcp_backend;
}
# Caddy equivalent
reverse_proxy /mcp/* localhost:3000 {
header_up -X-Forwarded-Host # remove client value
header_up X-Forwarded-Host {host} # set from caddy's validated host
}
Fix 4 — exclude Host-derived content from cache keys
If cached responses include Host-derived content (canonical URLs, og:url), configure the proxy to normalize the Host header in the cache key, or to add Cache-Control: no-store to any response that reflects the incoming Host:
# Nginx — normalize Host in cache key to prevent poisoning
proxy_cache_key "$scheme$proxy_host$request_uri";
# proxy_host is set by Nginx itself (from proxy_pass), not from the client request
# This makes the cache key independent of the client-provided Host header
SkillAudit checks for Host header injection risk
SkillAudit's static analysis scans for req.headers['host'] and req.headers['x-forwarded-host'] references in URL construction code paths, and for reverse proxy configurations that reflect the Host header in the cache key. An A-grade MCP server uses a statically configured base URL for all absolute URL generation and validates the Host header against an allowlist in its HTTP middleware. See the full MCP server security checklist, and the CRLF injection guide for related header manipulation attacks.
Check your MCP server's HTTP header security
SkillAudit checks for Host header injection patterns, X-Forwarded-Host trust without validation, and cache-key poisoning risks in 60 seconds.
Run a free audit