diff --git a/docs/config/preview-options.md b/docs/config/preview-options.md index 0202d77f963b87..d1a06a39d4ebb1 100644 --- a/docs/config/preview-options.md +++ b/docs/config/preview-options.md @@ -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` diff --git a/docs/config/server-options.md b/docs/config/server-options.md index 92a48f8570273f..901e333f32c857 100644 --- a/docs/config/server-options.md +++ b/docs/config/server-options.md @@ -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` diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index f0f02b102397e2..9c2e36cd9d9ea3 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -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) @@ -621,6 +622,8 @@ export type ResolvedConfig = Readonly< fsDenyGlob: AnymatchFn /** @internal */ safeModulePaths: Set + /** @internal */ + additionalAllowedHosts: string[] } & PluginHookUtils > @@ -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) => @@ -1413,7 +1418,7 @@ export async function resolveConfig( }, server, builder, - preview: resolvePreviewOptions(config.preview, server), + preview, envDir, env: { ...userEnv, @@ -1492,6 +1497,7 @@ export async function resolveConfig( }, ), safeModulePaths: new Set(), + additionalAllowedHosts: getAdditionalAllowedHosts(server, preview), } resolved = { ...config, diff --git a/packages/vite/src/node/http.ts b/packages/vite/src/node/http.ts index faaf6e17d17078..32c47461f7fd56 100644 --- a/packages/vite/src/node/http.ts +++ b/packages/vite/src/node/http.ts @@ -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. diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index 614c16f8aaac06..969c21502fd95b 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -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 {} @@ -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, @@ -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) { diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 88d5fed1df98df..7d46edff56dc28 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -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 { /** @@ -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 @@ -1043,6 +1051,7 @@ export const serverConfigDefaults = Object.freeze({ port: DEFAULT_DEV_PORT, strictPort: false, host: 'localhost', + allowedHosts: [], https: undefined, open: false, proxy: undefined, diff --git a/packages/vite/src/node/server/middlewares/__tests__/hostCheck.spec.ts b/packages/vite/src/node/server/middlewares/__tests__/hostCheck.spec.ts new file mode 100644 index 00000000000000..7186ba85f2644a --- /dev/null +++ b/packages/vite/src/node/server/middlewares/__tests__/hostCheck.spec.ts @@ -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) + } + }) +}) diff --git a/packages/vite/src/node/server/middlewares/hostCheck.ts b/packages/vite/src/node/server/middlewares/hostCheck.ts new file mode 100644 index 00000000000000..5f43592aa04449 --- /dev/null +++ b/packages/vite/src/node/server/middlewares/hostCheck.ts @@ -0,0 +1,164 @@ +import net from 'node:net' +import type { Connect } from 'dep-types/connect' +import type { ResolvedConfig } from '../../config' +import type { ResolvedPreviewOptions, ResolvedServerOptions } from '../..' + +const allowedHostsCache = new WeakMap>() + +const isFileOrExtensionProtocolRE = /^(?:file|.+-extension):/i + +export function getAdditionalAllowedHosts( + resolvedServerOptions: Pick, + resolvedPreviewOptions: Pick, +): string[] { + const list = [] + + // allow host option by default as that indicates that the user is + // expecting Vite to respond on that host + if ( + typeof resolvedServerOptions.host === 'string' && + resolvedServerOptions.host + ) { + list.push(resolvedServerOptions.host) + } + if ( + typeof resolvedServerOptions.hmr === 'object' && + resolvedServerOptions.hmr.host + ) { + list.push(resolvedServerOptions.hmr.host) + } + if ( + typeof resolvedPreviewOptions.host === 'string' && + resolvedPreviewOptions.host + ) { + list.push(resolvedPreviewOptions.host) + } + + // allow server origin by default as that indicates that the user is + // expecting Vite to respond on that host + if (resolvedServerOptions.origin) { + const serverOriginUrl = new URL(resolvedServerOptions.origin) + list.push(serverOriginUrl.hostname) + } + + return list +} + +// Based on webpack-dev-server's `checkHeader` function: https://github.com/webpack/webpack-dev-server/blob/v5.2.0/lib/Server.js#L3086 +// https://github.com/webpack/webpack-dev-server/blob/v5.2.0/LICENSE +export function isHostAllowedWithoutCache( + allowedHosts: string[], + additionalAllowedHosts: string[], + host: string, +): boolean { + if (isFileOrExtensionProtocolRE.test(host)) { + return true + } + + // We don't care about malformed Host headers, + // because we only need to consider browser requests. + // Non-browser clients can send any value they want anyway. + // + // `Host = uri-host [ ":" port ]` + const trimmedHost = host.trim() + + // IPv6 + if (trimmedHost[0] === '[') { + const endIpv6 = trimmedHost.indexOf(']') + if (endIpv6 < 0) { + return false + } + // DNS rebinding attacks does not happen with IP addresses + return net.isIP(trimmedHost.slice(1, endIpv6)) === 6 + } + + // uri-host does not include ":" unless IPv6 address + const colonPos = trimmedHost.indexOf(':') + const hostname = + colonPos === -1 ? trimmedHost : trimmedHost.slice(0, colonPos) + + // DNS rebinding attacks does not happen with IP addresses + if (net.isIP(hostname) === 4) { + return true + } + + // allow localhost and .localhost by default as they always resolve to the loopback address + // https://datatracker.ietf.org/doc/html/rfc6761#section-6.3 + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { + return true + } + + for (const additionalAllowedHost of additionalAllowedHosts) { + if (additionalAllowedHost === hostname) { + return true + } + } + + for (const allowedHost of allowedHosts) { + if (allowedHost === hostname) { + return true + } + + // allow all subdomains of it + // e.g. `.foo.example` will allow `foo.example`, `*.foo.example`, `*.*.foo.example`, etc + if ( + allowedHost[0] === '.' && + (allowedHost.slice(1) === hostname || hostname.endsWith(allowedHost)) + ) { + return true + } + } + + return false +} + +/** + * @param config resolved config + * @param host the value of host header. See [RFC 9110 7.2](https://datatracker.ietf.org/doc/html/rfc9110#name-host-and-authority). + */ +export function isHostAllowed(config: ResolvedConfig, host: string): boolean { + if (config.server.allowedHosts === true) { + return true + } + + if (!allowedHostsCache.has(config)) { + allowedHostsCache.set(config, new Set()) + } + + const allowedHosts = allowedHostsCache.get(config)! + if (allowedHosts.has(host)) { + return true + } + + const result = isHostAllowedWithoutCache( + config.server.allowedHosts, + config.additionalAllowedHosts, + host, + ) + if (result) { + allowedHosts.add(host) + } + return result +} + +export function hostCheckMiddleware( + config: ResolvedConfig, +): Connect.NextHandleFunction { + // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` + return function viteHostCheckMiddleware(req, res, next) { + const hostHeader = req.headers.host + if (!hostHeader || !isHostAllowed(config, hostHeader)) { + const hostname = hostHeader?.replace(/:\d+$/, '') + const hostnameWithQuotes = JSON.stringify(hostname) + res.writeHead(403, { + 'Content-Type': 'text/plain', + }) + res.end( + `Blocked request. This host (${hostnameWithQuotes}) is not allowed.\n` + + `To allow this host, add ${hostnameWithQuotes} to \`server.allowedHosts\` in vite.config.js.`, + ) + return + } + return next() + } +} diff --git a/packages/vite/src/node/server/ws.ts b/packages/vite/src/node/server/ws.ts index b295d1050a17e9..f5cb6b208bb77d 100644 --- a/packages/vite/src/node/server/ws.ts +++ b/packages/vite/src/node/server/ws.ts @@ -16,6 +16,7 @@ import type { ResolvedConfig } from '..' import { isObject } from '../utils' import type { NormalizedHotChannel, NormalizedHotChannelClient } from './hmr' import { normalizeHotChannel } from './hmr' +import { isHostAllowed } from './middlewares/hostCheck' import type { HttpServer } from '.' /* In Bun, the `ws` module is overridden to hook into the native code. Using the bundled `js` version @@ -165,6 +166,11 @@ export function createWebSocketServer( // this is fine because vite-ping does not receive / send any meaningful data if (protocol === 'vite-ping') return true + const hostHeader = req.headers.host + if (!hostHeader || !isHostAllowed(config, hostHeader)) { + return false + } + if (config.legacy?.skipWebSocketTokenCheck) { return true } diff --git a/playground/fs-serve/__tests__/fs-serve.spec.ts b/playground/fs-serve/__tests__/fs-serve.spec.ts index c84ed0970d826a..deeb5153b8dfaa 100644 --- a/playground/fs-serve/__tests__/fs-serve.spec.ts +++ b/playground/fs-serve/__tests__/fs-serve.spec.ts @@ -162,11 +162,13 @@ describe('cross origin', () => { const connectWebSocketFromServer = async ( url: string, + host: string, origin: string | undefined, ) => { try { const ws = new WebSocket(url, ['vite-hmr'], { headers: { + Host: host, ...(origin ? { Origin: origin } : undefined), }, }) @@ -212,10 +214,37 @@ describe('cross origin', () => { expect(result).toBe(true) }) + test('fetch with allowed hosts', async () => { + const viteTestUrlUrl = new URL(viteTestUrl) + const res = await fetch(viteTestUrl + '/src/index.html', { + headers: { Host: viteTestUrlUrl.host }, + }) + expect(res.status).toBe(200) + }) + + test.runIf(isServe)( + 'connect WebSocket with valid token with allowed hosts', + async () => { + const viteTestUrlUrl = new URL(viteTestUrl) + const token = viteServer.config.webSocketToken + const result = await connectWebSocketFromServer( + `${viteTestUrl}?token=${token}`, + viteTestUrlUrl.host, + viteTestUrlUrl.origin, + ) + expect(result).toBe(true) + }, + ) + test.runIf(isServe)( 'connect WebSocket without a token without the origin header', async () => { - const result = await connectWebSocketFromServer(viteTestUrl, undefined) + const viteTestUrlUrl = new URL(viteTestUrl) + const result = await connectWebSocketFromServer( + viteTestUrl, + viteTestUrlUrl.host, + undefined, + ) expect(result).toBe(true) }, ) @@ -269,5 +298,34 @@ describe('cross origin', () => { ) expect(result2).toBe(false) }) + + test('fetch with non-allowed hosts', async () => { + const res = await fetch(viteTestUrl + '/src/index.html', { + headers: { + Host: 'vite.dev', + }, + }) + expect(res.status).toBe(403) + }) + + test.runIf(isServe)( + 'connect WebSocket with valid token with non-allowed hosts', + async () => { + const token = viteServer.config.webSocketToken + const result = await connectWebSocketFromServer( + `${viteTestUrl}?token=${token}`, + 'vite.dev', + 'http://vite.dev', + ) + expect(result).toBe(false) + + const result2 = await connectWebSocketFromServer( + `${viteTestUrl}?token=${token}`, + 'vite.dev', + undefined, + ) + expect(result2).toBe(false) + }, + ) }) })