By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.
18px_cookie
e-remove
Blog
Glossary
Customer Story
Video
eBook / Report
Solution Brief

CVE-2026-27959: Userinfo Host Header Injection in Koa

Endor Labs researcher found CVE-2026-27959 in Koa: userinfo host injection via ctx.hostname enables password reset poisoning. Patch to koa 2.16.4 or 3.1.2

Endor Labs researcher found CVE-2026-27959 in Koa: userinfo host injection via ctx.hostname enables password reset poisoning. Patch to koa 2.16.4 or 3.1.2

Endor Labs researcher found CVE-2026-27959 in Koa: userinfo host injection via ctx.hostname enables password reset poisoning. Patch to koa 2.16.4 or 3.1.2

Written by
Peyton Kennedy
Peyton Kennedy
Published on
February 25, 2026
Updated on
February 25, 2026

Endor Labs researcher found CVE-2026-27959 in Koa: userinfo host injection via ctx.hostname enables password reset poisoning. Patch to koa 2.16.4 or 3.1.2

Endor Labs researcher found CVE-2026-27959 in Koa: userinfo host injection via ctx.hostname enables password reset poisoning. Patch to koa 2.16.4 or 3.1.2

On January 23, 2026, while auditing how Node.js web frameworks parse and expose the HTTP Host header to application code, I found that Koa's ctx.hostname getter returns attacker-controlled data when a Host header is crafted to contain a userinfo component.

I'm calling this class of injection userinfo host header injection: an attack that exploits the RFC 3986 userinfo sub-component of a URI's authority to smuggle an attacker-controlled hostname past a framework's naive port-stripping parser. The input is syntactically valid by the URI specification, which is what makes it dangerous. The `@` character has a well-defined structural role in URI authority syntax, and a parser that does not account for it will misidentify the userinfo segment as the hostname.

This is tracked as CVE-2026-27959 / GHSA-7gcc-r8m5-44qm. The CVSS v3.1 score is 7.5 (High), with a network attack vector, low complexity, and no privileges or user interaction required. Koa is downloaded over 6 million times per week on npm.

Koa has since fixed this issue. Versions 2.16.4 and 3.1.2 address the vulnerability. Applications still running Koa 2.16.3 or below, or Koa 3.0.0 through 3.1.1, and using ctx.hostname to construct security-sensitive URLs such as password reset links, email verification URLs, or OAuth callbacks, should upgrade immediately.

Affected versions

Package Name Version Published (UTC) Status
koa <3.1.2 & <2.16.4 Feb 25, 2026 Patched

Timeline

I initially reported this to the Koa maintainers on January 23, 2026. Koa maintainers coordinated the fix and shipped patched versions 2.16.4 and 3.1.2 on February 25, 2026, the same day as public disclosure.

Technical analysis

The Code

Koa exposes a hostname getter in lib/request.js meant to return just the hostname portion of the incoming Host header with any port number stripped. The host getter feeds it the raw header value, with fallbacks for HTTP/2 and proxy configurations:

// lib/request.js (Koa 2.16.3)
get host() {
  const proxy = this.app.proxy;
  let host = proxy && this.get('X-Forwarded-Host');
  if (!host) {
    if (this.req.httpVersionMajor >= 2) host = this.get(':authority');
    if (!host) host = this.get('Host');
  }
  if (!host) return '';
  return host.split(',')[0].trim();
}

get hostname() {
  const host = this.host;
  if (!host) return '';
  if ('[' === host[0]) return this.URL.hostname || ''; // IPv6 literal
  return host.split(':', 1)[0];
}

The port-stripping logic in hostname is a single operation: split the string on : and return the first segment. For a normal input like example.com:3000 this works as expected and returns example.com. The problem is that this logic only accounts for one possible structure of the URI authority component. RFC 3986 defines a richer grammar with multiple sub-components, and Koa validates against none of them.

Why a Userinfo URI Is Syntactically Valid

This is where the class of vulnerability gets interesting, and where it differs from a simple header injection. The payload I use is not malformed in the sense of being a broken or nonsensical string. It is a syntactically valid URI authority component as defined by RFC 3986, and the fact that it is valid is precisely what enables the attack.

Section 3 of RFC 3986 defines the structure of a URI:

URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ]

hier-part = "//" authority path-abempty

Section 3.2 defines the authority component:

authority = [ userinfo "@" ] host [ ":" port ]

The authority has three sub-components: an optional userinfo segment, a required host, and an optional port. The @ character is defined as the structural delimiter between userinfo and host. It is a reserved character with a specific syntactic role, not a character that can appear in a hostname.

Section 3.2.1 defines what is permitted inside the userinfo sub-component:

userinfo    = *( unreserved / pct-encoded / sub-delims / ":" )
unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
sub-delims  = "!" / "$" / "&" / "'" / "(" / ")"
            / "*" / "+" / "," / ";" / "="

Notice that userinfo permits ALPHA, DIGIT, ., and : among other characters. A string like evil.com:fake is a perfectly valid userinfo value under this grammar. RFC 3986 is not being violated when this string appears before an @. The full authority string evil.com:fake@legitimate.com:3000 is valid RFC 3986 syntax, and a conformant parser would decompose it as:

userinfo = "evil.com:fake"
host     = "legitimate.com"
port     = "3000"

Section 3.2.2 defines the host sub-component separately, and it is critical to understand why @ can never appear in a valid hostname. The grammar for host is:

host      = IP-literal / IPv4address / reg-name
reg-name  = *( unreserved / pct-encoded / sub-delims )

The character sets for unreserved and sub-delims are defined above. The @ character does not appear in either. It is not listed in unreserved. It is not listed in sub-delims. It cannot be included in a reg-name as a literal character. The only way @ can appear in a URI authority is as the structural delimiter terminating the userinfo segment, and if it appears there, everything to its left is userinfo by definition.

This is the design the RFC imposes: userinfo and host are mutually exclusive regions of the authority component, separated by @. The host begins immediately after @ and ends before the first : that is followed by a port number. A parser that encounters @ in a Host header and does not treat it as the userinfo/host boundary is non-conformant and exploitable.

Koa's hostname getter does not parse the authority component. It receives a raw string and splits on :. When the input is evil.com:fake@legitimate.com:3000, Koa splits on the first : and returns evil.com. That string is the beginning of the userinfo segment, not the host. Because Koa never scans for @, it has no mechanism to recognize that distinction. The result is a valid-looking hostname value that is entirely attacker-controlled.

This is the essence of userinfo host injection: the attacker crafts an authority string that is valid per RFC 3986 but structured such that the userinfo segment, not the host, will be extracted by a naive port-stripping parser. The downstream impact falls on older versions of Koa because their hostname getter treats the full header as a flat host:port string rather than as a structured authority component with potentially three sub-fields.

The Attack

When I send the following request to an unpatched Koa application:

GET / HTTP/1.1
Host: evil.com:fake@legitimate.com:3000

Koa's ctx.hostname returns the following:

API Returns
ctx.get('Host') evil.com:fake@legitimate.com:3000
ctx.hostname evil.com (attacker-controlled)

The HTTP connection itself still routes to legitimate.com:3000. The socket-level destination is determined by DNS and TCP, not by the Host header content. The server receives and processes the request normally. Koa reads the Host header, splits on the first :, and returns evil.com from ctx.hostname. If application code then uses ctx.hostname to construct a URL, that URL points to the attacker's domain.

These variations all produce the same result:

# Port on the attacker domain side
curl -H "Host: evil.com:443@legitimate.com:3000" http://localhost:3000/forgot-password

# Minimal form, no port on either side
curl -H "Host: evil.com:x@legitimate.com" http://localhost:3000/forgot-password

Attack Scenario: Password Reset Poisoning

The highest-impact exploitation path targets password reset link generation, which is one of the most common places ctx.hostname appears in real applications. A typical pattern looks like this:

app.use(async ctx => {
  if (ctx.path === '/forgot-password') {
    const token = generateSecureToken();
    const resetUrl = `${ctx.protocol}://${ctx.hostname}/reset?token=${token}`;
    await sendResetEmail(user.email, resetUrl);
    ctx.body = { ok: true };
  }
});

An attacker targeting a specific account sends:

curl -H "Host: evil.com:fake@legitimate.com:3000" \
  https://legitimate.com/forgot-password \
  -d '{"email":"victim@example.com"}'

The server generates https://evil.com/reset?token=SECRET and delivers it to the victim's inbox. When the victim clicks the link, their browser makes a request to evil.com, delivering the reset token to the attacker. The attacker uses it to set a new password and take over the account. The victim receives a legitimate-looking email from a trusted domain. The token in the URL appears normal. Nothing in the email indicates the link destination has been redirected.

The same attack pattern applies to email verification flows, webhook URL registration, multi-tenant host routing decisions, and OAuth redirect URI construction.

HTTP/2 and Proxy Paths Are Equally Affected

Koa's host getter has explicit handling for two additional input paths. For HTTP/2 connections, it reads the :authority pseudo-header. When app.proxy = true is set, it reads X-Forwarded-Host. Both are funneled through the same hostname getter without modification. In unpatched versions, both carry the same risk. If app.proxy = true is configured and an attacker can influence X-Forwarded-Host, they do not need to control the TCP-level Host header to execute this attack.

Preconditions for Exploitation

The crafted Host header needs to reach the Koa application. This occurs when:

  • The application is directly internet-exposed with no reverse proxy in front of it.
  • A reverse proxy passes the client-supplied Host header through unmodified.
  • app.proxy = true is set and upstream infrastructure does not sanitize X-Forwarded-Host. The server is the default or catch-all virtual host for any Host value not matched by the proxy.

In environments where a load balancer unconditionally rewrites the Host header to the canonical upstream value, the attack surface is reduced. However, most reverse proxy configurations pass Host through by default, often intentionally to support virtual hosting.

Mitigation

Koa has fixed this issue in versions 2.16.4 and 3.1.2. The fix adds validation to the hostname getter that detects and rejects authority strings containing @, preventing the userinfo segment from being returned as the hostname. Upgrading is the correct first step.

Detection

Search your codebase for uses of ctx.hostname in contexts where the value is used to construct a URL or drive a routing decision:

grep -rn "ctx\.hostname\|request\.hostname\|req\.hostname" src/

Any code path that feeds ctx.hostname into a string used for email links, redirects, webhook registration, or OAuth flows should be treated as affected on unpatched versions.

Short-Term: Upgrade

Upgrade Koa to a fixed version:

  • Koa 2.x: >= 2.16.4
  • Koa 3.x: >= 3.1.2
npm install koa@latest
# Or pin to the specific patch release:
npm install koa@2.16.4  # for 2.x
npm install koa@3.1.2   # for 3.x

Check package.json for any resolutions or overrides entries that might hold Koa at a vulnerable version.

Medium-Term: Defense in Depth

Even on patched versions, the following controls reduce residual risk from userinfo host injection across any framework.

Validate Host headers at the proxy layer. Configure your reverse proxy (nginx, Caddy, AWS ALB) to set the Host header to a known canonical value before forwarding requests to the application. This eliminates the attack at the network boundary regardless of how application-layer code handles the header.

Allowlist expected hostnames. If ctx.hostname is used for any security-sensitive operation, validate it against a known-good set before acting on it:

const ALLOWED_HOSTS = new Set(['legitimate.com', 'www.legitimate.com']);

function getSafeHostname(ctx) {
  const host = ctx.hostname;
  if (!ALLOWED_HOSTS.has(host)) {
    throw new Error(`Unexpected hostname: ${host}`);
  }
  return host;
}

Prefer environment-configured base URLs. For password reset and email verification flows, the most robust approach is to stop deriving the base URL from the request entirely. Use an environment variable instead:

const resetUrl = `${process.env.APP_BASE_URL}/reset?token=${token}`;

This makes URL generation fully independent of anything an attacker can supply over the wire.

Audit app.proxy usage. If app.proxy = true is configured, ensure only trusted upstream infrastructure can set X-Forwarded-Host. Accepting it from arbitrary clients is the most common configuration that broadens this attack surface.

Find out More

The Challenge

The Solution

The Impact

Welcome to the resistance
Oops! Something went wrong while submitting the form.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.