fix(dev): reject unknown Host headers on internal dev endpoints · nuxt/cli@14c268e · GitHub
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

‎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

0 commit comments

Comments
 (0)