Feb 27, 2026·6 min read·6 visits
Koa versions < 2.16.4 and < 3.1.2 utilized a flawed string-splitting method to parse the HTTP Host header. Attackers can inject an '@' symbol to trick the parser into identifying a malicious domain as the hostname. This enables URL spoofing attacks, notably compromising password reset flows.
A high-severity Host Header Injection vulnerability in the popular Node.js framework Koa allows attackers to manipulate context.hostname via malformed headers. By exploiting Koa's naive string-splitting logic, attackers can spoof the hostname used by the application for critical functions like URL generation and routing, leading to password reset poisoning and potential cache poisoning.
Koa is often touted as the "elegant," "minimalist" successor to Express. It strips away the bloat, giving you a bare-bones context (ctx) and a promise-based middleware stack. It’s beautiful code. But in the world of security, "minimalism" is often a polite synonym for "we didn't implement the safety rails because they looked ugly."
Here is the problem with minimalism: The HTTP specification is not minimal. It is a sprawling, ancient beast full of optional parameters, legacy authentication schemes, and edge cases that haven't been relevant since the 90s but are still technically valid. Koa's developers fell into a classic trap: assuming that a Host header is always just hostname:port.
CVE-2026-27959 is the result of that assumption. It’s a logic error that allows an attacker to lie to the application about where it is running, simply by confusing the parser with a character that virtually no modern web app expects to see in a Host header: the @ symbol.
To understand this vulnerability, we have to look at how Koa handles the ctx.hostname property. When a request comes in, the application often needs to know its own hostname to generate absolute URLs (e.g., for email links or OAuth callbacks). Koa derives this from the Host header (or X-Forwarded-Host if trusted).
The vulnerability lies in lib/request.js. The developers needed to strip the port number from the Host header to get the clean hostname. Their logic was effectively this:
// The naive implementation
const host = this.get('Host'); // e.g., "localhost:3000"
if (!host) return '';
if ('[' === host[0]) return this.URL.hostname || ''; // IPv6 handling
return host.split(':', 1)[0];Do you see the issue? split(':', 1)[0] takes everything before the first colon. This works perfectly for google.com (no colon) and google.com:443 (returns google.com).
But RFC 3986 defines the authority component of a URI as:
[ userinfo "@" ] host [ ":" port ]
This means user:password@domain.com is a valid structure. If an attacker sends Host: evil.com:junk@victim.com, Koa's naive parser sees the colon after evil.com, splits there, and returns evil.com. It ignores the fact that evil.com:junk is actually the userinfo part of the authority, and the real host is victim.com.
Let's look at the actual diff that fixed this. The patch didn't just tweak the regex; it completely capitulated to the complexity of the spec by delegating parsing to the WHATWG URL API.
The Vulnerable Code (simplified):
// Old logic
get hostname() {
const host = this.get('Host');
// ... IPv6 checks ...
return host.split(':', 1)[0]; // The fatal flaw
}The Fix (v2.16.4 / v3.1.2):
// New logic
get hostname() {
let host = this.get('Host');
// ... IPv6 checks ...
// Handle Userinfo confusion
if (host.includes('@')) {
try {
// Wrap in a protocol to make it a valid URL string for parsing
const url = new URL(`http://${host}`);
host = url.hostname;
} catch (e) {
// If parsing fails, fail safe
return '';
}
} else {
// Safe to split if no '@' is present
host = host.split(':', 1)[0];
}
return host;
}The fix explicitly checks for the @ symbol. If present, it stops playing string-manipulation games and constructs a full URL object to parse the string correctly. This is computationally more expensive, but it prevents the bypass.
How do we weaponize this? The most common impact of Host Header Injection is Password Reset Poisoning. Developers love to use ctx.hostname (or req.hostname in Express) to build the link sent in password reset emails.
The Scenario:
Vulnerable Code:
router.post('/forgot', async (ctx) => {
const token = generateToken();
// VULNERABLE: Relies on ctx.hostname
const link = `${ctx.protocol}://${ctx.hostname}/reset?token=${token}`;
await sendEmail(ctx.request.body.email, link);
});The Attack: The attacker triggers the password reset flow for the victim's email address, but modifies the Host header.
POST /forgot HTTP/1.1
Host: attacker.com:xy@victim-site.com
Content-Type: application/json
{ "email": "ceo@victim-site.com" }The Execution:
attacker.com:xy@victim-site.com. It splits at the first colon.ctx.hostname becomes attacker.com.http://attacker.com/reset?token=....The Payoff:
The CEO sees an email from the legitimate victim-site.com (because the email sender wasn't spoofed). They click the link, expecting to reset their password. Instead, their browser sends a GET request to attacker.com with the valid reset token in the URL query string. The attacker logs the token, uses it on the real site, and takes over the account.
While this isn't Remote Code Execution (RCE) out of the box, the Integrity impact is high. Host Header Injection is a facilitator vulnerability—it turns innocent features against the user.
victim-site.com could be served content containing malicious links to attacker.com.ctx.hostname is used to validate redirect_uri in OAuth flows, an attacker could bypass whitelist checks or steal authorization codes.> [!WARNING]
> Even with the patch, beware of ctx.origin. The patch fixes ctx.hostname, but ctx.origin (which is often protocol + '://' + host) might still return the raw, tainted header in some contexts or frameworks. Always validate.
The immediate fix is simple: Upgrade.
Defense in Depth:
Don't rely solely on the framework to parse headers correctly. The Host header is user input. Treat it like toxic waste.
server_name entries. If Nginx receives a request for attacker.com, it should drop it before it ever reaches Node.js.api.example.com, put that in your ENV variables (BASE_URL=https://api.example.com). Use that variable to generate links, not ctx.hostname. Trusting the client to tell you who you are is like trusting a stranger to hold your wallet.CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
koa koajs | < 2.16.4 | 2.16.4 |
koa koajs | >= 3.0.0 < 3.1.2 | 3.1.2 |
| Attribute | Detail |
|---|---|
| Attack Vector | Network (Remote) |
| CVSS v3.1 | 7.5 (High) |
| Impact | Integrity (High) |
| Exploit Status | PoC Available |
| Weakness | Improper Input Validation |
| EPSS | 0.05% |