@@ -34,11 +34,12 @@ import { acquireLock, formatLockError, getTakeoverPid, updateLock } from '../uti
3434import { debug , logger , writeNotice } from '../utils/logger'
3535import { loadNuxtManifest , resolveNuxtManifest , writeNuxtManifest } from '../utils/nuxt'
3636import { renderError , renderErrorAnsi } from './error-lazy'
37+ import { isAllowedHost } from './host-check'
3738import { bindListener , createListener , matchesBoundTarget , openBrowser , resolveOpenURL } from './listen'
3839import { RECOVERY_SCRIPT , withProgress } from './loading-page'
3940import { resolveDefaultLoadingTemplate } from './loading-template'
4041import { resolvePortlessURLs } from './portless'
41- import { DevProgress } from './progress'
42+ import { DEV_INTERNAL_PREFIX , DevProgress } from './progress'
4243import { formatChangedKeys , formatRestartReason , formatSkippedReload , mergeRestartReasons , withConfigKeys } from './reason'
4344import { encodeRequest , REQUEST_HEADER , runWithRequest } from './serving-state'
4445import { 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 ) {
0 commit comments