allow localhost host to connect to CDP and MCP

This commit is contained in:
Pierre Tachoire authored and Karl Seguin committed 2026-08-26 09:24:01 +08:00
1 parent a0e3da6a6b
commit 78fda6779f
3 files changed
+33 -9

No files matched your search

+14 -2
View File
@@ -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:<port>` 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:<port>` 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" {
+12 -5
View File
@@ -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:<port>`, 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;
}
+7 -2
View File
@@ -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;
}