Skip to content

Commit 14c268e

Browse files
committed
fix(dev): reject unknown Host headers on internal dev endpoints
1 parent 667f2a8 commit 14c268e

5 files changed

Lines changed: 197 additions & 4 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { isIP } from 'node:net'
2+
3+
/**
4+
* Hostname of an HTTP `Host` header: lowercased, port stripped, IPv6 brackets
5+
* removed. `undefined` for a value that cannot be a hostname at all.
6+
*/
7+
export function parseHostHeader(host: string | undefined): string | undefined {
8+
if (!host) {
9+
return undefined
10+
}
11+
const value = host.trim().toLowerCase()
12+
if (value.startsWith('[')) {
13+
const end = value.indexOf(']')
14+
return end > 1 ? value.slice(1, end) : undefined
15+
}
16+
const hostname = value.split(':')[0]
17+
return hostname || undefined
18+
}
19+
20+
/**
21+
* Whether a request's `Host` header names this dev server, guarding the CLI's
22+
* own endpoints (the progress stream, the loading page and the error page)
23+
* against DNS rebinding, where a hostname the attacker controls resolves to
24+
* this machine and a page in the developer's own browser becomes same-origin
25+
* with the dev server.
26+
*
27+
* IP literals are always accepted: rebinding needs a DNS name, and devices on
28+
* a permitted network reach a non-loopback bind by address. A missing `Host`
29+
* is accepted too, since it cannot come from a browser.
30+
*/
31+
export function isAllowedHost(host: string | undefined, allowedHosts: ReadonlySet<string>): boolean {
32+
if (!host) {
33+
return true
34+
}
35+
const hostname = parseHostHeader(host)
36+
if (!hostname) {
37+
return false
38+
}
39+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
40+
return true
41+
}
42+
if (isIP(hostname)) {
43+
return true
44+
}
45+
return allowedHosts.has(hostname)
46+
}

packages/nuxt-cli/src/dev/progress.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { IncomingMessage, ServerResponse } from 'node:http'
22

33
/** Path prefix reserved for the CLI's own dev-time endpoints. */
4-
const DEV_INTERNAL_PREFIX: string = '/__nuxt_dev__/'
4+
export const DEV_INTERNAL_PREFIX: string = '/__nuxt_dev__/'
55
export const PROGRESS_PATH: string = `${DEV_INTERNAL_PREFIX}progress`
66
const HEARTBEAT_INTERVAL = 15_000
77

packages/nuxt-cli/src/dev/utils.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,12 @@ import { acquireLock, formatLockError, getTakeoverPid, updateLock } from '../uti
3434
import { debug, logger, writeNotice } from '../utils/logger'
3535
import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt'
3636
import { renderError, renderErrorAnsi } from './error-lazy'
37+
import { isAllowedHost } from './host-check'
3738
import { bindListener, createListener, matchesBoundTarget, openBrowser, resolveOpenURL } from './listen'
3839
import { RECOVERY_SCRIPT, withProgress } from './loading-page'
3940
import { resolveDefaultLoadingTemplate } from './loading-template'
4041
import { resolvePortlessURLs } from './portless'
41-
import { DevProgress } from './progress'
42+
import { DEV_INTERNAL_PREFIX, DevProgress } from './progress'
4243
import { formatChangedKeys, formatRestartReason, formatSkippedReload, mergeRestartReasons, withConfigKeys } from './reason'
4344
import { encodeRequest, REQUEST_HEADER, runWithRequest } from './serving-state'
4445
import { WarmupGate } from './warmup-gate'
@@ -391,6 +392,8 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
391392
#rawConfig?: Record<string, unknown>
392393
#changedConfigKeys?: string[]
393394
#bound?: BoundServer
395+
#allowedHosts = new Set<string>()
396+
#allowAnyHost = false
394397
#openedEagerly = false
395398
#progress = new DevProgress()
396399
#warmup = new WarmupGate()
@@ -427,8 +430,13 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
427430
// Internal endpoints answer before Nuxt exists, so they are matched ahead
428431
// of anything that waits on the first successful load, and they stay out
429432
// of the request feed.
430-
if (this.#progress.handleRequest(req, res)) {
431-
return
433+
if ((req.url || '').split('?')[0]?.startsWith(DEV_INTERNAL_PREFIX)) {
434+
if (this.#rejectDisallowedHost(req, res)) {
435+
return
436+
}
437+
if (this.#progress.handleRequest(req, res)) {
438+
return
439+
}
432440
}
433441
if (!options.captureUIEvents) {
434442
return this.#serve(req, res)
@@ -456,8 +464,56 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
456464
}
457465
}
458466

467+
/**
468+
* Answer a request whose `Host` header does not name this server, so pages
469+
* loaded from a rebinding hostname cannot read the CLI's own endpoints.
470+
* Returns `true` when the request was rejected. Requests the app itself
471+
* serves are not gated here: Vite applies its own `allowedHosts` check.
472+
*/
473+
#rejectDisallowedHost(req: IncomingMessage, res: ServerResponse): boolean {
474+
if (this.#allowAnyHost || isAllowedHost(req.headers.host, this.#allowedHosts)) {
475+
return false
476+
}
477+
if (this.options.captureUIEvents) {
478+
this.#internalResponses.add(res)
479+
}
480+
if (!res.headersSent) {
481+
res.statusCode = 403
482+
res.setHeader('Content-Type', 'text/plain')
483+
}
484+
res.end('Forbidden: this host is not allowed. Pass `--host` to allow it.')
485+
return true
486+
}
487+
488+
/**
489+
* Record the hostnames this server answers on, for the `Host` check on the
490+
* CLI's own endpoints. `--public` opts out of the check entirely.
491+
*/
492+
#syncAllowedHosts(options: ListenOptions): void {
493+
this.#allowAnyHost = !!options.public
494+
this.#allowedHosts.clear()
495+
if (this.#allowAnyHost) {
496+
return
497+
}
498+
if (options.hostname) {
499+
this.#allowedHosts.add(options.hostname.toLowerCase())
500+
}
501+
for (const { url } of this.listener?.getURLs() ?? []) {
502+
try {
503+
const hostname = new URL(url).hostname.toLowerCase()
504+
this.#allowedHosts.add(hostname.startsWith('[') ? hostname.slice(1, -1) : hostname)
505+
}
506+
catch {
507+
// a malformed display URL is not a hostname to allow
508+
}
509+
}
510+
}
511+
459512
async #serve(req: IncomingMessage, res: ServerResponse): Promise<void> {
460513
if (this.#loadingError) {
514+
if (this.#rejectDisallowedHost(req, res)) {
515+
return
516+
}
461517
if (this.options.captureUIEvents) {
462518
this.#internalResponses.add(res)
463519
}
@@ -467,6 +523,9 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
467523
return
468524
}
469525
if (!this.#handler) {
526+
if (this.#rejectDisallowedHost(req, res)) {
527+
return
528+
}
470529
if (this.options.captureUIEvents) {
471530
this.#internalResponses.add(res)
472531
}
@@ -874,6 +933,7 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
874933
}
875934

876935
this.listener = await createListener(this.#bound, listenOptions, { announce: false })
936+
this.#syncAllowedHosts(listenOptions)
877937
this.emit('listening', { url: this.listener.url, urls: this.listener.getURLs(), confirmed: false })
878938

879939
const knowsScheme = overrides.httpsEnabled !== undefined || hint?.https === false
@@ -903,6 +963,7 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
903963
...listenOptions,
904964
open: listenOptions.open && !this.#openedEagerly,
905965
})
966+
this.#syncAllowedHosts(listenOptions)
906967
this.emit('listening', { url: this.listener.url, urls: this.listener.getURLs(), confirmed: true })
907968

908969
if (listenOptions.public) {

packages/nuxt-cli/test/e2e/dev.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,34 @@ describe('dev server', () => {
156156
}
157157
})
158158

159+
it('should reject internal endpoints for an unknown Host header', { timeout: 50_000 }, async () => {
160+
const host = '127.0.0.1'
161+
const port = await getPort({ host, port: 3034 })
162+
163+
const { result: { close } } = await runCommand('dev', [`--host=${host}`, `--port=${port}`, `--cwd=${fixtureDir}`]) as any
164+
165+
try {
166+
// `fetch` refuses to forward a forged `Host`, so speak HTTP directly.
167+
const { request } = await import('node:http')
168+
const status = await new Promise<number | undefined>((resolve, reject) => {
169+
const req = request({ host, port, path: '/__nuxt_dev__/progress', headers: { host: 'rebinding-attacker.com' } }, (res) => {
170+
res.resume()
171+
resolve(res.statusCode)
172+
})
173+
req.once('error', reject)
174+
req.end()
175+
})
176+
expect(status).toBe(403)
177+
178+
const allowed = await fetch(`http://${host}:${port}/__nuxt_dev__/progress`)
179+
expect(allowed.headers.get('content-type')).toBe('text/event-stream')
180+
await allowed.body?.cancel()
181+
}
182+
finally {
183+
await close()
184+
}
185+
})
186+
159187
it('should handle multiple set-cookie headers correctly', { timeout: 50_000 }, async () => {
160188
await rm(join(fixtureDir, '.nuxt'), { recursive: true, force: true })
161189
const host = '127.0.0.1'
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { isAllowedHost, parseHostHeader } from '../../src/dev/host-check'
4+
5+
describe('parseHostHeader', () => {
6+
it('strips the port', () => {
7+
expect(parseHostHeader('example.com:3000')).toBe('example.com')
8+
})
9+
10+
it('lowercases the hostname', () => {
11+
expect(parseHostHeader('EXAMPLE.com')).toBe('example.com')
12+
})
13+
14+
it('unwraps bracketed IPv6 literals', () => {
15+
expect(parseHostHeader('[::1]:3000')).toBe('::1')
16+
})
17+
18+
it('returns undefined for empty or malformed values', () => {
19+
expect(parseHostHeader(undefined)).toBeUndefined()
20+
expect(parseHostHeader('')).toBeUndefined()
21+
expect(parseHostHeader(':3000')).toBeUndefined()
22+
expect(parseHostHeader('[')).toBeUndefined()
23+
})
24+
})
25+
26+
describe('isAllowedHost', () => {
27+
const none = new Set<string>()
28+
29+
it('accepts a missing Host header', () => {
30+
expect(isAllowedHost(undefined, none)).toBe(true)
31+
})
32+
33+
it('accepts localhost and .localhost subdomains', () => {
34+
expect(isAllowedHost('localhost:3000', none)).toBe(true)
35+
expect(isAllowedHost('app.localhost', none)).toBe(true)
36+
})
37+
38+
it('accepts IP literals', () => {
39+
expect(isAllowedHost('127.0.0.1:3000', none)).toBe(true)
40+
expect(isAllowedHost('192.168.1.20:3000', none)).toBe(true)
41+
expect(isAllowedHost('[::1]:3000', none)).toBe(true)
42+
})
43+
44+
it('accepts hostnames on the allowlist, case-insensitively', () => {
45+
const allowed = new Set(['dev.example.com'])
46+
expect(isAllowedHost('dev.example.com:3000', allowed)).toBe(true)
47+
expect(isAllowedHost('DEV.example.com', allowed)).toBe(true)
48+
})
49+
50+
it('rejects hostnames not on the allowlist', () => {
51+
expect(isAllowedHost('rebinding-attacker.com:3000', none)).toBe(false)
52+
expect(isAllowedHost('localhost.evil.com', none)).toBe(false)
53+
})
54+
55+
it('rejects malformed Host values', () => {
56+
expect(isAllowedHost('[', none)).toBe(false)
57+
})
58+
})

0 commit comments

Comments
 (0)