Skip to content

Commit ef1049d

Browse files
committed
fix!: check host header to prevent DNS rebinding attacks and introduce server.allowedHosts
1 parent c065a77 commit ef1049d

File tree

10 files changed

+402
-4
lines changed

10 files changed

+402
-4
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
@@ -71,6 +71,7 @@ import { findNearestPackageData } from './packages'
7171
import { loadEnv, resolveEnvPrefix } from './env'
7272
import type { ResolvedSSROptions, SSROptions } from './ssr'
7373
import { resolveSSROptions } from './ssr'
74+
import { getAdditionalAllowedHosts } from './server/middlewares/hostCheck'
7475

7576
const debug = createDebugger('vite:config')
7677
const promisifiedRealpath = promisify(fs.realpath)
@@ -409,6 +410,8 @@ export type ResolvedConfig = Readonly<
409410
* @deprecated use `import.meta.hot`
410411
*/
411412
webSocketToken: string
413+
/** @internal */
414+
additionalAllowedHosts: string[]
412415
} & PluginHookUtils
413416
>
414417

@@ -673,6 +676,8 @@ export async function resolveConfig(
673676
config.legacy?.buildSsrCjsExternalHeuristics,
674677
)
675678

679+
const preview = resolvePreviewOptions(config.preview, server)
680+
676681
const middlewareMode = config?.server?.middlewareMode
677682

678683
const optimizeDeps = config.optimizeDeps || {}
@@ -728,7 +733,7 @@ export async function resolveConfig(
728733
},
729734
server,
730735
build: resolvedBuildOptions,
731-
preview: resolvePreviewOptions(config.preview, server),
736+
preview,
732737
envDir,
733738
env: {
734739
...userEnv,
@@ -764,6 +769,7 @@ export async function resolveConfig(
764769
webSocketToken: Buffer.from(
765770
crypto.getRandomValues(new Uint8Array(9)),
766771
).toString('base64url'),
772+
additionalAllowedHosts: getAdditionalAllowedHosts(server, preview),
767773
getSortedPlugins: undefined!,
768774
getSortedPluginHooks: undefined!,
769775
}

packages/vite/src/node/http.ts

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

packages/vite/src/node/preview.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import { proxyMiddleware } from './server/middlewares/proxy'
1919
import { resolveHostname, resolveServerUrls, shouldServeFile } from './utils'
2020
import { printServerUrls } from './logger'
2121
import { DEFAULT_PREVIEW_PORT } from './constants'
22-
import { resolveConfig } from '.'
23-
import type { InlineConfig, ResolvedConfig } from '.'
22+
import { resolveConfig } from './config'
23+
import type { InlineConfig, ResolvedConfig } from './config'
24+
import { hostCheckMiddleware } from './server/middlewares/hostCheck'
2425

2526
export interface PreviewOptions extends CommonServerOptions {}
2627

@@ -37,6 +38,7 @@ export function resolvePreviewOptions(
3738
port: preview?.port,
3839
strictPort: preview?.strictPort ?? server.strictPort,
3940
host: preview?.host ?? server.host,
41+
allowedHosts: preview?.allowedHosts ?? server.allowedHosts,
4042
https: preview?.https ?? server.https,
4143
open: preview?.open ?? server.open,
4244
proxy: preview?.proxy ?? server.proxy,
@@ -148,6 +150,13 @@ export async function preview(
148150
app.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
149151
}
150152

153+
// host check (to prevent DNS rebinding attacks)
154+
const { allowedHosts } = config.preview
155+
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
156+
if (allowedHosts !== true && !config.preview.https) {
157+
app.use(hostCheckMiddleware(config))
158+
}
159+
151160
// proxy
152161
const { proxy } = config.preview
153162
if (proxy) {

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

+8
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import { openBrowser as _openBrowser } from './openBrowser'
7878
import type { TransformOptions, TransformResult } from './transformRequest'
7979
import { transformRequest } from './transformRequest'
8080
import { searchForWorkspaceRoot } from './searchRoot'
81+
import { hostCheckMiddleware } from './middlewares/hostCheck'
8182

8283
export interface ServerOptions extends CommonServerOptions {
8384
/**
@@ -617,6 +618,13 @@ export async function _createServer(
617618
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
618619
}
619620

621+
// host check (to prevent DNS rebinding attacks)
622+
const { allowedHosts } = serverConfig
623+
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
624+
if (allowedHosts !== true && !serverConfig.https) {
625+
middlewares.use(hostCheckMiddleware(config))
626+
}
627+
620628
// proxy
621629
const { proxy } = serverConfig
622630
if (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:[email protected]'],
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+
})

0 commit comments

Comments
 (0)