Skip to content

Commit 83bf90e

Browse files
authored
refactor: use hostValidationMiddleware (#20019)
1 parent a33d0c7 commit 83bf90e

File tree

9 files changed

+72
-227
lines changed

9 files changed

+72
-227
lines changed

packages/vite/LICENSE.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,35 @@ Repository: gulpjs/glob-parent
11641164
11651165
---------------------------------------
11661166

1167+
## host-validation-middleware
1168+
License: MIT
1169+
By: sapphi-red
1170+
Repository: git+https://github.com/sapphi-red/host-validation-middleware.git
1171+
1172+
> MIT License
1173+
>
1174+
> Copyright (c) 2025 sapphi-red
1175+
>
1176+
> Permission is hereby granted, free of charge, to any person obtaining a copy
1177+
> of this software and associated documentation files (the "Software"), to deal
1178+
> in the Software without restriction, including without limitation the rights
1179+
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1180+
> copies of the Software, and to permit persons to whom the Software is
1181+
> furnished to do so, subject to the following conditions:
1182+
>
1183+
> The above copyright notice and this permission notice shall be included in all
1184+
> copies or substantial portions of the Software.
1185+
>
1186+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1187+
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1188+
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1189+
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1190+
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1191+
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1192+
> SOFTWARE.
1193+
1194+
---------------------------------------
1195+
11671196
## http-proxy
11681197
License: MIT
11691198
By: Charlie Robbins, jcrugzz <jcrugzz@gmail.com>

packages/vite/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
"escape-html": "^1.0.3",
121121
"estree-walker": "^3.0.3",
122122
"etag": "^1.8.1",
123+
"host-validation-middleware": "^0.1.1",
123124
"http-proxy": "^1.18.1",
124125
"launch-editor-middleware": "^2.10.0",
125126
"lightningcss": "^1.30.0",

packages/vite/src/node/config.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,6 @@ export interface ResolvedConfig
630630
/** @internal */
631631
safeModulePaths: Set<string>
632632
/** @internal */
633-
additionalAllowedHosts: string[]
634-
/** @internal */
635633
[SYMBOL_RESOLVED_CONFIG]: true
636634
} & PluginHookUtils
637635
> {}
@@ -1452,6 +1450,14 @@ export async function resolveConfig(
14521450

14531451
const preview = resolvePreviewOptions(config.preview, server)
14541452

1453+
const additionalAllowedHosts = getAdditionalAllowedHosts(server, preview)
1454+
if (Array.isArray(server.allowedHosts)) {
1455+
server.allowedHosts.push(...additionalAllowedHosts)
1456+
}
1457+
if (Array.isArray(preview.allowedHosts)) {
1458+
preview.allowedHosts.push(...additionalAllowedHosts)
1459+
}
1460+
14551461
resolved = {
14561462
configFile: configFile ? normalizePath(configFile) : undefined,
14571463
configFileDependencies: configFileDependencies.map((name) =>
@@ -1554,7 +1560,6 @@ export async function resolveConfig(
15541560
},
15551561
),
15561562
safeModulePaths: new Set<string>(),
1557-
additionalAllowedHosts: getAdditionalAllowedHosts(server, preview),
15581563
[SYMBOL_RESOLVED_CONFIG]: true,
15591564
}
15601565
resolved = {

packages/vite/src/node/preview.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { resolveConfig } from './config'
3939
import type { InlineConfig, ResolvedConfig } from './config'
4040
import { DEFAULT_PREVIEW_PORT } from './constants'
4141
import type { RequiredExceptFor } from './typeUtils'
42-
import { hostCheckMiddleware } from './server/middlewares/hostCheck'
42+
import { hostValidationMiddleware } from './server/middlewares/hostCheck'
4343

4444
export interface PreviewOptions extends CommonServerOptions {}
4545

@@ -206,7 +206,7 @@ export async function preview(
206206
const { allowedHosts } = config.preview
207207
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
208208
if (allowedHosts !== true && !config.preview.https) {
209-
app.use(hostCheckMiddleware(config, true))
209+
app.use(hostValidationMiddleware(allowedHosts, true))
210210
}
211211

212212
// proxy

packages/vite/src/node/server/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ import type { TransformOptions, TransformResult } from './transformRequest'
9898
import { transformRequest } from './transformRequest'
9999
import { searchForPackageRoot, searchForWorkspaceRoot } from './searchRoot'
100100
import type { DevEnvironment } from './environment'
101-
import { hostCheckMiddleware } from './middlewares/hostCheck'
101+
import { hostValidationMiddleware } from './middlewares/hostCheck'
102102
import { rejectInvalidRequestMiddleware } from './middlewares/rejectInvalidRequest'
103103

104104
const usedConfigs = new WeakSet<ResolvedConfig>()
@@ -879,7 +879,7 @@ export async function _createServer(
879879
const { allowedHosts } = serverConfig
880880
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
881881
if (allowedHosts !== true && !serverConfig.https) {
882-
middlewares.use(hostCheckMiddleware(config, false))
882+
middlewares.use(hostValidationMiddleware(allowedHosts, false))
883883
}
884884

885885
middlewares.use(cachedTransformMiddleware(server))
Lines changed: 2 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import { describe, expect, test } from 'vitest'
2-
import {
3-
getAdditionalAllowedHosts,
4-
isHostAllowedWithoutCache,
5-
} from '../hostCheck'
1+
import { expect, test } from 'vitest'
2+
import { getAdditionalAllowedHosts } from '../hostCheck'
63

74
test('getAdditionalAllowedHosts', async () => {
85
const actual = getAdditionalAllowedHosts(
@@ -26,87 +23,3 @@ test('getAdditionalAllowedHosts', async () => {
2623
].sort(),
2724
)
2825
})
29-
30-
describe('isHostAllowedWithoutCache', () => {
31-
const allowCases = {
32-
'IP address': [
33-
'192.168.0.0',
34-
'[::1]',
35-
'127.0.0.1:5173',
36-
'[2001:db8:0:0:1:0:0:1]:5173',
37-
],
38-
localhost: [
39-
'localhost',
40-
'localhost:5173',
41-
'foo.localhost',
42-
'foo.bar.localhost',
43-
],
44-
specialProtocols: [
45-
// for electron browser window (https://github.com/webpack/webpack-dev-server/issues/3821)
46-
'file:///path/to/file.html',
47-
// for browser extensions (https://github.com/webpack/webpack-dev-server/issues/3807)
48-
'chrome-extension://foo',
49-
],
50-
}
51-
52-
const disallowCases = {
53-
'IP address': ['255.255.255.256', '[:', '[::z]'],
54-
localhost: ['localhos', 'localhost.foo'],
55-
specialProtocols: ['mailto:foo@bar.com'],
56-
others: [''],
57-
}
58-
59-
for (const [name, inputList] of Object.entries(allowCases)) {
60-
test.each(inputList)(`allows ${name} (%s)`, (input) => {
61-
const actual = isHostAllowedWithoutCache([], [], input)
62-
expect(actual).toBe(true)
63-
})
64-
}
65-
66-
for (const [name, inputList] of Object.entries(disallowCases)) {
67-
test.each(inputList)(`disallows ${name} (%s)`, (input) => {
68-
const actual = isHostAllowedWithoutCache([], [], input)
69-
expect(actual).toBe(false)
70-
})
71-
}
72-
73-
test('allows additionalAlloweHosts option', () => {
74-
const additionalAllowedHosts = ['vite.example.com']
75-
const actual = isHostAllowedWithoutCache(
76-
[],
77-
additionalAllowedHosts,
78-
'vite.example.com',
79-
)
80-
expect(actual).toBe(true)
81-
})
82-
83-
test('allows single allowedHosts', () => {
84-
const cases = {
85-
allowed: ['example.com'],
86-
disallowed: ['vite.dev'],
87-
}
88-
for (const c of cases.allowed) {
89-
const actual = isHostAllowedWithoutCache(['example.com'], [], c)
90-
expect(actual, c).toBe(true)
91-
}
92-
for (const c of cases.disallowed) {
93-
const actual = isHostAllowedWithoutCache(['example.com'], [], c)
94-
expect(actual, c).toBe(false)
95-
}
96-
})
97-
98-
test('allows all subdomain allowedHosts', () => {
99-
const cases = {
100-
allowed: ['example.com', 'foo.example.com', 'foo.bar.example.com'],
101-
disallowed: ['vite.dev'],
102-
}
103-
for (const c of cases.allowed) {
104-
const actual = isHostAllowedWithoutCache(['.example.com'], [], c)
105-
expect(actual, c).toBe(true)
106-
}
107-
for (const c of cases.disallowed) {
108-
const actual = isHostAllowedWithoutCache(['.example.com'], [], c)
109-
expect(actual, c).toBe(false)
110-
}
111-
})
112-
})
Lines changed: 10 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1-
import net from 'node:net'
21
import type { Connect } from 'dep-types/connect'
3-
import type { ResolvedConfig } from '../../config'
2+
import { hostValidationMiddleware as originalHostValidationMiddleware } from 'host-validation-middleware'
43
import type { ResolvedPreviewOptions, ResolvedServerOptions } from '../..'
54

6-
const allowedHostsServerCache = new WeakMap<ResolvedConfig, Set<string>>()
7-
const allowedHostsPreviewCache = new WeakMap<ResolvedConfig, Set<string>>()
8-
9-
const isFileOrExtensionProtocolRE = /^(?:file|.+-extension):/i
10-
115
export function getAdditionalAllowedHosts(
126
resolvedServerOptions: Pick<ResolvedServerOptions, 'host' | 'hmr' | 'origin'>,
137
resolvedPreviewOptions: Pick<ResolvedPreviewOptions, 'host'>,
@@ -49,132 +43,20 @@ export function getAdditionalAllowedHosts(
4943
return list
5044
}
5145

52-
// Based on webpack-dev-server's `checkHeader` function: https://github.com/webpack/webpack-dev-server/blob/v5.2.0/lib/Server.js#L3086
53-
// https://github.com/webpack/webpack-dev-server/blob/v5.2.0/LICENSE
54-
export function isHostAllowedWithoutCache(
46+
export function hostValidationMiddleware(
5547
allowedHosts: string[],
56-
additionalAllowedHosts: string[],
57-
host: string,
58-
): boolean {
59-
if (isFileOrExtensionProtocolRE.test(host)) {
60-
return true
61-
}
62-
63-
// We don't care about malformed Host headers,
64-
// because we only need to consider browser requests.
65-
// Non-browser clients can send any value they want anyway.
66-
//
67-
// `Host = uri-host [ ":" port ]`
68-
const trimmedHost = host.trim()
69-
70-
// IPv6
71-
if (trimmedHost[0] === '[') {
72-
const endIpv6 = trimmedHost.indexOf(']')
73-
if (endIpv6 < 0) {
74-
return false
75-
}
76-
// DNS rebinding attacks does not happen with IP addresses
77-
return net.isIP(trimmedHost.slice(1, endIpv6)) === 6
78-
}
79-
80-
// uri-host does not include ":" unless IPv6 address
81-
const colonPos = trimmedHost.indexOf(':')
82-
const hostname =
83-
colonPos === -1 ? trimmedHost : trimmedHost.slice(0, colonPos)
84-
85-
// DNS rebinding attacks does not happen with IP addresses
86-
if (net.isIP(hostname) === 4) {
87-
return true
88-
}
89-
90-
// allow localhost and .localhost by default as they always resolve to the loopback address
91-
// https://datatracker.ietf.org/doc/html/rfc6761#section-6.3
92-
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
93-
return true
94-
}
95-
96-
for (const additionalAllowedHost of additionalAllowedHosts) {
97-
if (additionalAllowedHost === hostname) {
98-
return true
99-
}
100-
}
101-
102-
for (const allowedHost of allowedHosts) {
103-
if (allowedHost === hostname) {
104-
return true
105-
}
106-
107-
// allow all subdomains of it
108-
// e.g. `.foo.example` will allow `foo.example`, `*.foo.example`, `*.*.foo.example`, etc
109-
if (
110-
allowedHost[0] === '.' &&
111-
(allowedHost.slice(1) === hostname || hostname.endsWith(allowedHost))
112-
) {
113-
return true
114-
}
115-
}
116-
117-
return false
118-
}
119-
120-
/**
121-
* @param config resolved config
122-
* @param isPreview whether it's for the preview server or not
123-
* @param host the value of host header. See [RFC 9110 7.2](https://datatracker.ietf.org/doc/html/rfc9110#name-host-and-authority).
124-
*/
125-
export function isHostAllowed(
126-
config: ResolvedConfig,
127-
isPreview: boolean,
128-
host: string,
129-
): boolean {
130-
const allowedHosts = isPreview
131-
? config.preview.allowedHosts
132-
: config.server.allowedHosts
133-
if (allowedHosts === true) {
134-
return true
135-
}
136-
137-
const cache = isPreview ? allowedHostsPreviewCache : allowedHostsServerCache
138-
if (!cache.has(config)) {
139-
cache.set(config, new Set())
140-
}
141-
142-
const cachedAllowedHosts = cache.get(config)!
143-
if (cachedAllowedHosts.has(host)) {
144-
return true
145-
}
146-
147-
const result = isHostAllowedWithoutCache(
148-
allowedHosts,
149-
config.additionalAllowedHosts,
150-
host,
151-
)
152-
if (result) {
153-
cachedAllowedHosts.add(host)
154-
}
155-
return result
156-
}
157-
158-
export function hostCheckMiddleware(
159-
config: ResolvedConfig,
16048
isPreview: boolean,
16149
): Connect.NextHandleFunction {
162-
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
163-
return function viteHostCheckMiddleware(req, res, next) {
164-
const hostHeader = req.headers.host
165-
if (!hostHeader || !isHostAllowed(config, isPreview, hostHeader)) {
166-
const hostname = hostHeader?.replace(/:\d+$/, '')
50+
return originalHostValidationMiddleware({
51+
// Freeze the array to allow caching
52+
allowedHosts: Object.freeze([...allowedHosts]),
53+
generateErrorMessage(hostname) {
16754
const hostnameWithQuotes = JSON.stringify(hostname)
16855
const optionName = `${isPreview ? 'preview' : 'server'}.allowedHosts`
169-
res.writeHead(403, {
170-
'Content-Type': 'text/plain',
171-
})
172-
res.end(
56+
return (
17357
`Blocked request. This host (${hostnameWithQuotes}) is not allowed.\n` +
174-
`To allow this host, add ${hostnameWithQuotes} to \`${optionName}\` in vite.config.js.`,
58+
`To allow this host, add ${hostnameWithQuotes} to \`${optionName}\` in vite.config.js.`
17559
)
176-
return
177-
}
178-
return next()
179-
}
60+
},
61+
})
18062
}

0 commit comments

Comments
 (0)