History

fix: bind server to loopback only and validate host header

- 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

Rhys Sullivan committed 7 months ago

Commit 17bc5b01 parent aa724292 files · +70 −20Browse files

Unified · Split

2 files
  1. apps/cli/src/main.ts +56 −20
  2. apps/web/vite.config.ts +14 −0
apps/cli/src/main.ts+56−20
@@ -176,6 +176,25 @@ const serveStatic = async (pathname: string): Promise<Response | null> => { · 175 unmodified lines
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 }) => · 2 unmodified lines
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 });
apps/web/vite.config.ts+14−0
@@ -173,6 +173,9 @@ export default defineConfig({ · 172 unmodified lines
173173 });
174174 });
175175
176+ // Host-header allowlist — blocks DNS rebinding attacks
177+ const ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
178+
176179 server.middlewares.use(async (req, res, next) => {
177180 const url = req.url ?? "/";
178181
@@ -184,6 +187,17 @@ export default defineConfig({ · 5 unmodified lines
184187
185188 if (!isApi && !isMcp) return next();
186189
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+
187201 const backend = await getBackend();
188202 const request = await toWebRequest(req);
189203