From f5cfc4d31577273336e3bf3d251d96257f0a3e92 Mon Sep 17 00:00:00 2001 From: Lucien Coffe Date: Mon, 23 Mar 2026 14:17:50 +0100 Subject: [PATCH 1/6] feat: add --block_private_networks and --block_cidrs CLI flags Block outbound HTTP requests to specified IP ranges before TCP handshake using libcurl CURLOPT_OPENSOCKETFUNCTION callback. Fires after DNS resolution, reads resolved IP directly from sockaddr, does bitwise CIDR comparison. Fail-closed: unknown address families are blocked. --block_private_networks blocks RFC1918, localhost, link-local, ULA. --block_cidrs blocks additional comma-separated CIDRs. IPv4-mapped IPv6 (::ffff:x.x.x.x) is unwrapped to prevent bypass. --- src/Config.zig | 44 +++++ src/network/IpFilter.zig | 407 +++++++++++++++++++++++++++++++++++++++ src/network/Network.zig | 49 ++++- src/network/http.zig | 96 ++++++++- src/sys/libcurl.zig | 58 ++++++ 5 files changed, 649 insertions(+), 5 deletions(-) create mode 100644 src/network/IpFilter.zig diff --git a/src/Config.zig b/src/Config.zig index 25e033e3..10520b79 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -205,6 +205,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, @@ -292,6 +306,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. @@ -351,6 +368,19 @@ 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. + \\ e.g. --block_cidrs 169.254.169.254/32,fd00:ec2::254/128 + \\ 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. @@ -1094,5 +1124,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..f44b9b07 --- /dev/null +++ b/src/network/IpFilter.zig @@ -0,0 +1,407 @@ +// 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: Ipv4Addr, + prefix_len: u6, // 0-32 +}; + +pub const CidrV6 = struct { + network: Ipv6Addr, + prefix_len: u8, // 0-128 +}; + +// IpFilter fields +block_private: bool, +custom_v4: []const CidrV4, +custom_v6: []const CidrV6, + +// ── 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 .{ .network = parseIpv4Comptime(addr), .prefix_len = prefix }; +} + +/// Comptime helper: build a CidrV6 from a 16-byte literal array. +fn makeCidrV6(comptime bytes: Ipv6Addr, comptime prefix: u8) CidrV6 { + return .{ .network = bytes, .prefix_len = 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{ + // ::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 { + if (cidr.prefix_len == 0) return true; + const full_bytes: usize = cidr.prefix_len / 8; + const rem_bits: u4 = @intCast(cidr.prefix_len % 8); + + var i: usize = 0; + // Check full bytes + while (i < full_bytes) : (i += 1) { + if (addr[i] != cidr.network[i]) return false; + } + // Check partial byte (if any) + if (rem_bits > 0 and i < 4) { + const shift: u3 = @intCast(8 - rem_bits); + const mask: u8 = @as(u8, 0xFF) << shift; + if ((addr[i] & mask) != (cidr.network[i] & mask)) return false; + } + return true; +} + +/// Check if IPv6 address falls within a CIDR range. +fn matchesCidrV6(addr: Ipv6Addr, cidr: CidrV6) bool { + if (cidr.prefix_len == 0) return true; + const full_bytes: usize = cidr.prefix_len / 8; + const rem_bits: u4 = @intCast(cidr.prefix_len % 8); + + var i: usize = 0; + while (i < full_bytes) : (i += 1) { + if (addr[i] != cidr.network[i]) return false; + } + if (rem_bits > 0 and i < 16) { + const shift: u3 = @intCast(8 - rem_bits); + const mask: u8 = @as(u8, 0xFF) << shift; + if ((addr[i] & mask) != (cidr.network[i] & mask)) return false; + } + return true; +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +pub const ParsedCidrs = struct { v4: []CidrV4, v6: []CidrV6 }; + +/// Parse a comma-separated list of CIDR strings (e.g. "10.0.0.0/8,2001:db8::/32") +/// into separate IPv4 and IPv6 slices. Caller owns the returned slices and must +/// free them with the same allocator. Returns error.InvalidCidr on any malformed entry. +pub fn parseCidrList( + allocator: std.mem.Allocator, + cidr_str: []const u8, +) !ParsedCidrs { + 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 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 slash = std.mem.indexOfScalar(u8, trimmed, '/') orelse return error.InvalidCidr; + const addr_str = trimmed[0..slash]; + const prefix_str = trimmed[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; + try v4_list.append(allocator, .{ .network = v4, .prefix_len = @intCast(prefix) }); + } 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; + try v6_list.append(allocator, .{ .network = v6, .prefix_len = prefix }); + } else { + return error.InvalidCidr; + } + } + + const v4 = try v4_list.toOwnedSlice(allocator); + errdefer allocator.free(v4); + const v6 = try v6_list.toOwnedSlice(allocator); + return .{ .v4 = v4, .v6 = v6 }; +} + +/// Create an IpFilter. Set block_private to block outbound requests to +/// RFC1918, localhost, link-local, and ULA ranges — useful for sandboxing +/// and preventing access to internal infrastructure. custom_v4/custom_v6 +/// are additional user-defined ranges (caller owns the slices). +pub fn init(block_private: bool, custom_v4: []const CidrV4, custom_v6: []const CidrV6) IpFilter { + return .{ + .block_private = block_private, + .custom_v4 = custom_v4, + .custom_v6 = custom_v6, + }; +} + +fn isBlockedV4(self: *const IpFilter, addr: Ipv4Addr) bool { + if (self.block_private) { + for (PRIVATE_V4) |cidr| { + if (matchesCidrV4(addr, cidr)) return true; + } + } + for (self.custom_v4) |cidr| { + if (matchesCidrV4(addr, cidr)) return true; + } + return false; +} + +fn isBlockedV6(self: *const IpFilter, addr: Ipv6Addr) bool { + if (self.block_private) { + for (PRIVATE_V6) |cidr| { + if (matchesCidrV6(addr, cidr)) return true; + } + } + for (self.custom_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 + } +} + +// ── Unit tests ─────────────────────────────────────────────────────────────── + +/// 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; +} + +test "IPv4 CIDR matching: private group boundaries" { + const filter = IpFilter.init(true, &.{}, &.{}); + const t = std.testing; + + // Loopback + try t.expect(filter.testBlocked("127.0.0.1")); + try t.expect(filter.testBlocked("127.255.255.255")); + try t.expect(!filter.testBlocked("128.0.0.1")); + + // RFC1918 10.0.0.0/8 + try t.expect(filter.testBlocked("10.0.0.1")); + try t.expect(filter.testBlocked("10.255.255.255")); + try t.expect(!filter.testBlocked("11.0.0.0")); + + // RFC1918 172.16.0.0/12 — critical boundary + try t.expect(!filter.testBlocked("172.15.255.255")); // MUST NOT block + try t.expect(filter.testBlocked("172.16.0.0")); // MUST block + try t.expect(filter.testBlocked("172.31.255.255")); // MUST block + try t.expect(!filter.testBlocked("172.32.0.0")); // MUST NOT block + + // RFC1918 192.168.0.0/16 + try t.expect(filter.testBlocked("192.168.0.1")); + try t.expect(!filter.testBlocked("192.169.0.0")); + + // Link-local + try t.expect(filter.testBlocked("169.254.1.1")); + try t.expect(!filter.testBlocked("169.255.0.0")); + + // Public IP — must NOT be blocked + try t.expect(!filter.testBlocked("8.8.8.8")); + try t.expect(!filter.testBlocked("1.1.1.1")); + try t.expect(!filter.testBlocked("93.184.216.34")); // example.com +} + +test "IPv6 CIDR matching: private group" { + const filter = IpFilter.init(true, &.{}, &.{}); + const t = std.testing; + + try t.expect(filter.testBlocked("::1")); // localhost + try t.expect(filter.testBlocked("fe80::1")); // link-local + try t.expect(filter.testBlocked("fc00::1")); // ULA + try t.expect(filter.testBlocked("fd00::1")); // ULA (fd is fc00::/7) + try t.expect(!filter.testBlocked("2001:db8::1")); // documentation range — public + try t.expect(!filter.testBlocked("2606:4700::1111")); // Cloudflare +} + +test "IPv4-mapped IPv6 bypass prevention" { + const filter = IpFilter.init(true, &.{}, &.{}); + const t = std.testing; + + // ::ffff:127.0.0.1 must be blocked (maps to loopback) + try t.expect(filter.testBlocked("::ffff:127.0.0.1")); + // ::ffff:10.0.0.1 must be blocked (maps to RFC1918) + try t.expect(filter.testBlocked("::ffff:10.0.0.1")); + // ::ffff:8.8.8.8 must NOT be blocked (maps to public) + try t.expect(!filter.testBlocked("::ffff:8.8.8.8")); +} + +test "fail-closed: unknown address family blocked by isBlockedSockaddr" { + const filter = IpFilter.init(false, &.{}, &.{}); + const t = std.testing; + + // 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 t.expect(filter.isBlockedSockaddr(&sa)); +} + +test "custom CIDR ranges" { + const custom_v4 = [_]CidrV4{ + .{ .network = .{ 203, 0, 113, 0 }, .prefix_len = 24 }, // TEST-NET-3 + }; + const filter = IpFilter.init(false, &custom_v4, &.{}); + const t = std.testing; + + try t.expect(filter.testBlocked("203.0.113.1")); // in custom range + try t.expect(filter.testBlocked("203.0.113.255")); // in custom range + try t.expect(!filter.testBlocked("203.0.114.0")); // outside custom range + try t.expect(!filter.testBlocked("8.8.8.8")); // not in range +} + +test "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, &.{}, &.{}); + const filter_none = IpFilter.init(false, &.{}, &.{}); + const t = std.testing; + + try t.expect(filter_private.testBlocked("169.254.169.254")); // blocked via link-local + try t.expect(!filter_none.testBlocked("169.254.169.254")); // not blocked when disabled +} + +test "parseCidrList: mixed IPv4 and IPv6" { + const t = std.testing; + const result = try parseCidrList(t.allocator, "203.0.113.0/24, 2001:db8::/32, 192.168.1.0/24"); + defer t.allocator.free(result.v4); + defer t.allocator.free(result.v6); + + try t.expectEqual(2, result.v4.len); + try t.expectEqual(1, result.v6.len); + + // spot-check: 203.0.113.0/24 and 192.168.1.0/24 + const f = IpFilter.init(false, result.v4, result.v6); + try t.expect(f.testBlocked("203.0.113.1")); + try t.expect(!f.testBlocked("203.0.114.0")); + try t.expect(f.testBlocked("192.168.1.1")); + try t.expect(f.testBlocked("2001:db8::1")); + try t.expect(!f.testBlocked("2001:db9::1")); +} + +test "parseCidrList: invalid input returns error" { + const t = std.testing; + try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "not-a-cidr")); + try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "10.0.0.0/33")); // prefix too large + try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "10.0.0.0")); // missing prefix + try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "10.0.0.0/abc")); // non-numeric prefix +} + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/network/Network.zig b/src/network/Network.zig index 1fb8c8fb..883ceef5 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,12 @@ 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, +// Custom CIDR slices backing ip_filter; null when --block_cidrs was not set. +ip_filter_custom_v4: ?[]IpFilter.CidrV4 = null, +ip_filter_custom_v6: ?[]IpFilter.CidrV6 = null, + const TickCallback = struct { ctx: *anyopaque, fun: *const fn (*anyopaque) void, @@ -230,13 +237,39 @@ 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. Heap-allocated + // for pointer stability: connections need a stable *const IpFilter to pass to + // curl's opensocket callback. + const block_private = config.blockPrivateNetworks(); + const custom_cidrs: ?IpFilter.ParsedCidrs = blk: { + const s = config.blockCidrs() orelse break :blk null; + break :blk try IpFilter.parseCidrList(allocator, s); + }; + errdefer if (custom_cidrs) |c| { + allocator.free(c.v4); + allocator.free(c.v6); + }; + + const ip_filter: ?*IpFilter = blk: { + const has_custom = if (custom_cidrs) |c| c.v4.len > 0 or c.v6.len > 0 else false; + if (!block_private and !has_custom) break :blk null; + const f = try allocator.create(IpFilter); + f.* = IpFilter.init( + block_private, + if (custom_cidrs) |c| c.v4 else &.{}, + if (custom_cidrs) |c| c.v6 else &.{}, + ); + break :blk f; + }; + errdefer if (ip_filter) |f| 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 +313,10 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { .ws_pool = .init(allocator), .ws_max = config.wsMaxConcurrent(), + + .ip_filter = ip_filter, + .ip_filter_custom_v4 = if (custom_cidrs) |c| c.v4 else null, + .ip_filter_custom_v6 = if (custom_cidrs) |c| c.v6 else null, }; } @@ -316,6 +353,12 @@ pub fn deinit(self: *Network) void { if (self.cache) |*cache| cache.deinit(); + if (self.ip_filter) |f| { + self.allocator.destroy(f); + } + if (self.ip_filter_custom_v4) |v4| self.allocator.free(v4); + if (self.ip_filter_custom_v6) |v6| self.allocator.free(v6); + globalDeinit(); } @@ -612,7 +655,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 +680,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 4bf71ded..08cf3df2 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; @@ -222,6 +224,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, @@ -233,13 +264,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; } @@ -364,6 +399,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; @@ -414,6 +450,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 { @@ -596,3 +638,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, &.{}, &.{}); + 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, &.{}, &.{}); + 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, &.{}, &.{}); + 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..19605f2a 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -43,6 +43,27 @@ 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; + +/// Zig-side opensocket callback: purpose and address first, user data last. +/// Return a valid socket fd to allow the connection, or CURL_SOCKET_BAD to block. +pub const CurlOpenSocketFunction = fn (CurlSockType, *CurlSockAddr, ?*anyopaque) c.curl_socket_t; + 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 +158,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 +182,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 +231,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 +663,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 +687,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, From fb6c4e4978bff25d0ba6b3db50c4de19240c0260 Mon Sep 17 00:00:00 2001 From: Lucien Coffe Date: Tue, 24 Mar 2026 10:29:13 +0100 Subject: [PATCH 2/6] feat: add allow-list exclusions to --block_cidrs CIDRs prefixed with '-' are treated as allow rules that exempt matching IPs from blocking. Allow rules take precedence over both --block_private_networks and custom block CIDRs. Example: --block_private_networks --block_cidrs -10.0.0.42/32 blocks all private ranges except 10.0.0.42. Adds 3 new tests for allow-list behavior. --- src/Config.zig | 2 + src/network/IpFilter.zig | 136 +++++++++++++++++++++++++++++++++------ src/network/Network.zig | 6 +- src/network/http.zig | 6 +- 4 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 10520b79..8d3e35aa 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -378,7 +378,9 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ \\--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. diff --git a/src/network/IpFilter.zig b/src/network/IpFilter.zig index f44b9b07..ca421ab0 100644 --- a/src/network/IpFilter.zig +++ b/src/network/IpFilter.zig @@ -40,6 +40,8 @@ pub const CidrV6 = struct { block_private: bool, custom_v4: []const CidrV4, custom_v6: []const CidrV6, +allow_v4: []const CidrV4, +allow_v6: []const CidrV6, // ── Comptime helpers ───────────────────────────────────────────────────────── @@ -167,11 +169,13 @@ fn matchesCidrV6(addr: Ipv6Addr, cidr: CidrV6) bool { // ── Public API ─────────────────────────────────────────────────────────────── -pub const ParsedCidrs = struct { v4: []CidrV4, v6: []CidrV6 }; +pub const ParsedCidrs = struct { v4: []CidrV4, v6: []CidrV6, allow_v4: []CidrV4, allow_v6: []CidrV6 }; /// Parse a comma-separated list of CIDR strings (e.g. "10.0.0.0/8,2001:db8::/32") -/// into separate IPv4 and IPv6 slices. Caller owns the returned slices and must -/// free them with the same allocator. Returns error.InvalidCidr on any malformed entry. +/// into separate IPv4 and IPv6 slices. 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 slices and must free them with the same allocator. +/// Returns error.InvalidCidr on any malformed entry. pub fn parseCidrList( allocator: std.mem.Allocator, cidr_str: []const u8, @@ -180,24 +184,41 @@ pub fn parseCidrList( 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 slash = std.mem.indexOfScalar(u8, trimmed, '/') orelse return error.InvalidCidr; - const addr_str = trimmed[0..slash]; - const prefix_str = trimmed[slash + 1 ..]; + 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; - try v4_list.append(allocator, .{ .network = v4, .prefix_len = @intCast(prefix) }); + const cidr = CidrV4{ .network = v4, .prefix_len = @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; - try v6_list.append(allocator, .{ .network = v6, .prefix_len = prefix }); + const cidr = CidrV6{ .network = v6, .prefix_len = prefix }; + if (is_allow) { + try allow_v6_list.append(allocator, cidr); + } else { + try v6_list.append(allocator, cidr); + } } else { return error.InvalidCidr; } @@ -206,22 +227,39 @@ pub fn parseCidrList( const v4 = try v4_list.toOwnedSlice(allocator); errdefer allocator.free(v4); const v6 = try v6_list.toOwnedSlice(allocator); - return .{ .v4 = v4, .v6 = v6 }; + 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 an IpFilter. Set block_private to block outbound requests to /// RFC1918, localhost, link-local, and ULA ranges — useful for sandboxing /// and preventing access to internal infrastructure. custom_v4/custom_v6 -/// are additional user-defined ranges (caller owns the slices). -pub fn init(block_private: bool, custom_v4: []const CidrV4, custom_v6: []const CidrV6) IpFilter { +/// are additional user-defined ranges to block; allow_v4/allow_v6 are +/// exemptions that take precedence over all block rules. +/// Caller owns the slices. +pub fn init( + block_private: bool, + custom_v4: []const CidrV4, + custom_v6: []const CidrV6, + allow_v4: []const CidrV4, + allow_v6: []const CidrV6, +) IpFilter { return .{ .block_private = block_private, .custom_v4 = custom_v4, .custom_v6 = custom_v6, + .allow_v4 = allow_v4, + .allow_v6 = allow_v6, }; } fn isBlockedV4(self: *const IpFilter, addr: Ipv4Addr) bool { + for (self.allow_v4) |cidr| { + if (matchesCidrV4(addr, cidr)) return false; + } if (self.block_private) { for (PRIVATE_V4) |cidr| { if (matchesCidrV4(addr, cidr)) return true; @@ -234,6 +272,9 @@ fn isBlockedV4(self: *const IpFilter, addr: Ipv4Addr) bool { } fn isBlockedV6(self: *const IpFilter, addr: Ipv6Addr) bool { + for (self.allow_v6) |cidr| { + if (matchesCidrV6(addr, cidr)) return false; + } if (self.block_private) { for (PRIVATE_V6) |cidr| { if (matchesCidrV6(addr, cidr)) return true; @@ -280,7 +321,7 @@ fn testBlocked(self: *const IpFilter, ip: []const u8) bool { } test "IPv4 CIDR matching: private group boundaries" { - const filter = IpFilter.init(true, &.{}, &.{}); + const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); const t = std.testing; // Loopback @@ -314,7 +355,7 @@ test "IPv4 CIDR matching: private group boundaries" { } test "IPv6 CIDR matching: private group" { - const filter = IpFilter.init(true, &.{}, &.{}); + const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); const t = std.testing; try t.expect(filter.testBlocked("::1")); // localhost @@ -326,7 +367,7 @@ test "IPv6 CIDR matching: private group" { } test "IPv4-mapped IPv6 bypass prevention" { - const filter = IpFilter.init(true, &.{}, &.{}); + const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); const t = std.testing; // ::ffff:127.0.0.1 must be blocked (maps to loopback) @@ -338,7 +379,7 @@ test "IPv4-mapped IPv6 bypass prevention" { } test "fail-closed: unknown address family blocked by isBlockedSockaddr" { - const filter = IpFilter.init(false, &.{}, &.{}); + const filter = IpFilter.init(false, &.{}, &.{}, &.{}, &.{}); const t = std.testing; // Construct a sockaddr with an unknown address family @@ -356,7 +397,7 @@ test "custom CIDR ranges" { const custom_v4 = [_]CidrV4{ .{ .network = .{ 203, 0, 113, 0 }, .prefix_len = 24 }, // TEST-NET-3 }; - const filter = IpFilter.init(false, &custom_v4, &.{}); + const filter = IpFilter.init(false, &custom_v4, &.{}, &.{}, &.{}); const t = std.testing; try t.expect(filter.testBlocked("203.0.113.1")); // in custom range @@ -368,8 +409,8 @@ test "custom CIDR ranges" { test "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, &.{}, &.{}); - const filter_none = IpFilter.init(false, &.{}, &.{}); + const filter_private = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); + const filter_none = IpFilter.init(false, &.{}, &.{}, &.{}, &.{}); const t = std.testing; try t.expect(filter_private.testBlocked("169.254.169.254")); // blocked via link-local @@ -381,12 +422,14 @@ test "parseCidrList: mixed IPv4 and IPv6" { const result = try parseCidrList(t.allocator, "203.0.113.0/24, 2001:db8::/32, 192.168.1.0/24"); defer t.allocator.free(result.v4); defer t.allocator.free(result.v6); + defer t.allocator.free(result.allow_v4); + defer t.allocator.free(result.allow_v6); try t.expectEqual(2, result.v4.len); try t.expectEqual(1, result.v6.len); // spot-check: 203.0.113.0/24 and 192.168.1.0/24 - const f = IpFilter.init(false, result.v4, result.v6); + const f = IpFilter.init(false, result.v4, result.v6, result.allow_v4, result.allow_v6); try t.expect(f.testBlocked("203.0.113.1")); try t.expect(!f.testBlocked("203.0.114.0")); try t.expect(f.testBlocked("192.168.1.1")); @@ -394,6 +437,61 @@ test "parseCidrList: mixed IPv4 and IPv6" { try t.expect(!f.testBlocked("2001:db9::1")); } +test "allow list exempts from private blocking" { + const allow_v4 = [_]CidrV4{ + .{ .network = .{ 10, 0, 0, 42 }, .prefix_len = 32 }, + }; + const allow_v6 = [_]CidrV6{ + makeCidrV6(.{ 0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, 128), + }; + const filter = IpFilter.init(true, &.{}, &.{}, &allow_v4, &allow_v6); + const t = std.testing; + + // Allowed IPs pass through despite being in private ranges + try t.expect(!filter.testBlocked("10.0.0.42")); + try t.expect(!filter.testBlocked("fc00::1")); + + // Other private IPs still blocked + try t.expect(filter.testBlocked("10.0.0.43")); + try t.expect(filter.testBlocked("10.0.0.41")); + try t.expect(filter.testBlocked("192.168.1.1")); + try t.expect(filter.testBlocked("fc00::2")); +} + +test "allow list exempts from custom CIDR blocking" { + const custom_v4 = [_]CidrV4{ + .{ .network = .{ 203, 0, 113, 0 }, .prefix_len = 24 }, + }; + const allow_v4 = [_]CidrV4{ + .{ .network = .{ 203, 0, 113, 100 }, .prefix_len = 32 }, + }; + const filter = IpFilter.init(false, &custom_v4, &.{}, &allow_v4, &.{}); + const t = std.testing; + + try t.expect(!filter.testBlocked("203.0.113.100")); // allowed + try t.expect(filter.testBlocked("203.0.113.99")); // blocked + try t.expect(filter.testBlocked("203.0.113.101")); // blocked +} + +test "parseCidrList: allow entries with '-' prefix" { + const t = std.testing; + const result = try parseCidrList(t.allocator, "10.0.0.0/8,-10.0.0.42/32,-fc00::1/128"); + defer t.allocator.free(result.v4); + defer t.allocator.free(result.v6); + defer t.allocator.free(result.allow_v4); + defer t.allocator.free(result.allow_v6); + + try t.expectEqual(1, result.v4.len); + try t.expectEqual(0, result.v6.len); + try t.expectEqual(1, result.allow_v4.len); + try t.expectEqual(1, result.allow_v6.len); + + const f = IpFilter.init(false, result.v4, result.v6, result.allow_v4, result.allow_v6); + try t.expect(!f.testBlocked("10.0.0.42")); // allowed + try t.expect(f.testBlocked("10.0.0.43")); // blocked + try t.expect(!f.testBlocked("fc00::1")); // allowed (not blocked by custom, but allow-listed) +} + test "parseCidrList: invalid input returns error" { const t = std.testing; try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "not-a-cidr")); diff --git a/src/network/Network.zig b/src/network/Network.zig index 883ceef5..2968611b 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -248,16 +248,20 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { errdefer if (custom_cidrs) |c| { allocator.free(c.v4); allocator.free(c.v6); + allocator.free(c.allow_v4); + allocator.free(c.allow_v6); }; const ip_filter: ?*IpFilter = blk: { - const has_custom = if (custom_cidrs) |c| c.v4.len > 0 or c.v6.len > 0 else false; + const has_custom = if (custom_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; if (!block_private and !has_custom) break :blk null; const f = try allocator.create(IpFilter); f.* = IpFilter.init( block_private, if (custom_cidrs) |c| c.v4 else &.{}, if (custom_cidrs) |c| c.v6 else &.{}, + if (custom_cidrs) |c| c.allow_v4 else &.{}, + if (custom_cidrs) |c| c.allow_v6 else &.{}, ); break :blk f; }; diff --git a/src/network/http.zig b/src/network/http.zig index 08cf3df2..0cd30125 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -658,7 +658,7 @@ fn makeSockAddrV4(ip: [4]u8) libcurl.CurlSockAddr { } test "opensocketCallback: private IPv4 returns CURL_SOCKET_BAD" { - const filter = IpFilter.init(true, &.{}, &.{}); + const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); var sa = makeSockAddrV4(.{ 127, 0, 0, 1 }); const result = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); try std.testing.expectEqual(libcurl.CURL_SOCKET_BAD, result); @@ -666,7 +666,7 @@ test "opensocketCallback: private IPv4 returns CURL_SOCKET_BAD" { 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, &.{}, &.{}); + const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); var sa = makeSockAddrV4(.{ 8, 8, 8, 8 }); const fd = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); // A real fd is always >= 0 @@ -682,7 +682,7 @@ test "opensocketCallback: null clientp returns CURL_SOCKET_BAD (fail-closed)" { test "opensocketCallback: block_private=false allows private IP" { // When block_private is false the filter blocks nothing - const filter = IpFilter.init(false, &.{}, &.{}); + const filter = IpFilter.init(false, &.{}, &.{}, &.{}, &.{}); var sa = makeSockAddrV4(.{ 127, 0, 0, 1 }); const fd = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); try std.testing.expect(fd >= 0); From 7f5abfc9cfdbf13bed8d170611550d958d0a079a Mon Sep 17 00:00:00 2001 From: Lucien Coffe Date: Tue, 24 Mar 2026 10:39:24 +0100 Subject: [PATCH 3/6] fix: use dashes in CLI flag names for consistency Rename --block_private_networks to --block-private-networks and --block_cidrs to --block-cidrs to match the existing flag naming convention (e.g. --http-proxy, --proxy-bearer-token). --- src/Config.zig | 16 ++++++++-------- src/network/IpFilter.zig | 2 +- src/network/Network.zig | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 8d3e35aa..c31a3563 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -368,7 +368,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ we make requests towards. \\ Defaults to false. \\ - \\--block_private_networks + \\--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 @@ -376,12 +376,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ resources, redirects, etc.). \\ Defaults to false. \\ - \\--block_cidrs + \\--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. + \\ 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. @@ -1126,14 +1126,14 @@ fn parseCommonArg( return true; } - if (std.mem.eql(u8, "--block_private_networks", opt)) { + if (std.mem.eql(u8, "--block-private-networks", opt)) { common.block_private_networks = true; return true; } - if (std.mem.eql(u8, "--block_cidrs", opt)) { + if (std.mem.eql(u8, "--block-cidrs", opt)) { const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--block_cidrs" }); + log.fatal(.app, "missing argument value", .{ .arg = "--block-cidrs" }); return error.InvalidArgument; }; common.block_cidrs = try allocator.dupe(u8, str); diff --git a/src/network/IpFilter.zig b/src/network/IpFilter.zig index ca421ab0..f81c7fc7 100644 --- a/src/network/IpFilter.zig +++ b/src/network/IpFilter.zig @@ -408,7 +408,7 @@ test "custom CIDR ranges" { test "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. + // Users who want targeted cloud-metadata-only blocking can use --block-cidrs. const filter_private = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); const filter_none = IpFilter.init(false, &.{}, &.{}, &.{}, &.{}); const t = std.testing; diff --git a/src/network/Network.zig b/src/network/Network.zig index 2968611b..459eb5a5 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -86,9 +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). +/// Optional IP filter for blocking requests to private/internal networks (--block-private-networks). ip_filter: ?*IpFilter = null, -// Custom CIDR slices backing ip_filter; null when --block_cidrs was not set. +// Custom CIDR slices backing ip_filter; null when --block-cidrs was not set. ip_filter_custom_v4: ?[]IpFilter.CidrV4 = null, ip_filter_custom_v6: ?[]IpFilter.CidrV6 = null, From e57b5c645b73b32af57b050bd91fd916dbe40322 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 8 Apr 2026 14:06:17 +0200 Subject: [PATCH 4/6] remove deadcode libcurl.CurlOpenSocketFunction --- src/sys/libcurl.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 19605f2a..b621a3a3 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -60,10 +60,6 @@ pub const CurlSockAddr = extern struct { pub const CURL_SOCKET_BAD: c.curl_socket_t = c.CURL_SOCKET_BAD; -/// Zig-side opensocket callback: purpose and address first, user data last. -/// Return a valid socket fd to allow the connection, or CURL_SOCKET_BAD to block. -pub const CurlOpenSocketFunction = fn (CurlSockType, *CurlSockAddr, ?*anyopaque) c.curl_socket_t; - pub const FreeCallback = fn (ptr: ?*anyopaque) void; pub const StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8; pub const MallocCallback = fn (size: usize) ?*anyopaque; From 6ef518438b312e529658879ae6894870bde70c74 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 8 Apr 2026 14:35:46 +0200 Subject: [PATCH 5/6] fix custom cidrs mem leak --- src/network/IpFilter.zig | 150 ++++++++++++++++++++------------------- src/network/Network.zig | 38 +++------- src/network/http.zig | 6 +- 3 files changed, 90 insertions(+), 104 deletions(-) diff --git a/src/network/IpFilter.zig b/src/network/IpFilter.zig index f81c7fc7..687a345d 100644 --- a/src/network/IpFilter.zig +++ b/src/network/IpFilter.zig @@ -38,10 +38,7 @@ pub const CidrV6 = struct { // IpFilter fields block_private: bool, -custom_v4: []const CidrV4, -custom_v6: []const CidrV6, -allow_v4: []const CidrV4, -allow_v6: []const CidrV6, +cidrs: ?Cidrs, // ── Comptime helpers ───────────────────────────────────────────────────────── @@ -169,17 +166,29 @@ fn matchesCidrV6(addr: Ipv6Addr, cidr: CidrV6) bool { // ── Public API ─────────────────────────────────────────────────────────────── -pub const ParsedCidrs = struct { v4: []CidrV4, v6: []CidrV6, allow_v4: []CidrV4, allow_v6: []CidrV6 }; +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 separate IPv4 and IPv6 slices. 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 slices and must free them with the same allocator. +/// 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, -) !ParsedCidrs { +) !Cidrs { var v4_list: std.ArrayList(CidrV4) = .empty; errdefer v4_list.deinit(allocator); var v6_list: std.ArrayList(CidrV6) = .empty; @@ -234,54 +243,58 @@ pub fn parseCidrList( return .{ .v4 = v4, .v6 = v6, .allow_v4 = allow_v4, .allow_v6 = allow_v6 }; } -/// Create an IpFilter. Set block_private to block outbound requests to -/// RFC1918, localhost, link-local, and ULA ranges — useful for sandboxing -/// and preventing access to internal infrastructure. custom_v4/custom_v6 -/// are additional user-defined ranges to block; allow_v4/allow_v6 are -/// exemptions that take precedence over all block rules. -/// Caller owns the slices. +/// Create a heap-allocated 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, - custom_v4: []const CidrV4, - custom_v6: []const CidrV6, - allow_v4: []const CidrV4, - allow_v6: []const CidrV6, + cidrs: ?Cidrs, ) IpFilter { return .{ .block_private = block_private, - .custom_v4 = custom_v4, - .custom_v6 = custom_v6, - .allow_v4 = allow_v4, - .allow_v6 = allow_v6, + .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 { - for (self.allow_v4) |cidr| { - if (matchesCidrV4(addr, cidr)) return false; + if (self.cidrs) |c| { + for (c.allow_v4) |cidr| { + if (matchesCidrV4(addr, cidr)) return false; + } } if (self.block_private) { for (PRIVATE_V4) |cidr| { if (matchesCidrV4(addr, cidr)) return true; } } - for (self.custom_v4) |cidr| { - if (matchesCidrV4(addr, cidr)) return true; + if (self.cidrs) |c| { + for (c.v4) |cidr| { + if (matchesCidrV4(addr, cidr)) return true; + } } return false; } fn isBlockedV6(self: *const IpFilter, addr: Ipv6Addr) bool { - for (self.allow_v6) |cidr| { - if (matchesCidrV6(addr, cidr)) return false; + if (self.cidrs) |c| { + for (c.allow_v6) |cidr| { + if (matchesCidrV6(addr, cidr)) return false; + } } if (self.block_private) { for (PRIVATE_V6) |cidr| { if (matchesCidrV6(addr, cidr)) return true; } } - for (self.custom_v6) |cidr| { - if (matchesCidrV6(addr, cidr)) return true; + if (self.cidrs) |c| { + for (c.v6) |cidr| { + if (matchesCidrV6(addr, cidr)) return true; + } } return false; } @@ -321,8 +334,9 @@ fn testBlocked(self: *const IpFilter, ip: []const u8) bool { } test "IPv4 CIDR matching: private group boundaries" { - const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); const t = std.testing; + const filter = IpFilter.init(true, null); + defer filter.deinit(t.allocator); // Loopback try t.expect(filter.testBlocked("127.0.0.1")); @@ -355,8 +369,9 @@ test "IPv4 CIDR matching: private group boundaries" { } test "IPv6 CIDR matching: private group" { - const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); const t = std.testing; + const filter = IpFilter.init(true, null); + defer filter.deinit(t.allocator); try t.expect(filter.testBlocked("::1")); // localhost try t.expect(filter.testBlocked("fe80::1")); // link-local @@ -367,8 +382,9 @@ test "IPv6 CIDR matching: private group" { } test "IPv4-mapped IPv6 bypass prevention" { - const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); const t = std.testing; + const filter = IpFilter.init(true, null); + defer filter.deinit(t.allocator); // ::ffff:127.0.0.1 must be blocked (maps to loopback) try t.expect(filter.testBlocked("::ffff:127.0.0.1")); @@ -379,8 +395,9 @@ test "IPv4-mapped IPv6 bypass prevention" { } test "fail-closed: unknown address family blocked by isBlockedSockaddr" { - const filter = IpFilter.init(false, &.{}, &.{}, &.{}, &.{}); const t = std.testing; + const filter = IpFilter.init(false, null); + defer filter.deinit(t.allocator); // Construct a sockaddr with an unknown address family var sa: libcurl.CurlSockAddr = .{ @@ -394,11 +411,10 @@ test "fail-closed: unknown address family blocked by isBlockedSockaddr" { } test "custom CIDR ranges" { - const custom_v4 = [_]CidrV4{ - .{ .network = .{ 203, 0, 113, 0 }, .prefix_len = 24 }, // TEST-NET-3 - }; - const filter = IpFilter.init(false, &custom_v4, &.{}, &.{}, &.{}); const t = std.testing; + const cidrs = try parseCidrList(t.allocator, "203.0.113.0/24"); + const filter = IpFilter.init(false, cidrs); + defer filter.deinit(t.allocator); try t.expect(filter.testBlocked("203.0.113.1")); // in custom range try t.expect(filter.testBlocked("203.0.113.255")); // in custom range @@ -409,9 +425,11 @@ test "custom CIDR ranges" { test "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, &.{}, &.{}, &.{}, &.{}); - const filter_none = IpFilter.init(false, &.{}, &.{}, &.{}, &.{}); const t = std.testing; + const filter_private = IpFilter.init(true, null); + defer filter_private.deinit(t.allocator); + const filter_none = IpFilter.init(false, null); + defer filter_none.deinit(t.allocator); try t.expect(filter_private.testBlocked("169.254.169.254")); // blocked via link-local try t.expect(!filter_none.testBlocked("169.254.169.254")); // not blocked when disabled @@ -419,17 +437,14 @@ test "private group blocks cloud metadata IP via link-local" { test "parseCidrList: mixed IPv4 and IPv6" { const t = std.testing; - const result = try parseCidrList(t.allocator, "203.0.113.0/24, 2001:db8::/32, 192.168.1.0/24"); - defer t.allocator.free(result.v4); - defer t.allocator.free(result.v6); - defer t.allocator.free(result.allow_v4); - defer t.allocator.free(result.allow_v6); + const cidrs = try parseCidrList(t.allocator, "203.0.113.0/24, 2001:db8::/32, 192.168.1.0/24"); - try t.expectEqual(2, result.v4.len); - try t.expectEqual(1, result.v6.len); + try t.expectEqual(2, cidrs.v4.len); + try t.expectEqual(1, cidrs.v6.len); // spot-check: 203.0.113.0/24 and 192.168.1.0/24 - const f = IpFilter.init(false, result.v4, result.v6, result.allow_v4, result.allow_v6); + const f = IpFilter.init(false, cidrs); + defer f.deinit(t.allocator); try t.expect(f.testBlocked("203.0.113.1")); try t.expect(!f.testBlocked("203.0.114.0")); try t.expect(f.testBlocked("192.168.1.1")); @@ -438,14 +453,10 @@ test "parseCidrList: mixed IPv4 and IPv6" { } test "allow list exempts from private blocking" { - const allow_v4 = [_]CidrV4{ - .{ .network = .{ 10, 0, 0, 42 }, .prefix_len = 32 }, - }; - const allow_v6 = [_]CidrV6{ - makeCidrV6(.{ 0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, 128), - }; - const filter = IpFilter.init(true, &.{}, &.{}, &allow_v4, &allow_v6); const t = std.testing; + const cidrs = try parseCidrList(t.allocator, "-10.0.0.42/32,-fc00::1/128"); + const filter = IpFilter.init(true, cidrs); + defer filter.deinit(t.allocator); // Allowed IPs pass through despite being in private ranges try t.expect(!filter.testBlocked("10.0.0.42")); @@ -459,14 +470,10 @@ test "allow list exempts from private blocking" { } test "allow list exempts from custom CIDR blocking" { - const custom_v4 = [_]CidrV4{ - .{ .network = .{ 203, 0, 113, 0 }, .prefix_len = 24 }, - }; - const allow_v4 = [_]CidrV4{ - .{ .network = .{ 203, 0, 113, 100 }, .prefix_len = 32 }, - }; - const filter = IpFilter.init(false, &custom_v4, &.{}, &allow_v4, &.{}); const t = std.testing; + const cidrs = try parseCidrList(t.allocator, "203.0.113.0/24,-203.0.113.100/32"); + const filter = IpFilter.init(false, cidrs); + defer filter.deinit(t.allocator); try t.expect(!filter.testBlocked("203.0.113.100")); // allowed try t.expect(filter.testBlocked("203.0.113.99")); // blocked @@ -475,18 +482,15 @@ test "allow list exempts from custom CIDR blocking" { test "parseCidrList: allow entries with '-' prefix" { const t = std.testing; - const result = try parseCidrList(t.allocator, "10.0.0.0/8,-10.0.0.42/32,-fc00::1/128"); - defer t.allocator.free(result.v4); - defer t.allocator.free(result.v6); - defer t.allocator.free(result.allow_v4); - defer t.allocator.free(result.allow_v6); + const cidrs = try parseCidrList(t.allocator, "10.0.0.0/8,-10.0.0.42/32,-fc00::1/128"); - try t.expectEqual(1, result.v4.len); - try t.expectEqual(0, result.v6.len); - try t.expectEqual(1, result.allow_v4.len); - try t.expectEqual(1, result.allow_v6.len); + try t.expectEqual(1, cidrs.v4.len); + try t.expectEqual(0, cidrs.v6.len); + try t.expectEqual(1, cidrs.allow_v4.len); + try t.expectEqual(1, cidrs.allow_v6.len); - const f = IpFilter.init(false, result.v4, result.v6, result.allow_v4, result.allow_v6); + const f = IpFilter.init(false, cidrs); + defer f.deinit(t.allocator); try t.expect(!f.testBlocked("10.0.0.42")); // allowed try t.expect(f.testBlocked("10.0.0.43")); // blocked try t.expect(!f.testBlocked("fc00::1")); // allowed (not blocked by custom, but allow-listed) diff --git a/src/network/Network.zig b/src/network/Network.zig index 459eb5a5..359646ce 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -88,9 +88,6 @@ callbacks_mutex: std.Thread.Mutex = .{}, /// Optional IP filter for blocking requests to private/internal networks (--block-private-networks). ip_filter: ?*IpFilter = null, -// Custom CIDR slices backing ip_filter; null when --block-cidrs was not set. -ip_filter_custom_v4: ?[]IpFilter.CidrV4 = null, -ip_filter_custom_v6: ?[]IpFilter.CidrV6 = null, const TickCallback = struct { ctx: *anyopaque, @@ -237,35 +234,23 @@ 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. Heap-allocated - // for pointer stability: connections need a stable *const IpFilter to pass to - // curl's opensocket callback. + // IP filter for blocking requests to private/internal networks. const block_private = config.blockPrivateNetworks(); - const custom_cidrs: ?IpFilter.ParsedCidrs = blk: { + const cidrs: ?IpFilter.Cidrs = blk: { const s = config.blockCidrs() orelse break :blk null; break :blk try IpFilter.parseCidrList(allocator, s); }; - errdefer if (custom_cidrs) |c| { - allocator.free(c.v4); - allocator.free(c.v6); - allocator.free(c.allow_v4); - allocator.free(c.allow_v6); - }; - + 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: { - const has_custom = if (custom_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; - if (!block_private and !has_custom) break :blk null; + if (!block_private and !has_cidrs) break :blk null; const f = try allocator.create(IpFilter); - f.* = IpFilter.init( - block_private, - if (custom_cidrs) |c| c.v4 else &.{}, - if (custom_cidrs) |c| c.v6 else &.{}, - if (custom_cidrs) |c| c.allow_v4 else &.{}, - if (custom_cidrs) |c| c.allow_v6 else &.{}, - ); + f.* = IpFilter.init(block_private, cidrs); break :blk f; }; - errdefer if (ip_filter) |f| allocator.destroy(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); @@ -319,8 +304,6 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { .ws_max = config.wsMaxConcurrent(), .ip_filter = ip_filter, - .ip_filter_custom_v4 = if (custom_cidrs) |c| c.v4 else null, - .ip_filter_custom_v6 = if (custom_cidrs) |c| c.v6 else null, }; } @@ -358,10 +341,9 @@ 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); } - if (self.ip_filter_custom_v4) |v4| self.allocator.free(v4); - if (self.ip_filter_custom_v6) |v6| self.allocator.free(v6); globalDeinit(); } diff --git a/src/network/http.zig b/src/network/http.zig index 0cd30125..634690ff 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -658,7 +658,7 @@ fn makeSockAddrV4(ip: [4]u8) libcurl.CurlSockAddr { } test "opensocketCallback: private IPv4 returns CURL_SOCKET_BAD" { - const filter = IpFilter.init(true, &.{}, &.{}, &.{}, &.{}); + 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); @@ -666,7 +666,7 @@ test "opensocketCallback: private IPv4 returns CURL_SOCKET_BAD" { 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, &.{}, &.{}, &.{}, &.{}); + 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 @@ -682,7 +682,7 @@ test "opensocketCallback: null clientp returns CURL_SOCKET_BAD (fail-closed)" { test "opensocketCallback: block_private=false allows private IP" { // When block_private is false the filter blocks nothing - const filter = IpFilter.init(false, &.{}, &.{}, &.{}, &.{}); + 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); From 0253092f201ca96a05b929b8042040fe3709c874 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 9 Apr 2026 15:40:16 +0800 Subject: [PATCH 6/6] Improvements to IpFilters The main change is changing how CidrV4 and CidrV6 are stored, by pre-calculating their mask and storing their address as integer. This allows significant simplification of matchesCidrV4 and matchesCidrV6. --- src/network/IpFilter.zig | 581 +++++++++++++++++++++++---------------- 1 file changed, 348 insertions(+), 233 deletions(-) diff --git a/src/network/IpFilter.zig b/src/network/IpFilter.zig index 687a345d..73977188 100644 --- a/src/network/IpFilter.zig +++ b/src/network/IpFilter.zig @@ -27,13 +27,48 @@ pub const Ipv4Addr = [4]u8; pub const Ipv6Addr = [16]u8; pub const CidrV4 = struct { - network: Ipv4Addr, - prefix_len: u6, // 0-32 + 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: Ipv6Addr, - prefix_len: u8, // 0-128 + 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 @@ -62,12 +97,12 @@ fn parseIpv4Comptime(comptime s: []const u8) Ipv4Addr { /// Comptime helper: build a CidrV4. fn makeCidrV4(comptime addr: []const u8, comptime prefix: u6) CidrV4 { - return .{ .network = parseIpv4Comptime(addr), .prefix_len = prefix }; + 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 .{ .network = bytes, .prefix_len = prefix }; + return CidrV6.fromPrefix(bytes, prefix); } // ── Comptime CIDR range tables ─────────────────────────────────────────────── @@ -82,6 +117,8 @@ const PRIVATE_V4 = [_]CidrV4{ }; 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 @@ -128,40 +165,16 @@ fn isIpv4Mapped(addr: Ipv6Addr) ?Ipv4Addr { /// Check if IPv4 address falls within a CIDR range. fn matchesCidrV4(addr: Ipv4Addr, cidr: CidrV4) bool { - if (cidr.prefix_len == 0) return true; - const full_bytes: usize = cidr.prefix_len / 8; - const rem_bits: u4 = @intCast(cidr.prefix_len % 8); - - var i: usize = 0; - // Check full bytes - while (i < full_bytes) : (i += 1) { - if (addr[i] != cidr.network[i]) return false; - } - // Check partial byte (if any) - if (rem_bits > 0 and i < 4) { - const shift: u3 = @intCast(8 - rem_bits); - const mask: u8 = @as(u8, 0xFF) << shift; - if ((addr[i] & mask) != (cidr.network[i] & mask)) return false; - } - return true; + 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 { - if (cidr.prefix_len == 0) return true; - const full_bytes: usize = cidr.prefix_len / 8; - const rem_bits: u4 = @intCast(cidr.prefix_len % 8); - - var i: usize = 0; - while (i < full_bytes) : (i += 1) { - if (addr[i] != cidr.network[i]) return false; - } - if (rem_bits > 0 and i < 16) { - const shift: u3 = @intCast(8 - rem_bits); - const mask: u8 = @as(u8, 0xFF) << shift; - if ((addr[i] & mask) != (cidr.network[i] & mask)) return false; - } - return true; + 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 ─────────────────────────────────────────────────────────────── @@ -213,7 +226,7 @@ pub fn parseCidrList( 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{ .network = v4, .prefix_len = @intCast(prefix) }; + const cidr = CidrV4.fromPrefix(v4, @intCast(prefix)); if (is_allow) { try allow_v4_list.append(allocator, cidr); } else { @@ -222,7 +235,7 @@ pub fn parseCidrList( } 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{ .network = v6, .prefix_len = prefix }; + const cidr = CidrV6.fromPrefix(v6, prefix); if (is_allow) { try allow_v6_list.append(allocator, cidr); } else { @@ -243,10 +256,10 @@ pub fn parseCidrList( return .{ .v4 = v4, .v6 = v6, .allow_v4 = allow_v4, .allow_v6 = allow_v6 }; } -/// Create a heap-allocated 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. +// 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, @@ -258,42 +271,55 @@ pub fn init( } pub fn deinit(self: IpFilter, allocator: std.mem.Allocator) void { - if (self.cidrs) |c| c.deinit(allocator); + 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; + 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; - } - } - if (self.cidrs) |c| { - for (c.v4) |cidr| { - if (matchesCidrV4(addr, cidr)) return true; + 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; + 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; - } - } - if (self.cidrs) |c| { - for (c.v6) |cidr| { - if (matchesCidrV6(addr, cidr)) return true; + if (matchesCidrV6(addr, cidr)) { + return true; + } } } return false; @@ -320,7 +346,271 @@ pub fn isBlockedSockaddr(self: *const IpFilter, sa: *const libcurl.CurlSockAddr) } } -// ── Unit tests ─────────────────────────────────────────────────────────────── +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. @@ -332,178 +622,3 @@ fn testBlocked(self: *const IpFilter, ip: []const u8) bool { } unreachable; } - -test "IPv4 CIDR matching: private group boundaries" { - const t = std.testing; - const filter = IpFilter.init(true, null); - defer filter.deinit(t.allocator); - - // Loopback - try t.expect(filter.testBlocked("127.0.0.1")); - try t.expect(filter.testBlocked("127.255.255.255")); - try t.expect(!filter.testBlocked("128.0.0.1")); - - // RFC1918 10.0.0.0/8 - try t.expect(filter.testBlocked("10.0.0.1")); - try t.expect(filter.testBlocked("10.255.255.255")); - try t.expect(!filter.testBlocked("11.0.0.0")); - - // RFC1918 172.16.0.0/12 — critical boundary - try t.expect(!filter.testBlocked("172.15.255.255")); // MUST NOT block - try t.expect(filter.testBlocked("172.16.0.0")); // MUST block - try t.expect(filter.testBlocked("172.31.255.255")); // MUST block - try t.expect(!filter.testBlocked("172.32.0.0")); // MUST NOT block - - // RFC1918 192.168.0.0/16 - try t.expect(filter.testBlocked("192.168.0.1")); - try t.expect(!filter.testBlocked("192.169.0.0")); - - // Link-local - try t.expect(filter.testBlocked("169.254.1.1")); - try t.expect(!filter.testBlocked("169.255.0.0")); - - // Public IP — must NOT be blocked - try t.expect(!filter.testBlocked("8.8.8.8")); - try t.expect(!filter.testBlocked("1.1.1.1")); - try t.expect(!filter.testBlocked("93.184.216.34")); // example.com -} - -test "IPv6 CIDR matching: private group" { - const t = std.testing; - const filter = IpFilter.init(true, null); - defer filter.deinit(t.allocator); - - try t.expect(filter.testBlocked("::1")); // localhost - try t.expect(filter.testBlocked("fe80::1")); // link-local - try t.expect(filter.testBlocked("fc00::1")); // ULA - try t.expect(filter.testBlocked("fd00::1")); // ULA (fd is fc00::/7) - try t.expect(!filter.testBlocked("2001:db8::1")); // documentation range — public - try t.expect(!filter.testBlocked("2606:4700::1111")); // Cloudflare -} - -test "IPv4-mapped IPv6 bypass prevention" { - const t = std.testing; - const filter = IpFilter.init(true, null); - defer filter.deinit(t.allocator); - - // ::ffff:127.0.0.1 must be blocked (maps to loopback) - try t.expect(filter.testBlocked("::ffff:127.0.0.1")); - // ::ffff:10.0.0.1 must be blocked (maps to RFC1918) - try t.expect(filter.testBlocked("::ffff:10.0.0.1")); - // ::ffff:8.8.8.8 must NOT be blocked (maps to public) - try t.expect(!filter.testBlocked("::ffff:8.8.8.8")); -} - -test "fail-closed: unknown address family blocked by isBlockedSockaddr" { - const t = std.testing; - const filter = IpFilter.init(false, null); - defer filter.deinit(t.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 t.expect(filter.isBlockedSockaddr(&sa)); -} - -test "custom CIDR ranges" { - const t = std.testing; - const cidrs = try parseCidrList(t.allocator, "203.0.113.0/24"); - const filter = IpFilter.init(false, cidrs); - defer filter.deinit(t.allocator); - - try t.expect(filter.testBlocked("203.0.113.1")); // in custom range - try t.expect(filter.testBlocked("203.0.113.255")); // in custom range - try t.expect(!filter.testBlocked("203.0.114.0")); // outside custom range - try t.expect(!filter.testBlocked("8.8.8.8")); // not in range -} - -test "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 t = std.testing; - const filter_private = IpFilter.init(true, null); - defer filter_private.deinit(t.allocator); - const filter_none = IpFilter.init(false, null); - defer filter_none.deinit(t.allocator); - - try t.expect(filter_private.testBlocked("169.254.169.254")); // blocked via link-local - try t.expect(!filter_none.testBlocked("169.254.169.254")); // not blocked when disabled -} - -test "parseCidrList: mixed IPv4 and IPv6" { - const t = std.testing; - const cidrs = try parseCidrList(t.allocator, "203.0.113.0/24, 2001:db8::/32, 192.168.1.0/24"); - - try t.expectEqual(2, cidrs.v4.len); - try t.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(t.allocator); - try t.expect(f.testBlocked("203.0.113.1")); - try t.expect(!f.testBlocked("203.0.114.0")); - try t.expect(f.testBlocked("192.168.1.1")); - try t.expect(f.testBlocked("2001:db8::1")); - try t.expect(!f.testBlocked("2001:db9::1")); -} - -test "allow list exempts from private blocking" { - const t = std.testing; - const cidrs = try parseCidrList(t.allocator, "-10.0.0.42/32,-fc00::1/128"); - const filter = IpFilter.init(true, cidrs); - defer filter.deinit(t.allocator); - - // Allowed IPs pass through despite being in private ranges - try t.expect(!filter.testBlocked("10.0.0.42")); - try t.expect(!filter.testBlocked("fc00::1")); - - // Other private IPs still blocked - try t.expect(filter.testBlocked("10.0.0.43")); - try t.expect(filter.testBlocked("10.0.0.41")); - try t.expect(filter.testBlocked("192.168.1.1")); - try t.expect(filter.testBlocked("fc00::2")); -} - -test "allow list exempts from custom CIDR blocking" { - const t = std.testing; - const cidrs = try parseCidrList(t.allocator, "203.0.113.0/24,-203.0.113.100/32"); - const filter = IpFilter.init(false, cidrs); - defer filter.deinit(t.allocator); - - try t.expect(!filter.testBlocked("203.0.113.100")); // allowed - try t.expect(filter.testBlocked("203.0.113.99")); // blocked - try t.expect(filter.testBlocked("203.0.113.101")); // blocked -} - -test "parseCidrList: allow entries with '-' prefix" { - const t = std.testing; - const cidrs = try parseCidrList(t.allocator, "10.0.0.0/8,-10.0.0.42/32,-fc00::1/128"); - - try t.expectEqual(1, cidrs.v4.len); - try t.expectEqual(0, cidrs.v6.len); - try t.expectEqual(1, cidrs.allow_v4.len); - try t.expectEqual(1, cidrs.allow_v6.len); - - const f = IpFilter.init(false, cidrs); - defer f.deinit(t.allocator); - try t.expect(!f.testBlocked("10.0.0.42")); // allowed - try t.expect(f.testBlocked("10.0.0.43")); // blocked - try t.expect(!f.testBlocked("fc00::1")); // allowed (not blocked by custom, but allow-listed) -} - -test "parseCidrList: invalid input returns error" { - const t = std.testing; - try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "not-a-cidr")); - try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "10.0.0.0/33")); // prefix too large - try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "10.0.0.0")); // missing prefix - try t.expectError(error.InvalidCidr, parseCidrList(t.allocator, "10.0.0.0/abc")); // non-numeric prefix -} - -test { - std.testing.refAllDecls(@This()); -}