Skip to content

Commit d8c4918

Browse files
committed
feat(vhost): match req.hostname when present for reverse proxy support
Route by the framework-resolved req.hostname (Express 5, respects trust proxy / X-Forwarded-Host) when present, falling back to the raw Host header for plain Node.js / connect servers. req.vhost.host is populated from the same resolved value, so it reflects the host that routing actually used. Fixes #20, supersedes #21
1 parent a4b480a commit d8c4918

4 files changed

Lines changed: 161 additions & 7 deletions

File tree

HISTORY.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
4.0.0 / 2026-06-10
22
==================
33

4+
* Match against `req.hostname` when present (Express 5), falling back to the
5+
`Host` header, so routing works behind reverse proxies with `trust proxy`
6+
(#20); `req.vhost.host` reflects the value routing actually used
47
* Rewrite in TypeScript; ship ESM only with bundled type declarations
58
* Drop support for Node.js below 24; require Node.js 24 or newer
69
* **Breaking:** package is now ESM (`import vhost from 'vhost'`); `require()` is

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ contain `*` to match 1 or more characters in that section of the hostname. When
3131
`hostname` is a RegExp, it will be forced to case-insensitive (since hostnames are)
3232
and will be forced to match based on the start and end of the hostname.
3333

34+
The host used for matching is `req.hostname` when the framework provides one
35+
(Express 5 populates it, respecting `trust proxy` so hosts forwarded through a
36+
reverse proxy via `X-Forwarded-Host` are honored), otherwise the raw
37+
[`Host` header](https://nodejs.org/dist/latest/docs/api/http.html#messageheaders)
38+
(plain Node.js / connect servers).
39+
3440
When host is matched and the request is sent down to a vhost handler, the `req.vhost`
3541
property will be populated with an object. This object will have numeric properties
3642
corresponding to each wildcard (or capture group if RegExp object provided) and the

src/index.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
* Numeric keys `0..length-1` hold the captured wildcard / RegExp group values.
1313
*/
1414
export interface VHost {
15-
/** The raw `Host` header, including the port if one was present. */
15+
/**
16+
* The host value the request was routed by: `req.hostname` when the
17+
* framework provided one, otherwise the raw `Host` header (port included).
18+
*/
1619
host: string
17-
/** The `Host` header with any port stripped. */
20+
/** The routed host with any port stripped. */
1821
hostname: string
1922
/** The number of captured wildcards / RegExp groups. */
2023
length: number
@@ -29,6 +32,12 @@ export interface VHost {
2932
*/
3033
export interface VHostRequest {
3134
headers: { host?: string | undefined }
35+
/**
36+
* Framework-resolved hostname, e.g. Express 5's `req.hostname`. When present
37+
* it takes precedence over the `Host` header, so the middleware works behind
38+
* reverse proxies when `trust proxy` resolves `X-Forwarded-Host`.
39+
*/
40+
hostname?: string | undefined
3241
vhost?: VHost
3342
}
3443

@@ -103,7 +112,7 @@ export default function vhost<Req extends VHostRequest = VHostRequest, Res = unk
103112
: (name: string): boolean => regexp.test(name)
104113

105114
return function vhost (req, res, next) {
106-
const host = req.headers.host
115+
const host = hostof(req)
107116

108117
if (!host) {
109118
return next()
@@ -142,7 +151,7 @@ export default function vhost<Req extends VHostRequest = VHostRequest, Res = unk
142151
const minLen = hostname.length
143152

144153
return function vhost (req, res, next) {
145-
const host = req.headers.host
154+
const host = hostof(req)
146155

147156
if (!host) {
148157
return next()
@@ -167,7 +176,7 @@ export default function vhost<Req extends VHostRequest = VHostRequest, Res = unk
167176

168177
// RegExp hostname: unchanged regex path.
169178
return function vhost (req, res, next) {
170-
const host = req.headers.host
179+
const host = hostof(req)
171180

172181
if (!host) {
173182
return next()
@@ -190,9 +199,20 @@ export default function vhost<Req extends VHostRequest = VHostRequest, Res = unk
190199
}
191200
}
192201

202+
/**
203+
* Get the host value to route by: the framework-resolved `req.hostname`
204+
* (Express 5, respects `trust proxy` / `X-Forwarded-Host`) when present,
205+
* otherwise the raw `Host` header.
206+
*/
207+
function hostof (req: VHostRequest): string | undefined {
208+
return req.hostname || req.headers.host
209+
}
210+
193211
/**
194212
* Get the hostname (port stripped) from a raw `Host` header value, handling
195-
* IPv6 literals such as `[::1]:8080`.
213+
* IPv6 literals such as `[::1]:8080`. Values from `req.hostname` are already
214+
* port-free (and IPv6 literals stay bracketed), so re-parsing them here is a
215+
* no-op.
196216
*/
197217
function hostnameof (host: string): string | undefined {
198218
if (!host) {

test/test.mjs

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,127 @@ describe('vhost(hostname, server)', function () {
160160
.expect(200, 'undefined', done)
161161
})
162162

163+
describe('with req.hostname (express 5 / reverse proxy)', function () {
164+
it('should route by req.hostname over the Host header', function (_, done) {
165+
var vhosts = []
166+
167+
vhosts.push(vhost('proxied.com', proxied))
168+
vhosts.push(vhost('direct.com', direct))
169+
170+
var app = createServer(vhosts, null, function (req) {
171+
// what express 5 provides with `trust proxy` from X-Forwarded-Host
172+
req.hostname = 'proxied.com'
173+
})
174+
175+
function proxied (req, res) { res.end('proxied') }
176+
function direct (req, res) { res.end('direct') }
177+
178+
request(app)
179+
.get('/')
180+
.set('Host', 'direct.com')
181+
.expect(200, 'proxied', done)
182+
})
183+
184+
it('should reflect the routed value on req.vhost.host and hostname', function (_, done) {
185+
var app = createServer('proxied.com', function (req, res) {
186+
res.end(JSON.stringify({ host: req.vhost.host, hostname: req.vhost.hostname }))
187+
}, function (req) {
188+
req.hostname = 'proxied.com'
189+
})
190+
191+
request(app)
192+
.get('/')
193+
.set('Host', 'direct.com:8080')
194+
.expect(200, '{"host":"proxied.com","hostname":"proxied.com"}', done)
195+
})
196+
197+
it('should match a wildcard against req.hostname and capture from it', function (_, done) {
198+
var app = createServer('*.proxied.com', function (req, res) {
199+
res.end(JSON.stringify([req.vhost.length, req.vhost[0], req.vhost.hostname]))
200+
}, function (req) {
201+
req.hostname = 'foo.proxied.com'
202+
})
203+
204+
request(app)
205+
.get('/')
206+
.set('Host', 'direct.com')
207+
.expect(200, '[1,"foo","foo.proxied.com"]', done)
208+
})
209+
210+
it('should match a RegExp against req.hostname', function (_, done) {
211+
var app = createServer(/user-(bob|joe)\.proxied\.com/, function (req, res) {
212+
res.end(JSON.stringify([req.vhost.length, req.vhost[0]]))
213+
}, function (req) {
214+
req.hostname = 'user-bob.proxied.com'
215+
})
216+
217+
request(app)
218+
.get('/')
219+
.set('Host', 'direct.com')
220+
.expect(200, '[1,"bob"]', done)
221+
})
222+
223+
it('should 404 when req.hostname does not match', function (_, done) {
224+
var app = createServer('proxied.com', function (req, res) {
225+
res.end('proxied')
226+
}, function (req) {
227+
// Host header would match, but the resolved hostname wins
228+
req.hostname = 'other.com'
229+
})
230+
231+
request(app)
232+
.get('/')
233+
.set('Host', 'proxied.com')
234+
.expect(404, done)
235+
})
236+
237+
it('should fall back to the Host header when req.hostname is empty', function (_, done) {
238+
var app = createServer('direct.com', function (req, res) {
239+
res.end('direct')
240+
}, function (req) {
241+
req.hostname = ''
242+
})
243+
244+
request(app)
245+
.get('/')
246+
.set('Host', 'direct.com')
247+
.expect(200, 'direct', done)
248+
})
249+
250+
it('should not re-strip a portless IPv6 req.hostname', function (_, done) {
251+
// req.hostname is already port-free; re-parsing the bracketed literal
252+
// must be a no-op, not mangle it to an empty string
253+
var app = createServer('[::1]', function (req, res) {
254+
res.end(JSON.stringify({ host: req.vhost.host, hostname: req.vhost.hostname }))
255+
}, function (req) {
256+
req.hostname = '[::1]'
257+
})
258+
259+
request(app)
260+
.get('/')
261+
.set('Host', 'direct.com:8080')
262+
.expect(200, '{"host":"[::1]","hostname":"[::1]"}', done)
263+
})
264+
265+
it('should call next() when neither req.hostname nor Host is present', function (_, done) {
266+
var app = http.createServer(function onRequest (req, res) {
267+
req.headers.host = undefined
268+
269+
var mw = vhost('proxied.com', function (req, res) {
270+
res.end('handled')
271+
})
272+
273+
mw(req, res, function () {
274+
res.end('next:' + String(req.vhost))
275+
})
276+
})
277+
278+
request(app)
279+
.get('/')
280+
.expect(200, 'next:undefined', done)
281+
})
282+
})
283+
163284
describe('arguments', function () {
164285
describe('hostname', function () {
165286
it('should be required', function () {
@@ -699,12 +820,16 @@ describe('vhost(hostname, server)', function () {
699820
})
700821
})
701822

702-
function createServer (hostname, server) {
823+
function createServer (hostname, server, pretest) {
703824
var vhosts = !Array.isArray(hostname)
704825
? [vhost(hostname, server)]
705826
: hostname
706827

707828
return http.createServer(function onRequest (req, res) {
829+
// allows changes to the request/response objects before the middleware,
830+
// e.g. simulating the `req.hostname` an Express 5 app would provide
831+
if (pretest) pretest(req, res)
832+
708833
var index = 0
709834

710835
function next (err) {

0 commit comments

Comments
 (0)