diff --git a/src/Config.zig b/src/Config.zig index 483c320f..2acca63c 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -212,6 +212,20 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { }; } +pub fn blockPrivateNetworks(self: *const Config) bool { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| opts.common.block_private_networks, + else => unreachable, + }; +} + +pub fn blockCidrs(self: *const Config) ?[]const u8 { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| opts.common.block_cidrs, + else => unreachable, + }; +} + pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, @@ -300,6 +314,9 @@ pub const Common = struct { web_bot_auth_key_file: ?[]const u8 = null, web_bot_auth_keyid: ?[]const u8 = null, web_bot_auth_domain: ?[]const u8 = null, + + block_private_networks: bool = false, + block_cidrs: ?[]const u8 = null, }; /// Pre-formatted HTTP headers for reuse across Http and Client. @@ -362,6 +379,21 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ we make requests towards. \\ Defaults to false. \\ + \\--block-private-networks + \\ Blocks HTTP requests to private/internal IP addresses + \\ after DNS resolution. Useful for sandboxing, multi-tenant + \\ deployments, and preventing access to internal infrastructure + \\ regardless of what triggers the request (JavaScript, HTML + \\ resources, redirects, etc.). + \\ Defaults to false. + \\ + \\--block-cidrs + \\ Additional CIDR ranges to block, comma-separated. + \\ Prefix with '-' to allow (exempt from blocking). + \\ e.g. --block-cidrs 169.254.169.254/32,fd00:ec2::254/128 + \\ e.g. --block-cidrs 10.0.0.0/8,-10.0.0.42/32 + \\ Can be used standalone or combined with --block-private-networks. + \\ \\--http-proxy The HTTP proxy to use for all HTTP requests. \\ A username:password can be included for basic authentication. \\ Defaults to none. @@ -1145,5 +1177,19 @@ fn parseCommonArg( return true; } + if (std.mem.eql(u8, "--block-private-networks", opt)) { + common.block_private_networks = true; + return true; + } + + if (std.mem.eql(u8, "--block-cidrs", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--block-cidrs" }); + return error.InvalidArgument; + }; + common.block_cidrs = try allocator.dupe(u8, str); + return true; + } + return false; } diff --git a/src/network/IpFilter.zig b/src/network/IpFilter.zig new file mode 100644 index 00000000..73977188 --- /dev/null +++ b/src/network/IpFilter.zig @@ -0,0 +1,624 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const posix = std.posix; +const libcurl = @import("../sys/libcurl.zig"); + +const IpFilter = @This(); + +/// Binary representation for bitwise CIDR comparison. +pub const Ipv4Addr = [4]u8; +pub const Ipv6Addr = [16]u8; + +pub const CidrV4 = struct { + network: u32, + mask: u32, + + fn fromPrefix(addr: Ipv4Addr, prefix_len: u6) CidrV4 { + const network = std.mem.readInt(u32, &addr, .big); + const mask: u32 = if (prefix_len == 0) + 0 + else if (prefix_len == 32) + 0xFFFFFFFF + else + ~(@as(u32, 0xFFFFFFFF) >> @intCast(prefix_len)); + return .{ .network = network, .mask = mask }; + } +}; + +pub const CidrV6 = struct { + network_hi: u64, + network_lo: u64, + mask_hi: u64, + mask_lo: u64, + + fn fromPrefix(addr: Ipv6Addr, prefix_len: u8) CidrV6 { + const network_hi = std.mem.readInt(u64, addr[0..8], .big); + const network_lo = std.mem.readInt(u64, addr[8..16], .big); + var mask_hi: u64 = 0; + var mask_lo: u64 = 0; + if (prefix_len > 0) { + if (prefix_len < 64) { + mask_hi = ~(@as(u64, 0xFFFFFFFFFFFFFFFF) >> @intCast(prefix_len)); + } else if (prefix_len == 64) { + mask_hi = 0xFFFFFFFFFFFFFFFF; + } else if (prefix_len < 128) { + mask_hi = 0xFFFFFFFFFFFFFFFF; + mask_lo = ~(@as(u64, 0xFFFFFFFFFFFFFFFF) >> @intCast(prefix_len - 64)); + } else { + // prefix_len == 128 + mask_hi = 0xFFFFFFFFFFFFFFFF; + mask_lo = 0xFFFFFFFFFFFFFFFF; + } + } + return .{ .network_hi = network_hi, .network_lo = network_lo, .mask_hi = mask_hi, .mask_lo = mask_lo }; + } +}; + +// IpFilter fields +block_private: bool, +cidrs: ?Cidrs, + +// ── Comptime helpers ───────────────────────────────────────────────────────── + +/// Comptime helper: parse dotted-decimal IPv4 to [4]u8. +fn parseIpv4Comptime(comptime s: []const u8) Ipv4Addr { + var result: Ipv4Addr = undefined; + var octet: u8 = 0; + var octet_idx: usize = 0; + for (s) |ch| { + if (ch == '.') { + result[octet_idx] = octet; + octet_idx += 1; + octet = 0; + } else { + octet = octet * 10 + (ch - '0'); + } + } + result[octet_idx] = octet; + return result; +} + +/// Comptime helper: build a CidrV4. +fn makeCidrV4(comptime addr: []const u8, comptime prefix: u6) CidrV4 { + return CidrV4.fromPrefix(parseIpv4Comptime(addr), prefix); +} + +/// Comptime helper: build a CidrV6 from a 16-byte literal array. +fn makeCidrV6(comptime bytes: Ipv6Addr, comptime prefix: u8) CidrV6 { + return CidrV6.fromPrefix(bytes, prefix); +} + +// ── Comptime CIDR range tables ─────────────────────────────────────────────── + +const PRIVATE_V4 = [_]CidrV4{ + makeCidrV4("127.0.0.0", 8), // localhost + makeCidrV4("0.0.0.0", 8), // current network + makeCidrV4("10.0.0.0", 8), // RFC1918 + makeCidrV4("172.16.0.0", 12), // RFC1918 + makeCidrV4("192.168.0.0", 16), // RFC1918 + makeCidrV4("169.254.0.0", 16), // link-local +}; + +const PRIVATE_V6 = [_]CidrV6{ + // ::/128 — IPv6 Unspecified + makeCidrV6(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 128), + // ::1/128 — IPv6 localhost + makeCidrV6(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, 128), + // fe80::/10 — link-local + makeCidrV6(.{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 10), + // fc00::/7 — ULA + makeCidrV6(.{ 0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 7), +}; + +// ── Runtime IP parsing ─────────────────────────────────────────────────────── + +/// Parse dotted-decimal IPv4 string to 4-byte array. Returns null on parse failure. +fn parseIpv4(str: []const u8) ?Ipv4Addr { + var addr: Ipv4Addr = undefined; + var it = std.mem.splitScalar(u8, str, '.'); + var i: usize = 0; + while (it.next()) |part| : (i += 1) { + if (i >= 4) return null; + addr[i] = std.fmt.parseInt(u8, part, 10) catch return null; + } + if (i != 4) return null; + return addr; +} + +/// Parse IPv6 string to 16-byte array. Handles compressed notation. +/// Strips zone ID (e.g. "fe80::1%eth0" -> "fe80::1"). +/// Returns null on parse failure. +fn parseIpv6(str: []const u8) ?Ipv6Addr { + // Strip zone ID + const clean = if (std.mem.indexOfScalar(u8, str, '%')) |idx| str[0..idx] else str; + const parsed = std.net.Address.parseIp6(clean, 0) catch return null; + return parsed.in6.sa.addr; +} + +// ── CIDR matching ──────────────────────────────────────────────────────────── + +/// Detect IPv4-mapped IPv6 address (::ffff:x.x.x.x). +/// Returns the embedded IPv4 address if detected, null otherwise. +fn isIpv4Mapped(addr: Ipv6Addr) ?Ipv4Addr { + // IPv4-mapped prefix: 10 zero bytes + 2 0xFF bytes + const prefix = [12]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff }; + if (!std.mem.eql(u8, addr[0..12], &prefix)) return null; + return addr[12..16].*; +} + +/// Check if IPv4 address falls within a CIDR range. +fn matchesCidrV4(addr: Ipv4Addr, cidr: CidrV4) bool { + const addr_int = std.mem.readInt(u32, &addr, .big); + return (addr_int ^ cidr.network) & cidr.mask == 0; +} + +/// Check if IPv6 address falls within a CIDR range. +fn matchesCidrV6(addr: Ipv6Addr, cidr: CidrV6) bool { + const addr_hi = std.mem.readInt(u64, addr[0..8], .big); + const addr_lo = std.mem.readInt(u64, addr[8..16], .big); + return ((addr_hi ^ cidr.network_hi) & cidr.mask_hi == 0) and + ((addr_lo ^ cidr.network_lo) & cidr.mask_lo == 0); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +pub const Cidrs = struct { + v4: []CidrV4, + v6: []CidrV6, + allow_v4: []CidrV4, + allow_v6: []CidrV6, + + pub fn deinit(self: Cidrs, allocator: std.mem.Allocator) void { + allocator.free(self.v4); + allocator.free(self.v6); + allocator.free(self.allow_v4); + allocator.free(self.allow_v6); + } +}; + +/// Parse a comma-separated list of CIDR strings (e.g. "10.0.0.0/8,2001:db8::/32") +/// into a Cidrs struct. Entries prefixed with '-' are added to the allow list +/// (e.g. "-10.0.0.42/32" exempts that IP from blocking). +/// Caller owns the returned Cidrs and must free them via Cidrs.deinit. +/// Returns error.InvalidCidr on any malformed entry. +pub fn parseCidrList( + allocator: std.mem.Allocator, + cidr_str: []const u8, +) !Cidrs { + var v4_list: std.ArrayList(CidrV4) = .empty; + errdefer v4_list.deinit(allocator); + var v6_list: std.ArrayList(CidrV6) = .empty; + errdefer v6_list.deinit(allocator); + var allow_v4_list: std.ArrayList(CidrV4) = .empty; + errdefer allow_v4_list.deinit(allocator); + var allow_v6_list: std.ArrayList(CidrV6) = .empty; + errdefer allow_v6_list.deinit(allocator); + + var it = std.mem.splitScalar(u8, cidr_str, ','); + while (it.next()) |entry| { + const trimmed = std.mem.trim(u8, entry, " \t"); + if (trimmed.len == 0) continue; + + const is_allow = trimmed[0] == '-'; + const cidr_part = if (is_allow) trimmed[1..] else trimmed; + + const slash = std.mem.indexOfScalar(u8, cidr_part, '/') orelse return error.InvalidCidr; + const addr_str = cidr_part[0..slash]; + const prefix_str = cidr_part[slash + 1 ..]; + + if (parseIpv4(addr_str)) |v4| { + const prefix = std.fmt.parseInt(u8, prefix_str, 10) catch return error.InvalidCidr; + if (prefix > 32) return error.InvalidCidr; + const cidr = CidrV4.fromPrefix(v4, @intCast(prefix)); + if (is_allow) { + try allow_v4_list.append(allocator, cidr); + } else { + try v4_list.append(allocator, cidr); + } + } else if (parseIpv6(addr_str)) |v6| { + const prefix = std.fmt.parseInt(u8, prefix_str, 10) catch return error.InvalidCidr; + if (prefix > 128) return error.InvalidCidr; + const cidr = CidrV6.fromPrefix(v6, prefix); + if (is_allow) { + try allow_v6_list.append(allocator, cidr); + } else { + try v6_list.append(allocator, cidr); + } + } else { + return error.InvalidCidr; + } + } + + const v4 = try v4_list.toOwnedSlice(allocator); + errdefer allocator.free(v4); + const v6 = try v6_list.toOwnedSlice(allocator); + errdefer allocator.free(v6); + const allow_v4 = try allow_v4_list.toOwnedSlice(allocator); + errdefer allocator.free(allow_v4); + const allow_v6 = try allow_v6_list.toOwnedSlice(allocator); + return .{ .v4 = v4, .v6 = v6, .allow_v4 = allow_v4, .allow_v6 = allow_v6 }; +} + +// Create a IpFilter. Set block_private to block outbound requests to RFC1918, +// localhost, link-local, and ULA ranges. Pass parsed CIDRs for additional +// custom block/allow ranges; the filter takes ownership of the Cidrs and will +// free them on deinit. +pub fn init( + block_private: bool, + cidrs: ?Cidrs, +) IpFilter { + return .{ + .block_private = block_private, + .cidrs = cidrs, + }; +} + +pub fn deinit(self: IpFilter, allocator: std.mem.Allocator) void { + if (self.cidrs) |c| { + c.deinit(allocator); + } +} + +fn isBlockedV4(self: *const IpFilter, addr: Ipv4Addr) bool { + if (self.cidrs) |c| { + for (c.allow_v4) |cidr| { + if (matchesCidrV4(addr, cidr)) { + return false; + } + } + for (c.v4) |cidr| { + if (matchesCidrV4(addr, cidr)) { + return true; + } + } + } + + if (self.block_private) { + for (PRIVATE_V4) |cidr| { + if (matchesCidrV4(addr, cidr)) { + return true; + } + } + } + + return false; +} + +fn isBlockedV6(self: *const IpFilter, addr: Ipv6Addr) bool { + if (self.cidrs) |c| { + for (c.allow_v6) |cidr| { + if (matchesCidrV6(addr, cidr)) { + return false; + } + } + for (c.v6) |cidr| { + if (matchesCidrV6(addr, cidr)) { + return true; + } + } + } + + if (self.block_private) { + for (PRIVATE_V6) |cidr| { + if (matchesCidrV6(addr, cidr)) { + return true; + } + } + } + return false; +} + +/// Check if an address from curl's opensocket callback should be blocked. +/// Extracts the IP directly from the sockaddr structure; no string parsing needed. +/// Fail-closed: unknown address family -> true (blocked). +pub fn isBlockedSockaddr(self: *const IpFilter, sa: *const libcurl.CurlSockAddr) bool { + switch (sa.family) { + posix.AF.INET => { + const sin: *const posix.sockaddr.in = @ptrCast(&sa.addr); + // sin.addr is in network byte order (big-endian); convert to host bytes + const bytes: [4]u8 = @bitCast(sin.addr); + return self.isBlockedV4(bytes); + }, + posix.AF.INET6 => { + const sin6: *const posix.sockaddr.in6 = @ptrCast(&sa.addr); + const addr: Ipv6Addr = sin6.addr; + if (isIpv4Mapped(addr)) |v4| return self.isBlockedV4(v4); + return self.isBlockedV6(addr); + }, + else => return true, // unknown family -> fail-closed + } +} + +const testing = @import("../testing.zig"); +test "IpFilter: IPv4 CIDR matching: private group boundaries" { + const filter = IpFilter.init(true, null); + defer filter.deinit(testing.allocator); + + try testing.expect(filter.testBlocked("0.0.0.0")); + + // Loopback + try testing.expect(filter.testBlocked("127.0.0.1")); + try testing.expect(filter.testBlocked("127.255.255.255")); + try testing.expect(!filter.testBlocked("128.0.0.1")); + + // RFC1918 10.0.0.0/8 + try testing.expect(filter.testBlocked("10.0.0.1")); + try testing.expect(filter.testBlocked("10.255.255.255")); + try testing.expect(!filter.testBlocked("11.0.0.0")); + + // RFC1918 172.16.0.0/12 — critical boundary + try testing.expect(!filter.testBlocked("172.15.255.255")); // MUST NOT block + try testing.expect(filter.testBlocked("172.16.0.0")); // MUST block + try testing.expect(filter.testBlocked("172.31.255.255")); // MUST block + try testing.expect(!filter.testBlocked("172.32.0.0")); // MUST NOT block + + // RFC1918 192.168.0.0/16 + try testing.expect(filter.testBlocked("192.168.0.1")); + try testing.expect(!filter.testBlocked("192.169.0.0")); + + // Link-local + try testing.expect(filter.testBlocked("169.254.1.1")); + try testing.expect(!filter.testBlocked("169.255.0.0")); + + // Public IP — must NOT be blocked + try testing.expect(!filter.testBlocked("8.8.8.8")); + try testing.expect(!filter.testBlocked("1.1.1.1")); + try testing.expect(!filter.testBlocked("93.184.216.34")); // example.com +} + +test "IpFilter: IPv6 CIDR matching: private group" { + const filter = IpFilter.init(true, null); + defer filter.deinit(testing.allocator); + + try testing.expect(filter.testBlocked("::")); // unspecified + try testing.expect(filter.testBlocked("::1")); // localhost + try testing.expect(filter.testBlocked("fe80::1")); // link-local + try testing.expect(filter.testBlocked("fc00::1")); // ULA + try testing.expect(filter.testBlocked("fd00::1")); // ULA (fd is fc00::/7) + try testing.expect(!filter.testBlocked("2001:db8::1")); // documentation range — public + try testing.expect(!filter.testBlocked("2606:4700::1111")); // Cloudflare +} + +test "IpFilter: IPv4-mapped IPv6 bypass prevention" { + const filter = IpFilter.init(true, null); + defer filter.deinit(testing.allocator); + + // ::ffff:127.0.0.1 must be blocked (maps to loopback) + try testing.expect(filter.testBlocked("::ffff:127.0.0.1")); + // ::ffff:10.0.0.1 must be blocked (maps to RFC1918) + try testing.expect(filter.testBlocked("::ffff:10.0.0.1")); + // ::ffff:8.8.8.8 must NOT be blocked (maps to public) + try testing.expect(!filter.testBlocked("::ffff:8.8.8.8")); +} + +test "IpFilter: fail-closed: unknown address family blocked by isBlockedSockaddr" { + const filter = IpFilter.init(false, null); + defer filter.deinit(testing.allocator); + + // Construct a sockaddr with an unknown address family + var sa: libcurl.CurlSockAddr = .{ + .family = 255, // not AF_INET or AF_INET6 + .socktype = posix.SOCK.STREAM, + .protocol = 0, + .addrlen = 0, + .addr = undefined, + }; + try testing.expect(filter.isBlockedSockaddr(&sa)); +} + +test "IpFilter: custom CIDR ranges" { + const cidrs = try parseCidrList(testing.allocator, "203.0.113.0/24"); + const filter = IpFilter.init(false, cidrs); + defer filter.deinit(testing.allocator); + + try testing.expect(filter.testBlocked("203.0.113.1")); // in custom range + try testing.expect(filter.testBlocked("203.0.113.255")); // in custom range + try testing.expect(!filter.testBlocked("203.0.114.0")); // outside custom range + try testing.expect(!filter.testBlocked("8.8.8.8")); // not in range +} + +test "IpFilter: private group blocks cloud metadata IP via link-local" { + // 169.254.169.254 is in link-local (169.254.0.0/16) which is in the private group. + // Users who want targeted cloud-metadata-only blocking can use --block-cidrs. + const filter_private = IpFilter.init(true, null); + defer filter_private.deinit(testing.allocator); + const filter_none = IpFilter.init(false, null); + defer filter_none.deinit(testing.allocator); + + try testing.expect(filter_private.testBlocked("169.254.169.254")); // blocked via link-local + try testing.expect(!filter_none.testBlocked("169.254.169.254")); // not blocked when disabled +} + +test "IpFilter: parseCidrList: mixed IPv4 and IPv6" { + const cidrs = try parseCidrList(testing.allocator, "203.0.113.0/24, 2001:db8::/32, 192.168.1.0/24"); + + try testing.expectEqual(2, cidrs.v4.len); + try testing.expectEqual(1, cidrs.v6.len); + + // spot-check: 203.0.113.0/24 and 192.168.1.0/24 + const f = IpFilter.init(false, cidrs); + defer f.deinit(testing.allocator); + try testing.expect(f.testBlocked("203.0.113.1")); + try testing.expect(!f.testBlocked("203.0.114.0")); + try testing.expect(f.testBlocked("192.168.1.1")); + try testing.expect(f.testBlocked("2001:db8::1")); + try testing.expect(!f.testBlocked("2001:db9::1")); +} + +test "IpFilter: allow list exempts from private blocking" { + const cidrs = try parseCidrList(testing.allocator, "-10.0.0.42/32,-fc00::1/128"); + const filter = IpFilter.init(true, cidrs); + defer filter.deinit(testing.allocator); + + // Allowed IPs pass through despite being in private ranges + try testing.expect(!filter.testBlocked("10.0.0.42")); + try testing.expect(!filter.testBlocked("fc00::1")); + + // Other private IPs still blocked + try testing.expect(filter.testBlocked("10.0.0.43")); + try testing.expect(filter.testBlocked("10.0.0.41")); + try testing.expect(filter.testBlocked("192.168.1.1")); + try testing.expect(filter.testBlocked("fc00::2")); +} + +test "IpFilter: allow list exempts from custom CIDR blocking" { + const cidrs = try parseCidrList(testing.allocator, "203.0.113.0/24,-203.0.113.100/32"); + const filter = IpFilter.init(false, cidrs); + defer filter.deinit(testing.allocator); + + try testing.expect(!filter.testBlocked("203.0.113.100")); // allowed + try testing.expect(filter.testBlocked("203.0.113.99")); // blocked + try testing.expect(filter.testBlocked("203.0.113.101")); // blocked +} + +test "IpFilter: parseCidrList: allow entries with '-' prefix" { + const cidrs = try parseCidrList(testing.allocator, "10.0.0.0/8,-10.0.0.42/32,-fc00::1/128"); + + try testing.expectEqual(1, cidrs.v4.len); + try testing.expectEqual(0, cidrs.v6.len); + try testing.expectEqual(1, cidrs.allow_v4.len); + try testing.expectEqual(1, cidrs.allow_v6.len); + + const f = IpFilter.init(false, cidrs); + defer f.deinit(testing.allocator); + try testing.expect(!f.testBlocked("10.0.0.42")); // allowed + try testing.expect(f.testBlocked("10.0.0.43")); // blocked + try testing.expect(!f.testBlocked("fc00::1")); // allowed (not blocked by custom, but allow-listed) +} + +test "IpFilter: parseCidrList: invalid input returns error" { + try testing.expectError(error.InvalidCidr, parseCidrList(testing.allocator, "not-a-cidr")); + try testing.expectError(error.InvalidCidr, parseCidrList(testing.allocator, "10.0.0.0/33")); // prefix too large + try testing.expectError(error.InvalidCidr, parseCidrList(testing.allocator, "10.0.0.0")); // missing prefix + try testing.expectError(error.InvalidCidr, parseCidrList(testing.allocator, "10.0.0.0/abc")); // non-numeric prefix +} + +test "IpFilter: matchesCidrV4: exact match /32" { + const cidr = CidrV4.fromPrefix(.{ 192, 168, 1, 100 }, 32); + try testing.expect(matchesCidrV4(.{ 192, 168, 1, 100 }, cidr)); + try testing.expect(!matchesCidrV4(.{ 192, 168, 1, 101 }, cidr)); + try testing.expect(!matchesCidrV4(.{ 192, 168, 1, 99 }, cidr)); +} + +test "IpFilter: matchesCidrV4: /0 matches everything" { + const cidr = CidrV4.fromPrefix(.{ 0, 0, 0, 0 }, 0); + try testing.expect(matchesCidrV4(.{ 0, 0, 0, 0 }, cidr)); + try testing.expect(matchesCidrV4(.{ 255, 255, 255, 255 }, cidr)); + try testing.expect(matchesCidrV4(.{ 192, 168, 1, 1 }, cidr)); +} + +test "IpFilter: matchesCidrV4: /8 boundary" { + const cidr = CidrV4.fromPrefix(.{ 10, 0, 0, 0 }, 8); + try testing.expect(matchesCidrV4(.{ 10, 0, 0, 0 }, cidr)); + try testing.expect(matchesCidrV4(.{ 10, 255, 255, 255 }, cidr)); + try testing.expect(!matchesCidrV4(.{ 11, 0, 0, 0 }, cidr)); + try testing.expect(!matchesCidrV4(.{ 9, 255, 255, 255 }, cidr)); +} + +test "IpFilter: matchesCidrV4: /12 boundary (172.16.0.0/12)" { + const cidr = CidrV4.fromPrefix(.{ 172, 16, 0, 0 }, 12); + // In range + try testing.expect(matchesCidrV4(.{ 172, 16, 0, 0 }, cidr)); + try testing.expect(matchesCidrV4(.{ 172, 31, 255, 255 }, cidr)); + try testing.expect(matchesCidrV4(.{ 172, 20, 100, 50 }, cidr)); + // Out of range + try testing.expect(!matchesCidrV4(.{ 172, 15, 255, 255 }, cidr)); + try testing.expect(!matchesCidrV4(.{ 172, 32, 0, 0 }, cidr)); +} + +test "IpFilter: matchesCidrV4: /24 network" { + const cidr = CidrV4.fromPrefix(.{ 203, 0, 113, 0 }, 24); + try testing.expect(matchesCidrV4(.{ 203, 0, 113, 0 }, cidr)); + try testing.expect(matchesCidrV4(.{ 203, 0, 113, 255 }, cidr)); + try testing.expect(!matchesCidrV4(.{ 203, 0, 112, 255 }, cidr)); + try testing.expect(!matchesCidrV4(.{ 203, 0, 114, 0 }, cidr)); +} + +test "IpFilter: matchesCidrV4: non-byte-aligned /25" { + const cidr = CidrV4.fromPrefix(.{ 192, 168, 1, 0 }, 25); + // 192.168.1.0 - 192.168.1.127 should match + try testing.expect(matchesCidrV4(.{ 192, 168, 1, 0 }, cidr)); + try testing.expect(matchesCidrV4(.{ 192, 168, 1, 127 }, cidr)); + // 192.168.1.128+ should not match + try testing.expect(!matchesCidrV4(.{ 192, 168, 1, 128 }, cidr)); + try testing.expect(!matchesCidrV4(.{ 192, 168, 1, 255 }, cidr)); +} + +test "IpFilter: matchesCidrV6: /128 exact match" { + const addr: Ipv6Addr = .{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; + const cidr = CidrV6.fromPrefix(addr, 128); + try testing.expect(matchesCidrV6(addr, cidr)); + + var different = addr; + different[15] = 2; + try testing.expect(!matchesCidrV6(different, cidr)); +} + +test "IpFilter: matchesCidrV6: /0 matches everything" { + const cidr = CidrV6.fromPrefix(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0); + try testing.expect(matchesCidrV6(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, cidr)); + try testing.expect(matchesCidrV6(.{ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }, cidr)); +} + +test "IpFilter: matchesCidrV6: /64 boundary" { + // 2001:db8::/64 + const cidr = CidrV6.fromPrefix(.{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 64); + // In range - any suffix in lower 64 bits + try testing.expect(matchesCidrV6(.{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, cidr)); + try testing.expect(matchesCidrV6(.{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }, cidr)); + // Out of range - different prefix + try testing.expect(!matchesCidrV6(.{ 0x20, 0x01, 0x0d, 0xb9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, cidr)); +} + +test "IpFilter: matchesCidrV6: /48 network" { + // 2001:db8:abcd::/48 + const cidr = CidrV6.fromPrefix(.{ 0x20, 0x01, 0x0d, 0xb8, 0xab, 0xcd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 48); + try testing.expect(matchesCidrV6(.{ 0x20, 0x01, 0x0d, 0xb8, 0xab, 0xcd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, cidr)); + try testing.expect(matchesCidrV6(.{ 0x20, 0x01, 0x0d, 0xb8, 0xab, 0xcd, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }, cidr)); + try testing.expect(!matchesCidrV6(.{ 0x20, 0x01, 0x0d, 0xb8, 0xab, 0xce, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, cidr)); +} + +test "IpFilter: matchesCidrV6: /10 link-local (fe80::/10)" { + const cidr = CidrV6.fromPrefix(.{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 10); + // fe80:: through febf:: should match (first 10 bits: 1111111010) + try testing.expect(matchesCidrV6(.{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, cidr)); + try testing.expect(matchesCidrV6(.{ 0xfe, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }, cidr)); + // fec0:: should NOT match (11th bit differs) + try testing.expect(!matchesCidrV6(.{ 0xfe, 0xc0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, cidr)); +} + +test "IpFilter: matchesCidrV6: prefix > 64 bits (/96)" { + // ::ffff:0:0/96 (IPv4-mapped prefix) + const cidr = CidrV6.fromPrefix(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0, 0, 0 }, 96); + try testing.expect(matchesCidrV6(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1 }, cidr)); + try testing.expect(matchesCidrV6(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 10, 0, 0, 1 }, cidr)); + try testing.expect(!matchesCidrV6(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xfe, 192, 168, 1, 1 }, cidr)); +} + +/// Test-only convenience: parse an IP string and check against the filter. +/// Test inputs must be valid IPs; unreachable on parse failure. +fn testBlocked(self: *const IpFilter, ip: []const u8) bool { + if (parseIpv4(ip)) |v4| return self.isBlockedV4(v4); + if (parseIpv6(ip)) |v6| { + if (isIpv4Mapped(v6)) |v4| return self.isBlockedV4(v4); + return self.isBlockedV6(v6); + } + unreachable; +} diff --git a/src/network/Network.zig b/src/network/Network.zig index 1fb8c8fb..359646ce 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -28,6 +28,7 @@ const Config = @import("../Config.zig"); const libcurl = @import("../sys/libcurl.zig"); const http = @import("http.zig"); +const IpFilter = @import("IpFilter.zig"); const RobotStore = @import("Robots.zig").RobotStore; const WebBotAuth = @import("WebBotAuth.zig"); @@ -85,6 +86,9 @@ callbacks: [MAX_TICK_CALLBACKS]TickCallback = undefined, callbacks_len: usize = 0, callbacks_mutex: std.Thread.Mutex = .{}, +/// Optional IP filter for blocking requests to private/internal networks (--block-private-networks). +ip_filter: ?*IpFilter = null, + const TickCallback = struct { ctx: *anyopaque, fun: *const fn (*anyopaque) void, @@ -230,13 +234,31 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { ca_blob = try loadCerts(allocator); } + // IP filter for blocking requests to private/internal networks. + const block_private = config.blockPrivateNetworks(); + const cidrs: ?IpFilter.Cidrs = blk: { + const s = config.blockCidrs() orelse break :blk null; + break :blk try IpFilter.parseCidrList(allocator, s); + }; + const has_cidrs = if (cidrs) |c| c.v4.len > 0 or c.v6.len > 0 or c.allow_v4.len > 0 or c.allow_v6.len > 0 else false; + const ip_filter: ?*IpFilter = blk: { + if (!block_private and !has_cidrs) break :blk null; + const f = try allocator.create(IpFilter); + f.* = IpFilter.init(block_private, cidrs); + break :blk f; + }; + errdefer if (ip_filter) |f| { + f.deinit(allocator); + allocator.destroy(f); + }; + const count: usize = config.httpMaxConcurrent(); const connections = try allocator.alloc(http.Connection, count); errdefer allocator.free(connections); var available: std.DoublyLinkedList = .{}; for (0..count) |i| { - connections[i] = try http.Connection.init(ca_blob, config); + connections[i] = try http.Connection.init(ca_blob, config, ip_filter); available.append(&connections[i].node); } @@ -280,6 +302,8 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { .ws_pool = .init(allocator), .ws_max = config.wsMaxConcurrent(), + + .ip_filter = ip_filter, }; } @@ -316,6 +340,11 @@ pub fn deinit(self: *Network) void { if (self.cache) |*cache| cache.deinit(); + if (self.ip_filter) |f| { + f.deinit(self.allocator); + self.allocator.destroy(f); + } + globalDeinit(); } @@ -612,7 +641,7 @@ pub fn releaseConnection(self: *Network, conn: *http.Connection) void { self.ws_count -= 1; }, else => { - conn.reset(self.config, self.ca_blob) catch |err| { + conn.reset(self.config, self.ca_blob, self.ip_filter) catch |err| { lp.assert(false, "couldn't reset curl easy", .{ .err = err }); }; self.conn_mutex.lock(); @@ -637,7 +666,7 @@ pub fn newConnection(self: *Network) ?*http.Connection { }; // don't do this under lock - conn.* = http.Connection.init(self.ca_blob, self.config) catch { + conn.* = http.Connection.init(self.ca_blob, self.config, self.ip_filter) catch { self.ws_mutex.lock(); defer self.ws_mutex.unlock(); self.ws_pool.destroy(conn); diff --git a/src/network/http.zig b/src/network/http.zig index ec49b60f..7f586b1d 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -17,9 +17,11 @@ // along with this program. If not, see . const std = @import("std"); +const posix = std.posix; const Config = @import("../Config.zig"); const libcurl = @import("../sys/libcurl.zig"); +const IpFilter = @import("IpFilter.zig"); const log = @import("lightpanda").log; const assert = @import("lightpanda").assert; @@ -229,6 +231,35 @@ pub const ResponseHead = struct { } }; +/// Opensocket callback: blocks connections to private/internal IP ranges +/// before TCP SYN, regardless of request origin (JS, HTML resources, redirects, etc.). +/// Called by curl after DNS resolution, before the socket is created. +/// Returns CURL_SOCKET_BAD to block; otherwise creates and returns a real socket fd. +/// clientp is a *const IpFilter passed via CURLOPT_OPENSOCKETDATA. +fn opensocketCallback( + purpose: libcurl.CurlSockType, + address: *libcurl.CurlSockAddr, + clientp: ?*anyopaque, +) libcurl.CurlSocket { + const filter: *const IpFilter = @ptrCast(@alignCast(clientp orelse return libcurl.CURL_SOCKET_BAD)); + if (filter.isBlockedSockaddr(address)) { + if (address.family == posix.AF.INET or address.family == posix.AF.INET6) { + const ip = std.net.Address.initPosix(@ptrCast(&address.addr)); + log.warn(.http, "blocked by IP filter", .{ .ip = ip }); + } else { + log.warn(.http, "blocked by IP filter", .{ .family = address.family }); + } + return libcurl.CURL_SOCKET_BAD; + } + _ = purpose; // purpose is informational; we always open the same socket type + const fd = posix.socket( + @intCast(address.family), + @intCast(address.socktype), + @intCast(address.protocol), + ) catch return libcurl.CURL_SOCKET_BAD; + return fd; +} + pub const Connection = struct { _easy: *libcurl.Curl, transport: Transport, @@ -240,13 +271,17 @@ pub const Connection = struct { websocket: *@import("../browser/webapi/net/WebSocket.zig"), }; - pub fn init(ca_blob: ?libcurl.CurlBlob, config: *const Config) !Connection { + pub fn init( + ca_blob: ?libcurl.CurlBlob, + config: *const Config, + ip_filter: ?*const IpFilter, + ) !Connection { const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; var self = Connection{ ._easy = easy, .transport = .none }; errdefer self.deinit(); - try self.reset(config, ca_blob); + try self.reset(config, ca_blob, ip_filter); return self; } @@ -371,6 +406,7 @@ pub const Connection = struct { self: *Connection, config: *const Config, ca_blob: ?libcurl.CurlBlob, + ip_filter: ?*const IpFilter, ) !void { libcurl.curl_easy_reset(self._easy); self.transport = .none; @@ -421,6 +457,12 @@ pub const Connection = struct { // try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback); } + + // IP filter: block private/internal network addresses + if (ip_filter) |filter| { + try libcurl.curl_easy_setopt(self._easy, .opensocket_function, opensocketCallback); + try libcurl.curl_easy_setopt(self._easy, .opensocket_data, @constCast(filter)); + } } fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) usize { @@ -603,3 +645,53 @@ fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, } return 0; } + +// ── Unit tests for opensocketCallback ──────────────────────────────────────── + +fn makeSockAddrV4(ip: [4]u8) libcurl.CurlSockAddr { + var sa: posix.sockaddr.in = .{ + .port = 0, + .addr = @bitCast(ip), + }; + var curl_sa: libcurl.CurlSockAddr = .{ + .family = posix.AF.INET, + .socktype = posix.SOCK.STREAM, + .protocol = 0, + .addrlen = @sizeOf(posix.sockaddr.in), + .addr = undefined, + }; + @memcpy(std.mem.asBytes(&curl_sa.addr)[0..@sizeOf(posix.sockaddr.in)], std.mem.asBytes(&sa)); + return curl_sa; +} + +test "opensocketCallback: private IPv4 returns CURL_SOCKET_BAD" { + const filter = IpFilter.init(true, null); + var sa = makeSockAddrV4(.{ 127, 0, 0, 1 }); + const result = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); + try std.testing.expectEqual(libcurl.CURL_SOCKET_BAD, result); +} + +test "opensocketCallback: public IPv4 opens a real socket" { + // 8.8.8.8 — not in any blocked range; callback should create a real socket + const filter = IpFilter.init(true, null); + var sa = makeSockAddrV4(.{ 8, 8, 8, 8 }); + const fd = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); + // A real fd is always >= 0 + try std.testing.expect(fd >= 0); + posix.close(fd); +} + +test "opensocketCallback: null clientp returns CURL_SOCKET_BAD (fail-closed)" { + var sa = makeSockAddrV4(.{ 8, 8, 8, 8 }); + const result = opensocketCallback(.ipcxn, &sa, null); + try std.testing.expectEqual(libcurl.CURL_SOCKET_BAD, result); +} + +test "opensocketCallback: block_private=false allows private IP" { + // When block_private is false the filter blocks nothing + const filter = IpFilter.init(false, null); + var sa = makeSockAddrV4(.{ 127, 0, 0, 1 }); + const fd = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); + try std.testing.expect(fd >= 0); + posix.close(fd); +} diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 31587823..b621a3a3 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -43,6 +43,23 @@ pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR; pub const curl_readfunc_pause: usize = c.CURL_READFUNC_PAUSE; pub const CurlReadFunction = fn ([*]u8, usize, usize, *anyopaque) usize; +pub const CurlSockType = enum(c.curlsocktype) { + ipcxn = c.CURLSOCKTYPE_IPCXN, + accept = c.CURLSOCKTYPE_ACCEPT, +}; + +/// Mirror of curl's struct curl_sockaddr. The addr field is a struct sockaddr +/// inline (not a pointer), so addrlen tells you how many bytes of addr are valid. +pub const CurlSockAddr = extern struct { + family: c_int, + socktype: c_int, + protocol: c_int, + addrlen: c_uint, + addr: std.posix.sockaddr, +}; + +pub const CURL_SOCKET_BAD: c.curl_socket_t = c.CURL_SOCKET_BAD; + pub const FreeCallback = fn (ptr: ?*anyopaque) void; pub const StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8; pub const MallocCallback = fn (size: usize) ?*anyopaque; @@ -137,8 +154,17 @@ comptime { return 0; } }.cb; + const opensocket_cb_check: c.curl_opensocket_callback = struct { + fn cb(clientp: ?*anyopaque, purpose: c.curlsocktype, address: [*c]c.curl_sockaddr) callconv(.c) c.curl_socket_t { + _ = clientp; + _ = purpose; + _ = address; + return CURL_SOCKET_BAD; + } + }.cb; _ = debug_cb_check; _ = write_cb_check; + _ = opensocket_cb_check; if (@sizeOf(CurlWaitFd) != @sizeOf(c.curl_waitfd)) { @compileError("CurlWaitFd size mismatch"); @@ -152,6 +178,17 @@ comptime { if (c.CURL_WAIT_POLLIN != 1 or c.CURL_WAIT_POLLPRI != 2 or c.CURL_WAIT_POLLOUT != 4) { @compileError("CURL_WAIT_* flag values don't match CurlWaitEvents packed struct bit layout"); } + if (@sizeOf(CurlSockAddr) != @sizeOf(c.curl_sockaddr)) { + @compileError("CurlSockAddr size mismatch with curl_sockaddr"); + } + if (@offsetOf(CurlSockAddr, "family") != @offsetOf(c.curl_sockaddr, "family") or + @offsetOf(CurlSockAddr, "socktype") != @offsetOf(c.curl_sockaddr, "socktype") or + @offsetOf(CurlSockAddr, "protocol") != @offsetOf(c.curl_sockaddr, "protocol") or + @offsetOf(CurlSockAddr, "addrlen") != @offsetOf(c.curl_sockaddr, "addrlen") or + @offsetOf(CurlSockAddr, "addr") != @offsetOf(c.curl_sockaddr, "addr")) + { + @compileError("CurlSockAddr layout mismatch with curl_sockaddr"); + } } pub const CurlOption = enum(c.CURLoption) { @@ -190,6 +227,8 @@ pub const CurlOption = enum(c.CURLoption) { read_function = c.CURLOPT_READFUNCTION, connect_only = c.CURLOPT_CONNECT_ONLY, upload = c.CURLOPT_UPLOAD, + opensocket_function = c.CURLOPT_OPENSOCKETFUNCTION, + opensocket_data = c.CURLOPT_OPENSOCKETDATA, }; pub const CurlMOption = enum(c.CURLMoption) { @@ -620,6 +659,7 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype .header_data, .read_data, .write_data, + .opensocket_data, => blk: { const ptr: ?*anyopaque = switch (@typeInfo(@TypeOf(value))) { .null => null, @@ -643,6 +683,20 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype break :blk c.curl_easy_setopt(easy, opt, cb); }, + .opensocket_function => blk: { + const cb: c.curl_opensocket_callback = switch (@typeInfo(@TypeOf(value))) { + .null => null, + .@"fn" => struct { + fn cb(clientp: ?*anyopaque, purpose: c.curlsocktype, address: [*c]c.curl_sockaddr) callconv(.c) c.curl_socket_t { + const addr: *CurlSockAddr = @ptrCast(address orelse return CURL_SOCKET_BAD); + return value(@enumFromInt(purpose), addr, clientp); + } + }.cb, + else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), + }; + break :blk c.curl_easy_setopt(easy, opt, cb); + }, + .header_function => blk: { const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { .null => null,