@@ -176,6 +176,25 @@ const serveStatic = async (pathname: string): Promise<Response | null> => {
176176 return null ;
177177} ;
178178
179+ // ---------------------------------------------------------------------------
180+ // Host-header allowlist — blocks DNS rebinding attacks
181+ // ---------------------------------------------------------------------------
182+
183+ const ALLOWED_HOSTS = new Set ( [
184+ "localhost" ,
185+ "127.0.0.1" ,
186+ "[::1]" ,
187+ "::1" ,
188+ ] ) ;
189+
190+ const isAllowedHost = ( request : Request ) : boolean => {
191+ const host = request . headers . get ( "host" ) ;
192+ if ( ! host ) return true ; // no host header (e.g. HTTP/1.0) — allow
193+ // Strip port ("localhost:4788" → "localhost")
194+ const hostname = host . replace ( / : \d + $ / , "" ) ;
195+ return ALLOWED_HOSTS . has ( hostname ) ;
196+ } ;
197+
179198// ---------------------------------------------------------------------------
180199// Foreground session — API + MCP + Web UI on one Bun.serve()
181200// ---------------------------------------------------------------------------
@@ -184,36 +203,53 @@ const runForegroundSession = (input: { kind: "web" | "mcp"; port: number }) =>
184203 Effect . gen ( function * ( ) {
185204 const handlers = yield * Effect . promise ( ( ) => createServerHandlers ( ) ) ;
186205
187- const server = Bun . serve ( {
188- port : input . port ,
189- async fetch ( request ) {
190- const url = new URL ( request . url ) ;
206+ // Bind to loopback only — one server per address family so both
207+ // "localhost" (which may resolve to ::1 on Windows) and 127.0.0.1 work,
208+ // without exposing the server on the LAN.
209+ const fetch = async ( request : Request ) : Promise < Response > => {
210+ if ( ! isAllowedHost ( request ) ) {
211+ return new Response ( "Forbidden" , { status : 403 } ) ;
212+ }
191213
192- if ( url . pathname . startsWith ( "/mcp" ) ) {
193- return handlers . mcp . handleRequest ( request ) ;
194- }
214+ const url = new URL ( request . url ) ;
195215
196- if (
197- url . pathname . startsWith ( "/v1/" ) ||
198- url . pathname . startsWith ( "/docs" ) ||
199- url . pathname === "/openapi.json"
200- ) {
201- return handlers . api . handler ( request ) ;
202- }
216+ if ( url . pathname . startsWith ( "/mcp" ) ) {
217+ return handlers . mcp . handleRequest ( request ) ;
218+ }
219+
220+ if (
221+ url . pathname . startsWith ( "/v1/" ) ||
222+ url . pathname . startsWith ( "/docs" ) ||
223+ url . pathname === "/openapi.json"
224+ ) {
225+ return handlers . api . handler ( request ) ;
226+ }
203227
204- const staticResponse = await serveStatic ( url . pathname ) ;
205- if ( staticResponse ) return staticResponse ;
228+ const staticResponse = await serveStatic ( url . pathname ) ;
229+ if ( staticResponse ) return staticResponse ;
206230
207- return new Response ( "Not Found" , { status : 404 } ) ;
208- } ,
209- } ) ;
231+ return new Response ( "Not Found" , { status : 404 } ) ;
232+ } ;
233+
234+ const serverV4 = Bun . serve ( { port : input . port , hostname : "127.0.0.1" , fetch } ) ;
235+
236+ // IPv6 loopback — may fail on systems without IPv6; that's fine.
237+ let serverV6 : ReturnType < typeof Bun . serve > | null = null ;
238+ try {
239+ serverV6 = Bun . serve ( { port : input . port , hostname : "::1" , fetch } ) ;
240+ } catch {
241+ // IPv6 not available — continue with IPv4 only
242+ }
243+
244+ const server = serverV4 ;
210245
211246 const baseUrl = `http://localhost:${ server . port } ` ;
212247 console . log ( renderSessionSummary ( input . kind , baseUrl ) ) ;
213248
214249 yield * waitForShutdownSignal ( ) ;
215250
216- server . stop ( true ) ;
251+ serverV4 . stop ( true ) ;
252+ serverV6 ?. stop ( true ) ;
217253 yield * Effect . promise ( ( ) => handlers . mcp . close ( ) ) ;
218254 yield * Effect . promise ( ( ) => handlers . api . dispose ( ) ) ;
219255 } ) ;
0 commit comments