Koa has Host Header Injection via `ctx.hostname`
Description
Koa is middleware for Node.js using ES2017 async functions. Prior to versions 3.1.2 and 2.16.4, Koa's ctx.hostname API performs naive parsing of the HTTP Host header, extracting everything before the first colon without validating the input conforms to RFC 3986 hostname syntax. When a malformed Host header containing a @ symbol is received, ctx.hostname returns evil[.]com - an attacker-controlled value. Applications using ctx.hostname for URL generation, password reset links, email verification URLs, or routing decisions are vulnerable to Host header injection attacks. Versions 3.1.2 and 2.16.4 fix the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
koanpm | >= 3.0.0, < 3.1.2 | 3.1.2 |
koanpm | < 2.16.4 | 2.16.4 |
Affected products
1Patches
23 files changed · +83 −1
lib/request.js+11 −1 modified@@ -257,7 +257,17 @@ module.exports = { if (!host) host = this.get('Host'); } if (!host) return ''; - return splitCommaSeparatedValues(host, 1)[0]; + host = splitCommaSeparatedValues(host, 1)[0]; + // Host header may contain userinfo (e.g., "user@host") which is invalid per RFC 7230. + // Use URL parser to correctly extract the host portion. + if (host.includes('@')) { + try { + host = new URL(`http://${host}`).host; + } catch (e) { + return ''; + } + } + return host; }, /**
__tests__/request/host.js+44 −0 modified@@ -94,4 +94,48 @@ describe('req.host', () => { }); }); }); + + describe('with Host header containing @', () => { + it('should correctly parse host from userinfo@host format', () => { + const req = request(); + req.header.host = 'evil.com:fake@legitimate.com'; + assert.strictEqual(req.host, 'legitimate.com'); + }); + + it('should correctly parse host from user@host format', () => { + const req = request(); + req.header.host = 'user@example.com'; + assert.strictEqual(req.host, 'example.com'); + }); + + it('should correctly parse host with port from userinfo@host:port format', () => { + const req = request(); + req.header.host = 'user:pass@example.com:8080'; + assert.strictEqual(req.host, 'example.com:8080'); + }); + + it('should correctly parse @ in X-Forwarded-Host when proxy is trusted', () => { + const req = request(); + req.app.proxy = true; + req.header['x-forwarded-host'] = 'evil.com:fake@legitimate.com'; + req.header.host = 'foo.com'; + assert.strictEqual(req.host, 'legitimate.com'); + }); + + it('should correctly parse @ in :authority on HTTP/2', () => { + const req = request({ + httpVersionMajor: 2, + httpVersion: '2.0' + }); + req.header[':authority'] = 'evil.com:fake@legitimate.com'; + req.header.host = 'foo.com'; + assert.strictEqual(req.host, 'legitimate.com'); + }); + + it('should return empty string for invalid host with @', () => { + const req = request(); + req.header.host = 'user@'; + assert.strictEqual(req.host, ''); + }); + }); });
__tests__/request/hostname.js+28 −0 modified@@ -70,4 +70,32 @@ describe('req.hostname', () => { }); }); }); + + describe('with Host header containing @', () => { + it('should correctly parse hostname from userinfo@host format', () => { + const req = request(); + req.header.host = 'evil.com:fake@legitimate.com'; + assert.strictEqual(req.hostname, 'legitimate.com'); + }); + + it('should correctly parse hostname from user@host format', () => { + const req = request(); + req.header.host = 'user@example.com'; + assert.strictEqual(req.hostname, 'example.com'); + }); + + it('should correctly parse hostname with port from userinfo@host:port format', () => { + const req = request(); + req.header.host = 'user:pass@example.com:8080'; + assert.strictEqual(req.hostname, 'example.com'); + }); + + it('should correctly parse @ in X-Forwarded-Host when proxy is trusted', () => { + const req = request(); + req.app.proxy = true; + req.header['x-forwarded-host'] = 'evil.com:fake@legitimate.com'; + req.header.host = 'foo.com'; + assert.strictEqual(req.hostname, 'legitimate.com'); + }); + }); });
3 files changed · +83 −1
lib/request.js+11 −1 modified@@ -256,7 +256,17 @@ module.exports = { if (!host) host = this.get('Host') } if (!host) return '' - return splitCommaSeparatedValues(host, 1)[0] + host = splitCommaSeparatedValues(host, 1)[0] + // Host header may contain userinfo (e.g., "user@host") which is invalid per RFC 7230. + // Use URL parser to correctly extract the host portion. + if (host.includes('@')) { + try { + host = new URL(`http://${host}`).host + } catch (e) { + return '' + } + } + return host }, /**
__tests__/request/hostname.test.js+28 −0 modified@@ -70,4 +70,32 @@ describe('req.hostname', () => { }) }) }) + + describe('with Host header containing @', () => { + it('should correctly parse hostname from userinfo@host format', () => { + const req = request() + req.header.host = 'evil.com:fake@legitimate.com' + assert.strictEqual(req.hostname, 'legitimate.com') + }) + + it('should correctly parse hostname from user@host format', () => { + const req = request() + req.header.host = 'user@example.com' + assert.strictEqual(req.hostname, 'example.com') + }) + + it('should correctly parse hostname with port from userinfo@host:port format', () => { + const req = request() + req.header.host = 'user:pass@example.com:8080' + assert.strictEqual(req.hostname, 'example.com') + }) + + it('should correctly parse @ in X-Forwarded-Host when proxy is trusted', () => { + const req = request() + req.app.proxy = true + req.header['x-forwarded-host'] = 'evil.com:fake@legitimate.com' + req.header.host = 'foo.com' + assert.strictEqual(req.hostname, 'legitimate.com') + }) + }) })
__tests__/request/host.test.js+44 −0 modified@@ -94,4 +94,48 @@ describe('req.host', () => { }) }) }) + + describe('with Host header containing @', () => { + it('should correctly parse host from userinfo@host format', () => { + const req = request() + req.header.host = 'evil.com:fake@legitimate.com' + assert.strictEqual(req.host, 'legitimate.com') + }) + + it('should correctly parse host from user@host format', () => { + const req = request() + req.header.host = 'user@example.com' + assert.strictEqual(req.host, 'example.com') + }) + + it('should correctly parse host with port from userinfo@host:port format', () => { + const req = request() + req.header.host = 'user:pass@example.com:8080' + assert.strictEqual(req.host, 'example.com:8080') + }) + + it('should correctly parse @ in X-Forwarded-Host when proxy is trusted', () => { + const req = request() + req.app.proxy = true + req.header['x-forwarded-host'] = 'evil.com:fake@legitimate.com' + req.header.host = 'foo.com' + assert.strictEqual(req.host, 'legitimate.com') + }) + + it('should correctly parse @ in :authority on HTTP/2', () => { + const req = request({ + httpVersionMajor: 2, + httpVersion: '2.0' + }) + req.header[':authority'] = 'evil.com:fake@legitimate.com' + req.header.host = 'foo.com' + assert.strictEqual(req.host, 'legitimate.com') + }) + + it('should return empty string for invalid host with @', () => { + const req = request() + req.header.host = 'user@' + assert.strictEqual(req.host, '') + }) + }) })
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
5- github.com/advisories/GHSA-7gcc-r8m5-44qmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27959ghsaADVISORY
- github.com/koajs/koa/commit/55ab9bab044ead4e82c70a30a4f9dc0fc9c1b6dfghsax_refsource_MISCWEB
- github.com/koajs/koa/commit/b76ddc01fdb703e51652b0fd131d16394cadcfebghsax_refsource_MISCWEB
- github.com/koajs/koa/security/advisories/GHSA-7gcc-r8m5-44qmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.