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
176 return null;176 return null;
177};177};
178 178
179// ---------------------------------------------------------------------------
180// Host-header allowlist — blocks DNS rebinding attacks
181// ---------------------------------------------------------------------------
182
183const ALLOWED_HOSTS = new Set([
184 "localhost",
185 "127.0.0.1",
186 "[::1]",
187 "::1",
188]);
189
190const 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 });
apps/web/vite.config.ts+14−0
@@ -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