Skip to content

Commit 9da4abc

Browse files
committedJan 20, 2025
fix!: check host header to prevent DNS rebinding attacks and introduce server.allowedHosts
1 parent b71a5c8 commit 9da4abc

File tree

10 files changed

+400
-2
lines changed

10 files changed

+400
-2
lines changed
 

‎docs/config/preview-options.md

+9
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ See [`server.host`](./server-options#server-host) for more details.
1717

1818
:::
1919

20+
## preview.allowedHosts
21+
22+
- **Type:** `string | true`
23+
- **Default:** [`server.allowedHosts`](./server-options#server-allowedhosts)
24+
25+
The hostnames that Vite is allowed to respond to.
26+
27+
See [`server.allowedHosts`](./server-options#server-allowedhosts) for more details.
28+
2029
## preview.port
2130

2231
- **Type:** `number`

‎docs/config/server-options.md

+14
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ See [the WSL document](https://learn.microsoft.com/en-us/windows/wsl/networking#
4141

4242
:::
4343

44+
## server.allowedHosts
45+
46+
- **Type:** `string[] | true`
47+
- **Default:** `[]`
48+
49+
The hostnames that Vite is allowed to respond to.
50+
`localhost` and domains under `.localhost` and all IP addresses are allowed by default.
51+
When using HTTPS, this check is skipped.
52+
53+
If a string starts with `.`, it will allow that hostname without the `.` and all subdomains under the hostname. For example, `.example.com` will allow `example.com`, `foo.example.com`, and `foo.bar.example.com`.
54+
55+
If set to `true`, the server is allowed to respond to requests for any hosts.
56+
This is not recommended as it will be vulnerable to DNS rebinding attacks.
57+
4458
## server.port
4559

4660
- **Type:** `number`

‎packages/vite/src/node/config.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { findNearestPackageData } from './packages'
7373
import { loadEnv, resolveEnvPrefix } from './env'
7474
import type { ResolvedSSROptions, SSROptions } from './ssr'
7575
import { resolveSSROptions } from './ssr'
76+
import { getAdditionalAllowedHosts } from './server/middlewares/hostCheck'
7677

7778
const debug = createDebugger('vite:config')
7879
const promisifiedRealpath = promisify(fs.realpath)
@@ -424,6 +425,8 @@ export type ResolvedConfig = Readonly<
424425
* @deprecated
425426
*/
426427
webSocketToken: string
428+
/** @internal */
429+
additionalAllowedHosts: string[]
427430
} & PluginHookUtils
428431
>
429432

@@ -791,6 +794,8 @@ export async function resolveConfig(
791794

792795
const base = withTrailingSlash(resolvedBase)
793796

797+
const preview = resolvePreviewOptions(config.preview, server)
798+
794799
resolved = {
795800
configFile: configFile ? normalizePath(configFile) : undefined,
796801
configFileDependencies: configFileDependencies.map((name) =>
@@ -822,7 +827,7 @@ export async function resolveConfig(
822827
},
823828
server,
824829
build: resolvedBuildOptions,
825-
preview: resolvePreviewOptions(config.preview, server),
830+
preview,
826831
envDir,
827832
env: {
828833
...userEnv,
@@ -858,6 +863,7 @@ export async function resolveConfig(
858863
webSocketToken: Buffer.from(
859864
crypto.getRandomValues(new Uint8Array(9)),
860865
).toString('base64url'),
866+
additionalAllowedHosts: getAdditionalAllowedHosts(server, preview),
861867
getSortedPlugins: undefined!,
862868
getSortedPluginHooks: undefined!,
863869
}

‎packages/vite/src/node/http.ts

+12
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export interface CommonServerOptions {
2424
* Set to 0.0.0.0 to listen on all addresses, including LAN and public addresses.
2525
*/
2626
host?: string | boolean
27+
/**
28+
* The hostnames that Vite is allowed to respond to.
29+
* `localhost` and subdomains under `.localhost` and all IP addresses are allowed by default.
30+
* When using HTTPS, this check is skipped.
31+
*
32+
* If a string starts with `.`, it will allow that hostname without the `.` and all subdomains under the hostname.
33+
* For example, `.example.com` will allow `example.com`, `foo.example.com`, and `foo.bar.example.com`.
34+
*
35+
* If set to `true`, the server is allowed to respond to requests for any hosts.
36+
* This is not recommended as it will be vulnerable to DNS rebinding attacks.
37+
*/
38+
allowedHosts?: string[] | true
2739
/**
2840
* Enable TLS + HTTP/2.
2941
* Note: this downgrades to TLS only when the proxy option is also used.

‎packages/vite/src/node/preview.ts

+9
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { bindCLIShortcuts } from './shortcuts'
3737
import type { BindCLIShortcutsOptions } from './shortcuts'
3838
import { resolveConfig } from './config'
3939
import type { InlineConfig, ResolvedConfig } from './config'
40+
import { hostCheckMiddleware } from './server/middlewares/hostCheck'
4041

4142
export interface PreviewOptions extends CommonServerOptions {}
4243

@@ -53,6 +54,7 @@ export function resolvePreviewOptions(
5354
port: preview?.port,
5455
strictPort: preview?.strictPort ?? server.strictPort,
5556
host: preview?.host ?? server.host,
57+
allowedHosts: preview?.allowedHosts ?? server.allowedHosts,
5658
https: preview?.https ?? server.https,
5759
open: preview?.open ?? server.open,
5860
proxy: preview?.proxy ?? server.proxy,
@@ -188,6 +190,13 @@ export async function preview(
188190
app.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
189191
}
190192

193+
// host check (to prevent DNS rebinding attacks)
194+
const { allowedHosts } = config.preview
195+
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
196+
if (allowedHosts !== true && !config.preview.https) {
197+
app.use(hostCheckMiddleware(config))
198+
}
199+
191200
// proxy
192201
const { proxy } = config.preview
193202
if (proxy) {

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

+8
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import type { TransformOptions, TransformResult } from './transformRequest'
9797
import { transformRequest } from './transformRequest'
9898
import { searchForWorkspaceRoot } from './searchRoot'
9999
import { warmupFiles } from './warmup'
100+
import { hostCheckMiddleware } from './middlewares/hostCheck'
100101

101102
export interface ServerOptions extends CommonServerOptions {
102103
/**
@@ -853,6 +854,13 @@ export async function _createServer(
853854
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
854855
}
855856

857+
// host check (to prevent DNS rebinding attacks)
858+
const { allowedHosts } = serverConfig
859+
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
860+
if (allowedHosts !== true && !serverConfig.https) {
861+
middlewares.use(hostCheckMiddleware(config))
862+
}
863+
856864
middlewares.use(cachedTransformMiddleware(server))
857865

858866
// proxy
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, test } from 'vitest'
2+
import {
3+
getAdditionalAllowedHosts,
4+
isHostAllowedWithoutCache,
5+
} from '../hostCheck'
6+
7+
test('getAdditionalAllowedHosts', async () => {
8+
const actual = getAdditionalAllowedHosts(
9+
{
10+
host: 'vite.host.example.com',
11+
hmr: {
12+
host: 'vite.hmr-host.example.com',
13+
},
14+
origin: 'http://vite.origin.example.com:5173',
15+
},
16+
{
17+
host: 'vite.preview-host.example.com',
18+
},
19+
).sort()
20+
expect(actual).toStrictEqual(
21+
[
22+
'vite.host.example.com',
23+
'vite.hmr-host.example.com',
24+
'vite.origin.example.com',
25+
'vite.preview-host.example.com',
26+
].sort(),
27+
)
28+
})
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+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import net from 'node:net'
2+
import type { Connect } from 'dep-types/connect'
3+
import type { ResolvedConfig } from '../../config'
4+
import type { ResolvedPreviewOptions, ResolvedServerOptions } from '../..'
5+
6+
const allowedHostsCache = new WeakMap<ResolvedConfig, Set<string>>()
7+
8+
const isFileOrExtensionProtocolRE = /^(?:file|.+-extension):/i
9+
10+
export function getAdditionalAllowedHosts(
11+
resolvedServerOptions: Pick<ResolvedServerOptions, 'host' | 'hmr' | 'origin'>,
12+
resolvedPreviewOptions: Pick<ResolvedPreviewOptions, 'host'>,
13+
): string[] {
14+
const list = []
15+
16+
// allow host option by default as that indicates that the user is
17+
// expecting Vite to respond on that host
18+
if (
19+
typeof resolvedServerOptions.host === 'string' &&
20+
resolvedServerOptions.host
21+
) {
22+
list.push(resolvedServerOptions.host)
23+
}
24+
if (
25+
typeof resolvedServerOptions.hmr === 'object' &&
26+
resolvedServerOptions.hmr.host
27+
) {
28+
list.push(resolvedServerOptions.hmr.host)
29+
}
30+
if (
31+
typeof resolvedPreviewOptions.host === 'string' &&
32+
resolvedPreviewOptions.host
33+
) {
34+
list.push(resolvedPreviewOptions.host)
35+
}
36+
37+
// allow server origin by default as that indicates that the user is
38+
// expecting Vite to respond on that host
39+
if (resolvedServerOptions.origin) {
40+
const serverOriginUrl = new URL(resolvedServerOptions.origin)
41+
list.push(serverOriginUrl.hostname)
42+
}
43+
44+
return list
45+
}
46+
47+
// Based on webpack-dev-server's `checkHeader` function: https://github.com/webpack/webpack-dev-server/blob/v5.2.0/lib/Server.js#L3086
48+
// https://github.com/webpack/webpack-dev-server/blob/v5.2.0/LICENSE
49+
export function isHostAllowedWithoutCache(
50+
allowedHosts: string[],
51+
additionalAllowedHosts: string[],
52+
host: string,
53+
): boolean {
54+
if (isFileOrExtensionProtocolRE.test(host)) {
55+
return true
56+
}
57+
58+
// We don't care about malformed Host headers,
59+
// because we only need to consider browser requests.
60+
// Non-browser clients can send any value they want anyway.
61+
//
62+
// `Host = uri-host [ ":" port ]`
63+
const trimmedHost = host.trim()
64+
65+
// IPv6
66+
if (trimmedHost[0] === '[') {
67+
const endIpv6 = trimmedHost.indexOf(']')
68+
if (endIpv6 < 0) {
69+
return false
70+
}
71+
// DNS rebinding attacks does not happen with IP addresses
72+
return net.isIP(trimmedHost.slice(1, endIpv6)) === 6
73+
}
74+
75+
// uri-host does not include ":" unless IPv6 address
76+
const colonPos = trimmedHost.indexOf(':')
77+
const hostname =
78+
colonPos === -1 ? trimmedHost : trimmedHost.slice(0, colonPos)
79+
80+
// DNS rebinding attacks does not happen with IP addresses
81+
if (net.isIP(hostname) === 4) {
82+
return true
83+
}
84+
85+
// allow localhost and .localhost by default as they always resolve to the loopback address
86+
// https://datatracker.ietf.org/doc/html/rfc6761#section-6.3
87+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
88+
return true
89+
}
90+
91+
for (const additionalAllowedHost of additionalAllowedHosts) {
92+
if (additionalAllowedHost === hostname) {
93+
return true
94+
}
95+
}
96+
97+
for (const allowedHost of allowedHosts) {
98+
if (allowedHost === hostname) {
99+
return true
100+
}
101+
102+
// allow all subdomains of it
103+
// e.g. `.foo.example` will allow `foo.example`, `*.foo.example`, `*.*.foo.example`, etc
104+
if (
105+
allowedHost[0] === '.' &&
106+
(allowedHost.slice(1) === hostname || hostname.endsWith(allowedHost))
107+
) {
108+
return true
109+
}
110+
}
111+
112+
return false
113+
}
114+
115+
/**
116+
* @param config resolved config
117+
* @param host the value of host header. See [RFC 9110 7.2](https://datatracker.ietf.org/doc/html/rfc9110#name-host-and-authority).
118+
*/
119+
export function isHostAllowed(config: ResolvedConfig, host: string): boolean {
120+
if (config.server.allowedHosts === true) {
121+
return true
122+
}
123+
124+
if (!allowedHostsCache.has(config)) {
125+
allowedHostsCache.set(config, new Set())
126+
}
127+
128+
const allowedHosts = allowedHostsCache.get(config)!
129+
if (allowedHosts.has(host)) {
130+
return true
131+
}
132+
133+
const result = isHostAllowedWithoutCache(
134+
config.server.allowedHosts ?? [],
135+
config.additionalAllowedHosts,
136+
host,
137+
)
138+
if (result) {
139+
allowedHosts.add(host)
140+
}
141+
return result
142+
}
143+
144+
export function hostCheckMiddleware(
145+
config: ResolvedConfig,
146+
): Connect.NextHandleFunction {
147+
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
148+
return function viteHostCheckMiddleware(req, res, next) {
149+
const hostHeader = req.headers.host
150+
if (!hostHeader || !isHostAllowed(config, hostHeader)) {
151+
const hostname = hostHeader?.replace(/:\d+$/, '')
152+
const hostnameWithQuotes = JSON.stringify(hostname)
153+
res.writeHead(403, {
154+
'Content-Type': 'text/plain',
155+
})
156+
res.end(
157+
`Blocked request. This host (${hostnameWithQuotes}) is not allowed.\n` +
158+
`To allow this host, add ${hostnameWithQuotes} to \`server.allowedHosts\` in vite.config.js.`,
159+
)
160+
return
161+
}
162+
return next()
163+
}
164+
}

‎packages/vite/src/node/server/ws.ts

+6
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { InferCustomEventPayload } from 'types/customEvent'
1515
import type { ResolvedConfig } from '..'
1616
import { isObject } from '../utils'
1717
import type { HMRChannel } from './hmr'
18+
import { isHostAllowed } from './middlewares/hostCheck'
1819
import type { HttpServer } from '.'
1920

2021
/* In Bun, the `ws` module is overridden to hook into the native code. Using the bundled `js` version
@@ -153,6 +154,11 @@ export function createWebSocketServer(
153154
const host = (hmr && hmr.host) || undefined
154155

155156
const shouldHandle = (req: IncomingMessage) => {
157+
const hostHeader = req.headers.host
158+
if (!hostHeader || !isHostAllowed(config, hostHeader)) {
159+
return false
160+
}
161+
156162
if (config.legacy?.skipWebSocketTokenCheck) {
157163
return true
158164
}

‎playground/fs-serve/__tests__/fs-serve.spec.ts

+59-1
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,13 @@ describe('cross origin', () => {
162162

163163
const connectWebSocketFromServer = async (
164164
url: string,
165+
host: string,
165166
origin: string | undefined,
166167
) => {
167168
try {
168169
const ws = new WebSocket(url, ['vite-hmr'], {
169170
headers: {
171+
Host: host,
170172
...(origin ? { Origin: origin } : undefined),
171173
},
172174
})
@@ -212,10 +214,37 @@ describe('cross origin', () => {
212214
expect(result).toBe(true)
213215
})
214216

217+
test('fetch with allowed hosts', async () => {
218+
const viteTestUrlUrl = new URL(viteTestUrl)
219+
const res = await fetch(viteTestUrl + '/src/index.html', {
220+
headers: { Host: viteTestUrlUrl.host },
221+
})
222+
expect(res.status).toBe(200)
223+
})
224+
225+
test.runIf(isServe)(
226+
'connect WebSocket with valid token with allowed hosts',
227+
async () => {
228+
const viteTestUrlUrl = new URL(viteTestUrl)
229+
const token = viteServer.config.webSocketToken
230+
const result = await connectWebSocketFromServer(
231+
`${viteTestUrl}?token=${token}`,
232+
viteTestUrlUrl.host,
233+
viteTestUrlUrl.origin,
234+
)
235+
expect(result).toBe(true)
236+
},
237+
)
238+
215239
test.runIf(isServe)(
216240
'connect WebSocket without a token without the origin header',
217241
async () => {
218-
const result = await connectWebSocketFromServer(viteTestUrl, undefined)
242+
const viteTestUrlUrl = new URL(viteTestUrl)
243+
const result = await connectWebSocketFromServer(
244+
viteTestUrl,
245+
viteTestUrlUrl.host,
246+
undefined,
247+
)
219248
expect(result).toBe(true)
220249
},
221250
)
@@ -269,5 +298,34 @@ describe('cross origin', () => {
269298
)
270299
expect(result2).toBe(false)
271300
})
301+
302+
test('fetch with non-allowed hosts', async () => {
303+
const res = await fetch(viteTestUrl + '/src/index.html', {
304+
headers: {
305+
Host: 'vite.dev',
306+
},
307+
})
308+
expect(res.status).toBe(403)
309+
})
310+
311+
test.runIf(isServe)(
312+
'connect WebSocket with valid token with non-allowed hosts',
313+
async () => {
314+
const token = viteServer.config.webSocketToken
315+
const result = await connectWebSocketFromServer(
316+
`${viteTestUrl}?token=${token}`,
317+
'vite.dev',
318+
'http://vite.dev',
319+
)
320+
expect(result).toBe(false)
321+
322+
const result2 = await connectWebSocketFromServer(
323+
`${viteTestUrl}?token=${token}`,
324+
'vite.dev',
325+
undefined,
326+
)
327+
expect(result2).toBe(false)
328+
},
329+
)
272330
})
273331
})

0 commit comments

Comments
 (0)
Please sign in to comment.