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`
- **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: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 bd896fb

Please sign in to comment.