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 b71a5c8 commit 9da4abc
Show file tree
Hide file tree
Showing 10 changed files with 400 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 @@ -17,6 +17,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 @@ -41,6 +41,20 @@ See [the WSL document](https://learn.microsoft.com/en-us/windows/wsl/networking#

:::

## server.allowedHosts

- **Type:** `string[] | true`
- **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 @@ -73,6 +73,7 @@ import { findNearestPackageData } from './packages'
import { loadEnv, resolveEnvPrefix } from './env'
import type { ResolvedSSROptions, SSROptions } from './ssr'
import { resolveSSROptions } from './ssr'
import { getAdditionalAllowedHosts } from './server/middlewares/hostCheck'

const debug = createDebugger('vite:config')
const promisifiedRealpath = promisify(fs.realpath)
Expand Down Expand Up @@ -424,6 +425,8 @@ export type ResolvedConfig = Readonly<
* @deprecated
*/
webSocketToken: string
/** @internal */
additionalAllowedHosts: string[]
} & PluginHookUtils
>

Expand Down Expand Up @@ -791,6 +794,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 @@ -822,7 +827,7 @@ export async function resolveConfig(
},
server,
build: resolvedBuildOptions,
preview: resolvePreviewOptions(config.preview, server),
preview,
envDir,
env: {
...userEnv,
Expand Down Expand Up @@ -858,6 +863,7 @@ export async function resolveConfig(
webSocketToken: Buffer.from(
crypto.getRandomValues(new Uint8Array(9)),
).toString('base64url'),
additionalAllowedHosts: getAdditionalAllowedHosts(server, preview),
getSortedPlugins: undefined!,
getSortedPluginHooks: undefined!,
}
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 @@ -37,6 +37,7 @@ import { bindCLIShortcuts } from './shortcuts'
import type { BindCLIShortcutsOptions } from './shortcuts'
import { resolveConfig } from './config'
import type { InlineConfig, ResolvedConfig } from './config'
import { hostCheckMiddleware } from './server/middlewares/hostCheck'

export interface PreviewOptions extends CommonServerOptions {}

Expand All @@ -53,6 +54,7 @@ export function resolvePreviewOptions(
port: 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 @@ -188,6 +190,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
8 changes: 8 additions & 0 deletions packages/vite/src/node/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import type { TransformOptions, TransformResult } from './transformRequest'
import { transformRequest } from './transformRequest'
import { searchForWorkspaceRoot } from './searchRoot'
import { warmupFiles } from './warmup'
import { hostCheckMiddleware } from './middlewares/hostCheck'

export interface ServerOptions extends CommonServerOptions {
/**
Expand Down Expand Up @@ -853,6 +854,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
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:foo@bar.com'],
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 9da4abc

Please sign in to comment.