Skip to content

Commit bd896fb

Browse files
committedJan 20, 2025·
fix!: check host header to prevent DNS rebinding attacks and introduce server.allowedHosts
1 parent 029dcd6 commit bd896fb

File tree

10 files changed

+401
-2
lines changed

10 files changed

+401
-2
lines changed
 

‎docs/config/preview-options.md

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

2020
:::
2121

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

2433
- **Type:** `number`

‎docs/config/server-options.md

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

4343
:::
4444

45+
## server.allowedHosts
46+
47+
- **Type:** `string[] | true`
Has conversations. Original line has conversations.
48+
- **Default:** `[]`
49+
50+
The hostnames that Vite is allowed to respond to.
51+
`localhost` and domains under `.localhost` and all IP addresses are allowed by default.
52+
When using HTTPS, this check is skipped.
53+
54+
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`.
55+
56+
If set to `true`, the server is allowed to respond to requests for any hosts.
57+
This is not recommended as it will be vulnerable to DNS rebinding attacks.
58+
4559
## server.port
4660

4761
- **Type:** `number`

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

+7-1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import type { ResolvedSSROptions, SSROptions } from './ssr'
100100
import { resolveSSROptions, ssrConfigDefaults } from './ssr'
101101
import { PartialEnvironment } from './baseEnvironment'
102102
import { createIdResolver } from './idResolver'
103+
import { getAdditionalAllowedHosts } from './server/middlewares/hostCheck'
103104

104105
const debug = createDebugger('vite:config', { depth: 10 })
105106
const promisifiedRealpath = promisify(fs.realpath)
@@ -621,6 +622,8 @@ export type ResolvedConfig = Readonly<
621622
fsDenyGlob: AnymatchFn
622623
/** @internal */
623624
safeModulePaths: Set<string>
625+
/** @internal */
626+
additionalAllowedHosts: string[]
624627
} & PluginHookUtils
625628
>
626629

@@ -1383,6 +1386,8 @@ export async function resolveConfig(
13831386

13841387
const base = withTrailingSlash(resolvedBase)
13851388

1389+
const preview = resolvePreviewOptions(config.preview, server)
1390+
13861391
resolved = {
13871392
configFile: configFile ? normalizePath(configFile) : undefined,
13881393
configFileDependencies: configFileDependencies.map((name) =>
@@ -1413,7 +1418,7 @@ export async function resolveConfig(
14131418
},
14141419
server,
14151420
builder,
1416-
preview: resolvePreviewOptions(config.preview, server),
1421+
preview,
14171422
envDir,
14181423
env: {
14191424
...userEnv,
@@ -1492,6 +1497,7 @@ export async function resolveConfig(
14921497
},
14931498
),
14941499
safeModulePaths: new Set<string>(),
1500+
additionalAllowedHosts: getAdditionalAllowedHosts(server, preview),
14951501
}
14961502
resolved = {
14971503
...config,

‎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
@@ -38,6 +38,7 @@ import { resolveConfig } from './config'
3838
import type { InlineConfig, ResolvedConfig } from './config'
3939
import { DEFAULT_PREVIEW_PORT } from './constants'
4040
import type { RequiredExceptFor } from './typeUtils'
41+
import { hostCheckMiddleware } from './server/middlewares/hostCheck'
4142

4243
export interface PreviewOptions extends CommonServerOptions {}
4344

@@ -55,6 +56,7 @@ export function resolvePreviewOptions(
5556
port: preview?.port ?? DEFAULT_PREVIEW_PORT,
5657
strictPort: preview?.strictPort ?? server.strictPort,
5758
host: preview?.host ?? server.host,
59+
allowedHosts: preview?.allowedHosts ?? server.allowedHosts,
5860
https: preview?.https ?? server.https,
5961
open: preview?.open ?? server.open,
6062
proxy: preview?.proxy ?? server.proxy,
@@ -202,6 +204,13 @@ export async function preview(
202204
app.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
203205
}
204206

207+
// host check (to prevent DNS rebinding attacks)
208+
const { allowedHosts } = config.preview
209+
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
210+
if (allowedHosts !== true && !config.preview.https) {
211+
app.use(hostCheckMiddleware(config))
212+
}
213+
205214
// proxy
206215
const { proxy } = config.preview
207216
if (proxy) {

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

+9
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import type { TransformOptions, TransformResult } from './transformRequest'
9393
import { transformRequest } from './transformRequest'
9494
import { searchForPackageRoot, searchForWorkspaceRoot } from './searchRoot'
9595
import type { DevEnvironment } from './environment'
96+
import { hostCheckMiddleware } from './middlewares/hostCheck'
9697

9798
export interface ServerOptions extends CommonServerOptions {
9899
/**
@@ -857,6 +858,13 @@ export async function _createServer(
857858
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
858859
}
859860

861+
// host check (to prevent DNS rebinding attacks)
862+
const { allowedHosts } = serverConfig
863+
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
864+
if (allowedHosts !== true && !serverConfig.https) {
865+
middlewares.use(hostCheckMiddleware(config))
866+
}
867+
860868
middlewares.use(cachedTransformMiddleware(server))
861869

862870
// proxy
@@ -1043,6 +1051,7 @@ export const serverConfigDefaults = Object.freeze({
10431051
port: DEFAULT_DEV_PORT,
10441052
strictPort: false,
10451053
host: 'localhost',
1054+
allowedHosts: [],
10461055
https: undefined,
10471056
open: false,
10481057
proxy: undefined,
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
@@ -16,6 +16,7 @@ import type { ResolvedConfig } from '..'
1616
import { isObject } from '../utils'
1717
import type { NormalizedHotChannel, NormalizedHotChannelClient } from './hmr'
1818
import { normalizeHotChannel } from './hmr'
19+
import { isHostAllowed } from './middlewares/hostCheck'
1920
import type { HttpServer } from '.'
2021

2122
/* In Bun, the `ws` module is overridden to hook into the native code. Using the bundled `js` version
@@ -165,6 +166,11 @@ export function createWebSocketServer(
165166
// this is fine because vite-ping does not receive / send any meaningful data
166167
if (protocol === 'vite-ping') return true
167168

169+
const hostHeader = req.headers.host
170+
if (!hostHeader || !isHostAllowed(config, hostHeader)) {
171+
return false
172+
}
173+
168174
if (config.legacy?.skipWebSocketTokenCheck) {
169175
return true
170176
}

‎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.