Skip to content

Commit

Permalink
fix!: check host header to prevent DNS rebinding attacks and introduc…
Browse files Browse the repository at this point in the history
…e `server.allowedHosts`
  • Loading branch information
sapphi-red committed Jan 20, 2025
1 parent 029dcd6 commit bd896fb
Show file tree
Hide file tree
Showing 10 changed files with 401 additions and 2 deletions.
9 changes: 9 additions & 0 deletions docs/config/preview-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ See [`server.host`](./server-options#server-host) for more details.

:::

## preview.allowedHosts

- **Type:** `string | true`
- **Default:** [`server.allowedHosts`](./server-options#server-allowedhosts)

The hostnames that Vite is allowed to respond to.

See [`server.allowedHosts`](./server-options#server-allowedhosts) for more details.

## preview.port

- **Type:** `number`
Expand Down
14 changes: 14 additions & 0 deletions docs/config/server-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ See [the WSL document](https://learn.microsoft.com/en-us/windows/wsl/networking#

:::

## server.allowedHosts

- **Type:** `string[] | true`

This comment has been minimized.

Copy link
@kalufinnle

kalufinnle Feb 3, 2025

Incident report: a lot of developers reported there is a problem with adding allowedHosts after updated to recent release, Issue: Blocked request. This host ("xxxx") is not allowed.
To allow this host, add "xxxx" to server.allowedHosts in vite.config.js.

And editing vite.config.js in whatever ways does not solve the problems

This comment has been minimized.

Copy link
@sapphi-red

sapphi-red Feb 3, 2025

Author Member

Please create an issue with a reproduction if you encountered any bugs.

This comment has been minimized.

Copy link
@kalufinnle

kalufinnle Feb 3, 2025

please see this issue: #19242

This comment has been minimized.

Copy link
@sapphi-red

sapphi-red Feb 4, 2025

Author Member

That issue does not have a reproduction, please create one.

- **Default:** `[]`

The hostnames that Vite is allowed to respond to.
`localhost` and domains under `.localhost` and all IP addresses are allowed by default.
When using HTTPS, this check is skipped.

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

If set to `true`, the server is allowed to respond to requests for any hosts.
This is not recommended as it will be vulnerable to DNS rebinding attacks.

## server.port

- **Type:** `number`
Expand Down
8 changes: 7 additions & 1 deletion packages/vite/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import type { ResolvedSSROptions, SSROptions } from './ssr'
import { resolveSSROptions, ssrConfigDefaults } from './ssr'
import { PartialEnvironment } from './baseEnvironment'
import { createIdResolver } from './idResolver'
import { getAdditionalAllowedHosts } from './server/middlewares/hostCheck'

const debug = createDebugger('vite:config', { depth: 10 })
const promisifiedRealpath = promisify(fs.realpath)
Expand Down Expand Up @@ -621,6 +622,8 @@ export type ResolvedConfig = Readonly<
fsDenyGlob: AnymatchFn
/** @internal */
safeModulePaths: Set<string>
/** @internal */
additionalAllowedHosts: string[]
} & PluginHookUtils
>

Expand Down Expand Up @@ -1383,6 +1386,8 @@ export async function resolveConfig(

const base = withTrailingSlash(resolvedBase)

const preview = resolvePreviewOptions(config.preview, server)

resolved = {
configFile: configFile ? normalizePath(configFile) : undefined,
configFileDependencies: configFileDependencies.map((name) =>
Expand Down Expand Up @@ -1413,7 +1418,7 @@ export async function resolveConfig(
},
server,
builder,
preview: resolvePreviewOptions(config.preview, server),
preview,
envDir,
env: {
...userEnv,
Expand Down Expand Up @@ -1492,6 +1497,7 @@ export async function resolveConfig(
},
),
safeModulePaths: new Set<string>(),
additionalAllowedHosts: getAdditionalAllowedHosts(server, preview),
}
resolved = {
...config,
Expand Down
12 changes: 12 additions & 0 deletions packages/vite/src/node/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export interface CommonServerOptions {
* Set to 0.0.0.0 to listen on all addresses, including LAN and public addresses.
*/
host?: string | boolean
/**
* The hostnames that Vite is allowed to respond to.
* `localhost` and subdomains under `.localhost` and all IP addresses are allowed by default.
* When using HTTPS, this check is skipped.
*
* 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`.
*
* If set to `true`, the server is allowed to respond to requests for any hosts.
* This is not recommended as it will be vulnerable to DNS rebinding attacks.
*/
allowedHosts?: string[] | true
/**
* Enable TLS + HTTP/2.
* Note: this downgrades to TLS only when the proxy option is also used.
Expand Down
9 changes: 9 additions & 0 deletions packages/vite/src/node/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { resolveConfig } from './config'
import type { InlineConfig, ResolvedConfig } from './config'
import { DEFAULT_PREVIEW_PORT } from './constants'
import type { RequiredExceptFor } from './typeUtils'
import { hostCheckMiddleware } from './server/middlewares/hostCheck'

export interface PreviewOptions extends CommonServerOptions {}

Expand All @@ -55,6 +56,7 @@ export function resolvePreviewOptions(
port: preview?.port ?? DEFAULT_PREVIEW_PORT,
strictPort: preview?.strictPort ?? server.strictPort,
host: preview?.host ?? server.host,
allowedHosts: preview?.allowedHosts ?? server.allowedHosts,
https: preview?.https ?? server.https,
open: preview?.open ?? server.open,
proxy: preview?.proxy ?? server.proxy,
Expand Down Expand Up @@ -202,6 +204,13 @@ export async function preview(
app.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
}

// host check (to prevent DNS rebinding attacks)
const { allowedHosts } = config.preview
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
if (allowedHosts !== true && !config.preview.https) {
app.use(hostCheckMiddleware(config))
}

// proxy
const { proxy } = config.preview
if (proxy) {
Expand Down
9 changes: 9 additions & 0 deletions packages/vite/src/node/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import type { TransformOptions, TransformResult } from './transformRequest'
import { transformRequest } from './transformRequest'
import { searchForPackageRoot, searchForWorkspaceRoot } from './searchRoot'
import type { DevEnvironment } from './environment'
import { hostCheckMiddleware } from './middlewares/hostCheck'

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

// host check (to prevent DNS rebinding attacks)
const { allowedHosts } = serverConfig
// no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks
if (allowedHosts !== true && !serverConfig.https) {
middlewares.use(hostCheckMiddleware(config))
}

middlewares.use(cachedTransformMiddleware(server))

// proxy
Expand Down Expand Up @@ -1043,6 +1051,7 @@ export const serverConfigDefaults = Object.freeze({
port: DEFAULT_DEV_PORT,
strictPort: false,
host: 'localhost',
allowedHosts: [],
https: undefined,
open: false,
proxy: undefined,
Expand Down
112 changes: 112 additions & 0 deletions packages/vite/src/node/server/middlewares/__tests__/hostCheck.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, test } from 'vitest'
import {
getAdditionalAllowedHosts,
isHostAllowedWithoutCache,
} from '../hostCheck'

test('getAdditionalAllowedHosts', async () => {
const actual = getAdditionalAllowedHosts(
{
host: 'vite.host.example.com',
hmr: {
host: 'vite.hmr-host.example.com',
},
origin: 'http://vite.origin.example.com:5173',
},
{
host: 'vite.preview-host.example.com',
},
).sort()
expect(actual).toStrictEqual(
[
'vite.host.example.com',
'vite.hmr-host.example.com',
'vite.origin.example.com',
'vite.preview-host.example.com',
].sort(),
)
})

describe('isHostAllowedWithoutCache', () => {
const allowCases = {
'IP address': [
'192.168.0.0',
'[::1]',
'127.0.0.1:5173',
'[2001:db8:0:0:1:0:0:1]:5173',
],
localhost: [
'localhost',
'localhost:5173',
'foo.localhost',
'foo.bar.localhost',
],
specialProtocols: [
// for electron browser window (https://github.com/webpack/webpack-dev-server/issues/3821)
'file:///path/to/file.html',
// for browser extensions (https://github.com/webpack/webpack-dev-server/issues/3807)
'chrome-extension://foo',
],
}

const disallowCases = {
'IP address': ['255.255.255.256', '[:', '[::z]'],
localhost: ['localhos', 'localhost.foo'],
specialProtocols: ['mailto:[email protected]'],
others: [''],
}

for (const [name, inputList] of Object.entries(allowCases)) {
test.each(inputList)(`allows ${name} (%s)`, (input) => {
const actual = isHostAllowedWithoutCache([], [], input)
expect(actual).toBe(true)
})
}

for (const [name, inputList] of Object.entries(disallowCases)) {
test.each(inputList)(`disallows ${name} (%s)`, (input) => {
const actual = isHostAllowedWithoutCache([], [], input)
expect(actual).toBe(false)
})
}

test('allows additionalAlloweHosts option', () => {
const additionalAllowedHosts = ['vite.example.com']
const actual = isHostAllowedWithoutCache(
[],
additionalAllowedHosts,
'vite.example.com',
)
expect(actual).toBe(true)
})

test('allows single allowedHosts', () => {
const cases = {
allowed: ['example.com'],
disallowed: ['vite.dev'],
}
for (const c of cases.allowed) {
const actual = isHostAllowedWithoutCache(['example.com'], [], c)
expect(actual, c).toBe(true)
}
for (const c of cases.disallowed) {
const actual = isHostAllowedWithoutCache(['example.com'], [], c)
expect(actual, c).toBe(false)
}
})

test('allows all subdomain allowedHosts', () => {
const cases = {
allowed: ['example.com', 'foo.example.com', 'foo.bar.example.com'],
disallowed: ['vite.dev'],
}
for (const c of cases.allowed) {
const actual = isHostAllowedWithoutCache(['.example.com'], [], c)
expect(actual, c).toBe(true)
}
for (const c of cases.disallowed) {
const actual = isHostAllowedWithoutCache(['.example.com'], [], c)
expect(actual, c).toBe(false)
}
})
})
Loading

0 comments on commit bd896fb

Please sign in to comment.