Skip to content

fix: prevent host header injection by sanitizing userinfo in Host header - #7317

Open
bjohansebas wants to merge 1 commit into
masterfrom
fix/hostname-host-header-injection
Open

fix: prevent host header injection by sanitizing userinfo in Host header#7317
bjohansebas wants to merge 1 commit into
masterfrom
fix/hostname-host-header-injection

Conversation

@bjohansebas

Copy link
Copy Markdown
Member

req.hostname / req.host: master (unpatched) vs our version

Host header master req.hostname ours req.hostname master req.host ours req.host
example.com:3000 (control) example.com example.com example.com:3000 example.com:3000
example.com:notaport example.com undefined example.com:notaport undefined
[::1]:notaport [::1] undefined [::1]:notaport undefined
evil.com:fake@legitimate.com:3000 evil.com ⚠️ legitimate.com evil.com:fake@legitimate.com:3000 legitimate.com:3000
user@example.com user@example.com ⚠️ example.com user@example.com example.com
user:pass@example.com:8080 user ⚠️ example.com user:pass@example.com:8080 example.com:8080
user@[::1]:8080 user@[ ⚠️ [::1] user@[::1]:8080 [::1]:8080
a@b@example.com a@b@example.com ⚠️ example.com a@b@example.com example.com
evil.com:x%40legitimate.com evil.com ⚠️ undefined evil.com:x%40legitimate.com undefined
user@ user@ ⚠️ undefined user@ undefined
X-Forwarded-Host: evil.com:fake@legitimate.com (trust proxy) evil.com ⚠️ legitimate.com evil.com:fake@legitimate.com legitimate.com

ref: https://github.com/expressjs/express/security/advisories/GHSA-rvrq-qj4c-w73v (Note that it was closed because it did not meet our criteria for being considered a vulnerability and will instead be treated as a bug)

@bjohansebas
bjohansebas force-pushed the fix/hostname-host-header-injection branch from 20be0d9 to f556b0b Compare June 16, 2026 04:16
@bjohansebas

Copy link
Copy Markdown
Member Author

cc: @expressjs/express-collaborators @expressjs/security-wg

@krzysdz krzysdz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

req.host and req.hostname are documented as strings in code, DefinitelyTyped and on the website. Changing them to string? might break some assumptions in user code. This is wrong, because when the client does not send a Host header, the properties are undefined.

Demo

HTTP/0.9 and HTTP/1.0 don't require (or even support) the Host header, so the demo shows real requests that are accepted by Node.js. HTTP/1.1 without Host is rejected by Node.js.

const { createConnection } = require("node:net")
const express = require("express");

const app = express();

app.use((req, res) => {
    const { host, hostname } = req;
    console.log(host, hostname);
    // => undefined undefined
    res.json({ host, hostname });
    // => {}
});

const IP = "127.0.0.1"
const server = app.listen(0, IP, e => {
    if (e) return;
    const { port } = server.address();
    const socket = createConnection(port, () => {
        socket.once("end", () => server.close());
        socket.on("data", (d) => console.log(d.toString("utf8")));
        socket.end(`GET / HTTP/1.0\r\nUser-Agent: raw bytes\r\n\r\n`);
        // or HTTP/0.9
        // socket.end(`GET / HTTP/0.9\r\n\r\n`);
    });
});
$ node index.js
undefined undefined
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 2
ETag: W/"2-vyGp6PvFo4RvsFtPoIWeCReyIC8"
Date: Mon, 03 Aug 2026 19:39:04 GMT
Connection: close

{}
History

JSDoc for req.host was added together with req.host in 2b90cd7 in 2012 and released in Express 3.0.0beta2. Since at least 2013 and Express 3.2.4 it has been known that req.host may return undefined (06ead58).

Website did not have information about types until expressjs/expressjs.com#2389.

DefinitelyTyped has used string as the type of req.host since introducing type definitions for Express 3.0 in 2012 (DefinitelyTyped/DefinitelyTyped@c02fecf#diff-b40c3775054ff9d4caf7eeecfeb581188302f77086dd8e3dcd748e801229ccc7R130).

@jonchurch

jonchurch commented Aug 4, 2026

Copy link
Copy Markdown
Member

Context for reviewers: the problem this PR is meant to solve is that our req.hostname getter will return whatever precedes the first : (after skipping any IPv6 literal) when attempting to extract the hostname from req.host (which is the Host header value, or X-Forwarded-Host under trust proxy). Given the same input, no conformant parser would emit the hostname we do here. I assume that's because the getter was never meant to be a conformant URI parser.

E.g. Host: evil.com:fake@legitimate.com will return evil.com from the hostname getter.

A few things to note about master:

  1. The getter attempts to strip port information from req.host. It splits on the first : it encounters (ignoring IPV6 literal) and returns the first segment.
  2. We dont validate the Host header anywhere
  3. A valid Host header value per rfc9110 does not allow for user info (9110 imports the its uri-host def from host defined in rfc3986)

I think there are two questions here

What should we do with malformed input when resolving hostname?

A few options jump out at me:

  1. Change the getter so it correctly parse a hostname from a generic URI (recover)
  2. Validate the Host header input to the getter, set req.hostname to undefined if it is invalid per RFC 9110 (reject)
  3. Do nothing, not our responsibility. This one is the whole "bug", we emit a hostname that no other parser would likely emit given the same input

Which getter gets a change? host or hostname?

This PR takes the approach of doing sanitization at the req.host layer, which is one layer above how I was thinking through the problem. The PR performs validation on the Host header, stripping userinfo, and notably also rejecting an invalid port by setting req.host to undefined.

That change means the "split at first colon" hostname getter can't have the same bug it does today.

My thoughts

I need to wrap up here and step away lol, Im now too close to this.

I think that if we have a bug here at all, it's that the req.hostname getter makes an assumption about the structure of its input without asserting anything about that structure, which then leaves the behavior undefined when it encounters something weird. The getter is very very simple, so "undefined behavior" is a pretty narrow scope, narrow enough that we could maybe close it out by even just splitting on the last colon we see not the first. (sigh, URI parsing is never simple, that wont work at all)

Ultimately I don't think there's a need for that particular getter to morph into a full URL parser, there be dragons yonder way.

But I will say it is really surprising the way that it behaves today.

I'm less inclined to ship a change which affects req.host itself than I am a change which makes foo.com:bar@example.com not set req.hostname = 'foo.com'

Host headers are always user controlled, but also have a well defined "valid" grammar, which gives us space to make an assertion about deriving a value from a Host header. What should the hostname of an invalid Host header be? How can you reliably compute something from a bad input?

req.host being just whatever was sent on the wire makes more sense to me, invalid or not, than trying to derive a hostname from garbage.

Comment thread lib/request.js
Comment on lines +449 to +463
// A Host header must not contain userinfo (RFC 9110 section 7.2). Drop any
// "user@" / "user:pass@" prefix so a crafted value like
// "evil.com:x@good.com" cannot inject an attacker-controlled host via
// req.hostname; the real host is the part after the last "@".
var atIndex = val.lastIndexOf('@');
if (atIndex !== -1) val = val.slice(atIndex + 1);

if (!val) return;

// The optional port must be numeric (RFC 3986 section 3.2.3). Reject
// malformed authorities, e.g. an encoded "@" smuggled in as the port
// ("evil.com:x%40good.com"), which would otherwise leak a fake hostname.
var portOffset = val[0] === '[' ? val.indexOf(']') + 1 : 0;
var portIndex = val.indexOf(':', portOffset);
if (portIndex !== -1 && !isValidPort(val.slice(portIndex + 1))) return;

@jonchurch jonchurch Aug 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These outcomes are inconsistent with each other aren't they?

Or more specifically, is there spec based logic im missing which would lead to sanitizing userinfo, but rejecting invalid port info? Both are technically invalid Host header values right? So why not set both to undefined?

These are questions, not necessarily suggested changes to be clear.

Is it that the non numeric port value is an invalid URI? Whereas a URI with userinfo is just an invalid Host header, not necessarily a bad URI?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

semver-minor This change is a semver minor tc agenda

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants