Skip to content

Commit bd896fb

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