- Bind to 127.0.0.1 + ::1 instead of :: so the server works with both localhost and 127.0.0.1 on Windows without exposing to the LAN - Add host header validation to production server and vite dev server
Unified · Split
| @@ -176,6 +176,25 @@ const serveStatic = async (pathname: string): Promise<Response | null> => { · 175 unmodified lines | |||
| 176 | return null; | 176 | return null; |
| 177 | }; | 177 | }; |
| 178 | 178 | ||
| 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 | |||
| 179 | // --------------------------------------------------------------------------- | 198 | // --------------------------------------------------------------------------- |
| 180 | // Foreground session — API + MCP + Web UI on one Bun.serve() | 199 | // Foreground session — API + MCP + Web UI on one Bun.serve() |
| 181 | // --------------------------------------------------------------------------- | 200 | // --------------------------------------------------------------------------- |
| @@ -184,36 +203,53 @@ const runForegroundSession = (input: { kind: "web" | "mcp"; port: number }) => · 2 unmodified lines | |||
| 184 | Effect.gen(function* () { | 203 | Effect.gen(function* () { |
| 185 | const handlers = yield* Effect.promise(() => createServerHandlers()); | 204 | const handlers = yield* Effect.promise(() => createServerHandlers()); |
| 186 | 205 | ||
| 187 | const server = Bun.serve({ | 206 | // Bind to loopback only — one server per address family so both |
| 188 | port: input.port, | 207 | // "localhost" (which may resolve to ::1 on Windows) and 127.0.0.1 work, |
| 189 | async fetch(request) { | 208 | // without exposing the server on the LAN. |
| 190 | const url = new URL(request.url); | 209 | const fetch = async (request: Request): Promise<Response> => { |
| 210 | if (!isAllowedHost(request)) { | ||
| 211 | return new Response("Forbidden", { status: 403 }); | ||
| 212 | } | ||
| 191 | 213 | ||
| 192 | if (url.pathname.startsWith("/mcp")) { | 214 | const url = new URL(request.url); |
| 193 | return handlers.mcp.handleRequest(request); | ||
| 194 | } | ||
| 195 | 215 | ||
| 196 | if ( | 216 | if (url.pathname.startsWith("/mcp")) { |
| 197 | url.pathname.startsWith("/v1/") || | 217 | return handlers.mcp.handleRequest(request); |
| 198 | url.pathname.startsWith("/docs") || | 218 | } |
| 199 | url.pathname === "/openapi.json" | 219 | |
| 200 | ) { | 220 | if ( |
| 201 | return handlers.api.handler(request); | 221 | url.pathname.startsWith("/v1/") || |
| 202 | } | 222 | url.pathname.startsWith("/docs") || |
| 223 | url.pathname === "/openapi.json" | ||
| 224 | ) { | ||
| 225 | return handlers.api.handler(request); | ||
| 226 | } | ||
| 203 | 227 | ||
| 204 | const staticResponse = await serveStatic(url.pathname); | 228 | const staticResponse = await serveStatic(url.pathname); |
| 205 | if (staticResponse) return staticResponse; | 229 | if (staticResponse) return staticResponse; |
| 206 | 230 | ||
| 207 | return new Response("Not Found", { status: 404 }); | 231 | return new Response("Not Found", { status: 404 }); |
| 208 | }, | 232 | }; |
| 209 | }); | 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; | ||
| 210 | 245 | ||
| 211 | const baseUrl = `http://localhost:${server.port}`; | 246 | const baseUrl = `http://localhost:${server.port}`; |
| 212 | console.log(renderSessionSummary(input.kind, baseUrl)); | 247 | console.log(renderSessionSummary(input.kind, baseUrl)); |
| 213 | 248 | ||
| 214 | yield* waitForShutdownSignal(); | 249 | yield* waitForShutdownSignal(); |
| 215 | 250 | ||
| 216 | server.stop(true); | 251 | serverV4.stop(true); |
| 252 | serverV6?.stop(true); | ||
| 217 | yield* Effect.promise(() => handlers.mcp.close()); | 253 | yield* Effect.promise(() => handlers.mcp.close()); |
| 218 | yield* Effect.promise(() => handlers.api.dispose()); | 254 | yield* Effect.promise(() => handlers.api.dispose()); |
| 219 | }); | 255 | }); |
| @@ -173,6 +173,9 @@ export default defineConfig({ · 172 unmodified lines | |||
| 173 | }); | 173 | }); |
| 174 | }); | 174 | }); |
| 175 | 175 | ||
| 176 | // Host-header allowlist — blocks DNS rebinding attacks | ||
| 177 | const ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); | ||
| 178 | |||
| 176 | server.middlewares.use(async (req, res, next) => { | 179 | server.middlewares.use(async (req, res, next) => { |
| 177 | const url = req.url ?? "/"; | 180 | const url = req.url ?? "/"; |
| 178 | 181 | ||
| @@ -184,6 +187,17 @@ export default defineConfig({ · 5 unmodified lines | |||
| 184 | 187 | ||
| 185 | if (!isApi && !isMcp) return next(); | 188 | if (!isApi && !isMcp) return next(); |
| 186 | 189 | ||
| 190 | // Validate host header to prevent DNS rebinding | ||
| 191 | const host = req.headers.host; | ||
| 192 | if (host) { | ||
| 193 | const hostname = host.replace(/:\d+$/, ""); | ||
| 194 | if (!ALLOWED_HOSTS.has(hostname)) { | ||
| 195 | res.statusCode = 403; | ||
| 196 | res.end("Forbidden"); | ||
| 197 | return; | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 187 | const backend = await getBackend(); | 201 | const backend = await getBackend(); |
| 188 | const request = await toWebRequest(req); | 202 | const request = await toWebRequest(req); |
| 189 | 203 | ||