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

Lines changed: 9 additions & 0 deletions
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

Lines changed: 14 additions & 0 deletions
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

Lines changed: 7 additions & 1 deletion
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

Lines changed: 12 additions & 0 deletions
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

Lines changed: 11 additions & 2 deletions
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

Lines changed: 8 additions & 0 deletions
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) {
Lines changed: 112 additions & 0 deletions
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+
})

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy