Skip to content

Commit 9da4abc

Browse files
committed
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:[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)