Skip to content

Commit 99c9d96

Browse files
authored
Merge pull request #74 from RhysSullivan/fix/localhost-binding
Fix localhost server binding for Windows IPv6 compatibility
2 parents a3e5d42 + 17bc5b0 commit 99c9d96

2 files changed

Lines changed: 70 additions & 20 deletions

File tree

‎apps/cli/src/main.ts‎

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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
});

‎apps/web/vite.config.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ export default defineConfig({
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({
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

0 commit comments

Comments
 (0)