- 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 | 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 | 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 | 203 | Effect.gen(function* () { |
| 185 | 204 | const handlers = yield* Effect.promise(() => createServerHandlers()); |
| 186 | 205 | |
| 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 | + } | |
| 191 | 213 | |
| 192 | − if (url.pathname.startsWith("/mcp")) { | |
| 193 | − return handlers.mcp.handleRequest(request); | |
| 194 | − } | |
| 214 | + const url = new URL(request.url); | |
| 195 | 215 | |
| 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 | + } | |
| 203 | 227 | |
| 204 | − const staticResponse = await serveStatic(url.pathname); | |
| 205 | − if (staticResponse) return staticResponse; | |
| 228 | + const staticResponse = await serveStatic(url.pathname); | |
| 229 | + if (staticResponse) return staticResponse; | |
| 206 | 230 | |
| 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; | |
| 210 | 245 | |
| 211 | 246 | const baseUrl = `http://localhost:${server.port}`; |
| 212 | 247 | console.log(renderSessionSummary(input.kind, baseUrl)); |
| 213 | 248 | |
| 214 | 249 | yield* waitForShutdownSignal(); |
| 215 | 250 | |
| 216 | − server.stop(true); | |
| 251 | + serverV4.stop(true); | |
| 252 | + serverV6?.stop(true); | |
| 217 | 253 | yield* Effect.promise(() => handlers.mcp.close()); |
| 218 | 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 | 179 | server.middlewares.use(async (req, res, next) => { |
| 177 | 180 | const url = req.url ?? "/"; |
| 178 | 181 | |
| @@ -184,6 +187,17 @@ export default defineConfig({ · 5 unmodified lines | ||
| 184 | 187 | |
| 185 | 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 | 201 | const backend = await getBackend(); |
| 188 | 202 | const request = await toWebRequest(req); |
| 189 | 203 | |