From 78fda6779fbc52e94846064c53afedafe29a40ff Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 25 Aug 2026 15:59:27 +0200 Subject: [PATCH] allow localhost host to connect to CDP and MCP --- src/Server.zig | 16 ++++++++++++++-- src/cdp/Connection.zig | 17 ++++++++++++----- src/mcp/HttpServer.zig | 9 +++++++-- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/Server.zig b/src/Server.zig index c518afa6d..96947b2d9 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -908,8 +908,10 @@ test "Client: http handshake host" { for ([_][]const u8{ "rebind.evil.com:9583", - // Not even the name that resolves to loopback on every machine. - "localhost:9583", + // Only the exact `localhost:` form is allowed. + "localhost", + "LOCALHOST:9583", + "localhost.evil.com:9583", }) |host| { var buf: [256]u8 = undefined; try assertHTTPError( @@ -918,6 +920,16 @@ test "Client: http handshake host" { try std.fmt.bufPrint(&buf, with_host, .{host}), ); } + + // `localhost:` is hardwired to loopback by browsers, no DNS + // lookup involved, so it gets through like an IP literal. + { + var c = try createTestClient(); + defer c.deinit(); + var buf: [256]u8 = undefined; + const res = try c.httpRequest(try std.fmt.bufPrint(&buf, with_host, .{"localhost:9583"})); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 101 Switching Protocols\r\n")); + } } test "Client: http valid handshake" { diff --git a/src/cdp/Connection.zig b/src/cdp/Connection.zig index 2c526cc57..c30835f1b 100644 --- a/src/cdp/Connection.zig +++ b/src/cdp/Connection.zig @@ -495,20 +495,27 @@ pub fn upgrade(self: *Connection, request: []u8) !void { } else if (std.ascii.eqlIgnoreCase(key, "host")) { const host = value; const is_allowed = blk: { + // allow literal localhost + if (std.mem.startsWith(u8, host, "localhost:")) { + break :blk true; + } + _ = std.Io.net.IpAddress.parseLiteral(host) catch break :blk false; break :blk true; }; // Defense in depth against DNS rebinding: an IP literal is the only // thing that can legitimately reach us, because no name has to be - // resolved to produce one. Any name at all - "localhost" included - - // means something answered a DNS lookup with our address, which is - // exactly what a rebinding attack looks like. A request without a - // Host header isn't from a browser, so it can't be the vector. + // resolved to produce one. The one name we accept is + // `localhost:`, which browsers hardwire to loopback without + // any DNS lookup. Any other name means something answered a DNS + // lookup with our address, which is exactly what a rebinding + // attack looks like. A request without a Host header isn't from a + // browser, so it can't be the vector. if (!is_allowed) { log.warn(.cdp, "rejected websocket host", .{ .host = host[0..@min(host.len, 64)], - .hint = "connect to the CDP endpoint by IP address", + .hint = "connect to the CDP endpoint by IP address or localhost", }); return error.ForbiddenHost; } diff --git a/src/mcp/HttpServer.zig b/src/mcp/HttpServer.zig index 4f3f12165..e1394cd4a 100644 --- a/src/mcp/HttpServer.zig +++ b/src/mcp/HttpServer.zig @@ -403,7 +403,7 @@ fn serve(self: *HttpServer, out: *std.Io.Writer, arena: std.mem.Allocator, reque // the head's string memory. const session_id = checkHeaders(arena, request) catch |err| switch (err) { error.ForbiddenOrigin => return request.respond("Origin not allowed\n", .{ .status = .forbidden, .keep_alive = false }), - error.ForbiddenHost => return request.respond("Host not allowed, connect to the MCP endpoint by IP address\n", .{ .status = .forbidden, .keep_alive = false }), + error.ForbiddenHost => return request.respond("Host not allowed, connect to the MCP endpoint by IP address or localhost\n", .{ .status = .forbidden, .keep_alive = false }), else => return err, }; const keep_alive = request.head.keep_alive; @@ -457,6 +457,11 @@ fn checkHeaders(arena: std.mem.Allocator, request: *std.http.Server.Request) !?[ return error.ForbiddenOrigin; } else if (std.ascii.eqlIgnoreCase(key, "host")) { const is_allowed = blk: { + // allow literal localhost + if (std.mem.startsWith(u8, value, "localhost:")) { + break :blk true; + } + _ = std.Io.net.IpAddress.parseLiteral(value) catch break :blk false; break :blk true; }; @@ -464,7 +469,7 @@ fn checkHeaders(arena: std.mem.Allocator, request: *std.http.Server.Request) !?[ if (!is_allowed) { log.warn(.mcp, "rejected request host", .{ .host = value[0..@min(value.len, 64)], - .hint = "connect to the MCP endpoint by IP address", + .hint = "connect to the MCP endpoint by IP address or localhost", }); return error.ForbiddenHost; }