From f5cfc4d31577273336e3bf3d251d96257f0a3e92 Mon Sep 17 00:00:00 2001 From: Lucien Coffe Date: Mon, 23 Mar 2026 14:17:50 +0100 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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()); -} From 5c7a665d11a7fab5f8de72fa5e46dbe1b904883c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 9 Apr 2026 18:54:31 +0800 Subject: [PATCH 07/13] run e2e-test with pre-generated snapshot --- .github/workflows/e2e-test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index a47829bc..f069ebb5 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -51,8 +51,11 @@ jobs: - uses: ./.github/actions/install + - name: v8 snapshot + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin + - name: zig build release - run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 + run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 - name: upload artifact uses: actions/upload-artifact@v7 From 4dcb2c997e01e4367ca6118629fb4ac712f9692c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 8 Apr 2026 16:54:58 +0800 Subject: [PATCH 08/13] Better handle v8 callback with no valid context In https://github.com/lightpanda-io/browser/pull/1885 we added fallback to the incumbent context when the current context had be released (by us, but not by v8). This now handles the case where there is no incumbent context. It's not clear exactly why this can happen, but we do see it in some WPT tests (e.g. /html/browsers/the-window-object/named-access-on-the-window-object/navigated-named-objects.window.html) --- src/browser/js/Caller.zig | 26 +++++++++++++++++++++----- src/browser/js/Context.zig | 12 ++++++++---- src/browser/js/Env.zig | 2 +- src/browser/js/bridge.zig | 32 ++++++++++++++++++++++++-------- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index cd4d25d3..ed4a4119 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -39,9 +39,22 @@ prev_local: ?*const js.Local, prev_context: *Context, // Takes the raw v8 isolate and extracts the context from it. -pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void { - const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate }); +// Returns false if the context has been destroyed (e.g., navigated-away iframe), +// in which case a JS exception has been thrown and the caller should return immediately. +pub fn init(self: *Caller, v8_isolate: *v8.Isolate) bool { + const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate }) orelse { + throwDetachedError(v8_isolate); + return false; + }; initWithContext(self, ctx, v8_context); + return true; +} + +fn throwDetachedError(isolate: *v8.Isolate) void { + const message = "Cannot execute in detached context (e.g., navigated-away iframe)"; + const v8_message = v8.v8__String__NewFromUtf8(isolate, message.ptr, v8.kNormal, @intCast(message.len)); + const js_exception = v8.v8__Exception__Error(v8_message); + _ = v8.v8__Isolate__ThrowException(isolate, js_exception); } fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void { @@ -60,9 +73,9 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) ctx.local = &self.local; } -pub fn initFromHandle(self: *Caller, handle: ?*const v8.FunctionCallbackInfo) void { +pub fn initFromHandle(self: *Caller, handle: ?*const v8.FunctionCallbackInfo) bool { const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?; - self.init(isolate); + return self.init(isolate); } pub fn deinit(self: *Caller) void { @@ -538,7 +551,10 @@ pub const Function = struct { pub fn call(comptime T: type, info_handle: *const v8.FunctionCallbackInfo, func: anytype, comptime opts: Opts) void { const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?; - const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate }); + const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate }) orelse { + throwDetachedError(v8_isolate); + return; + }; const info = FunctionCallbackInfo{ .handle = info_handle }; var hs: js.HandleScope = undefined; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index beec0625..b691af95 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -138,7 +138,8 @@ pub fn fromC(c_context: *const v8.Context) ?*Context { /// Returns the Context and v8::Context for the given isolate. /// If the current context is from a destroyed Context (e.g., navigated-away iframe), /// falls back to the incumbent context (the calling context). -pub fn fromIsolate(isolate: js.Isolate) struct { *Context, *const v8.Context } { +/// Returns null if neither context has a valid Context struct (both were destroyed). +pub fn fromIsolate(isolate: js.Isolate) ?struct { *Context, *const v8.Context } { const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?; if (fromC(v8_context)) |ctx| { return .{ ctx, v8_context }; @@ -146,7 +147,8 @@ pub fn fromIsolate(isolate: js.Isolate) struct { *Context, *const v8.Context } { // The current context's Context struct has been freed (e.g., iframe navigated away). // Fall back to the incumbent context (the calling context). const v8_incumbent = v8.v8__Isolate__GetIncumbentContext(isolate.handle).?; - return .{ fromC(v8_incumbent).?, v8_incumbent }; + const ctx = fromC(v8_incumbent) orelse return null; + return .{ ctx, v8_incumbent }; } pub fn deinit(self: *Context) void { @@ -806,7 +808,9 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul const then_callback = newFunctionWithData(local, struct { pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { var c: Caller = undefined; - c.initFromHandle(callback_handle); + if (!c.initFromHandle(callback_handle)) { + return; + } defer c.deinit(); const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? }; @@ -830,7 +834,7 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul const catch_callback = newFunctionWithData(local, struct { pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { var c: Caller = undefined; - c.initFromHandle(callback_handle); + if (!c.initFromHandle(callback_handle)) return; defer c.deinit(); const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? }; diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index bae6a8f0..2c1ebf38 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -519,7 +519,7 @@ fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) v const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?; const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?; const isolate = js.Isolate{ .handle = v8_isolate }; - const ctx, const v8_context = Context.fromIsolate(isolate); + const ctx, const v8_context = Context.fromIsolate(isolate) orelse return; const local = js.Local{ .ctx = ctx, diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 8fbdc315..93c51f78 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -116,7 +116,9 @@ pub const Constructor = struct { fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; - caller.init(v8_isolate); + if (!caller.init(v8_isolate)) { + return; + } defer caller.deinit(); caller.constructor(T, func, handle.?, .{ @@ -216,7 +218,9 @@ pub const Indexed = struct { fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; - caller.init(v8_isolate); + if (!caller.init(v8_isolate)) { + return 0; + } defer caller.deinit(); return caller.getIndex(T, getter, idx, handle.?, .{ @@ -232,7 +236,9 @@ pub const Indexed = struct { fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; - caller.init(v8_isolate); + if (!caller.init(v8_isolate)) { + return 0; + } defer caller.deinit(); return caller.getEnumerator(T, enumerator, handle.?, .{}); } @@ -258,7 +264,9 @@ pub const NamedIndexed = struct { fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; - caller.init(v8_isolate); + if (!caller.init(v8_isolate)) { + return 0; + } defer caller.deinit(); return caller.getNamedIndex(T, getter, c_name.?, handle.?, .{ @@ -272,7 +280,9 @@ pub const NamedIndexed = struct { fn wrap(c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; - caller.init(v8_isolate); + if (!caller.init(v8_isolate)) { + return 0; + } defer caller.deinit(); return caller.setNamedIndex(T, setter, c_name.?, c_value.?, handle.?, .{ @@ -286,7 +296,9 @@ pub const NamedIndexed = struct { fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; - caller.init(v8_isolate); + if (!caller.init(v8_isolate)) { + return 0; + } defer caller.deinit(); return caller.deleteNamedIndex(T, deleter, c_name.?, handle.?, .{ @@ -387,7 +399,9 @@ pub const Property = struct { pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; - caller.init(v8_isolate); + if (!caller.init(v8_isolate)) { + return 0; + } defer caller.deinit(); const local = &caller.local; @@ -465,7 +479,9 @@ pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8 const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; - caller.init(v8_isolate); + if (!caller.init(v8_isolate)) { + return 0; + } defer caller.deinit(); const local = &caller.local; From 8eaeafe16cc81cd6624477e27a784b673e3b624e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 9 Apr 2026 10:07:35 +0800 Subject: [PATCH 09/13] Fix a lot of typos. I used https://github.com/crate-ci/typos, it worked well. Also, make sure cdp-initiated KeyboardEvent is freed when no element is in focus --- flake.nix | 2 +- src/Config.zig | 6 +++--- src/browser/Factory.zig | 2 +- src/browser/HttpClient.zig | 8 ++++---- src/browser/Page.zig | 15 +++++++++------ src/browser/ScriptManager.zig | 2 +- src/browser/StyleManager.zig | 2 +- src/browser/dump.zig | 2 +- src/browser/js/Caller.zig | 4 ++-- src/browser/js/Context.zig | 4 ++-- src/browser/js/Function.zig | 2 +- src/browser/js/Inspector.zig | 2 +- src/browser/js/Local.zig | 4 ++-- src/browser/js/Scheduler.zig | 4 ++-- src/browser/js/Snapshot.zig | 2 +- src/browser/parser/Parser.zig | 2 +- src/browser/tests/domimplementation.html | 2 +- src/browser/tests/domparser.html | 2 +- src/browser/tests/events.html | 2 +- .../tests/legacy/dom/document_fragment.html | 2 +- .../tests/legacy/dom/html_collection.html | 2 +- src/browser/tests/legacy/events/event.html | 6 +++--- src/browser/tests/node/normalize.html | 2 +- src/browser/tests/testing.js | 2 +- src/browser/tests/window/cross_origin.html | 2 +- src/browser/tests/window/scroll.html | 2 +- src/browser/webapi/CData.zig | 2 +- src/browser/webapi/Document.zig | 4 ++-- src/browser/webapi/Element.zig | 4 ++-- src/browser/webapi/KeyValueList.zig | 2 +- src/browser/webapi/animation/Animation.zig | 2 +- src/browser/webapi/canvas/OffscreenCanvas.zig | 2 +- src/browser/webapi/children.zig | 2 +- src/browser/webapi/element/Attribute.zig | 6 +++--- src/browser/webapi/element/html/Canvas.zig | 2 +- src/browser/webapi/event/MouseEvent.zig | 2 +- src/browser/webapi/storage/Cookie.zig | 4 ++-- .../ReadableStreamDefaultController.zig | 2 +- src/cdp/AXNode.zig | 4 ++-- src/cdp/CDP.zig | 18 +++++++++--------- src/cdp/Node.zig | 2 +- src/cdp/domains/page.zig | 2 +- src/cdp/domains/target.zig | 2 +- src/datetime.zig | 4 ++-- src/network/http.zig | 2 +- src/network/websocket.zig | 6 +++--- 46 files changed, 82 insertions(+), 79 deletions(-) diff --git a/flake.nix b/flake.nix index 330bbdf0..d306ae09 100644 --- a/flake.nix +++ b/flake.nix @@ -70,7 +70,7 @@ gcc.cc.lib crtFiles - # Libaries + # Libraries expat.dev glib.dev glibc.dev diff --git a/src/Config.zig b/src/Config.zig index 66c8e2a7..483c320f 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -692,7 +692,7 @@ fn parseServeArgs( } log.fatal(.app, "unknown argument", .{ .mode = "serve", .arg = opt }); - return error.UnkownOption; + return error.UnknownOption; } return serve; @@ -723,7 +723,7 @@ fn parseMcpArgs( } log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt }); - return error.UnkownOption; + return error.UnknownOption; } return result; @@ -865,7 +865,7 @@ fn parseFetchArgs( if (std.mem.startsWith(u8, opt, "--")) { log.fatal(.app, "unknown argument", .{ .mode = "fetch", .arg = opt }); - return error.UnkownOption; + return error.UnknownOption; } if (url != null) { diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 14770cea..79297a32 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -380,7 +380,7 @@ pub fn destroy(self: *Factory, value: anytype) void { // We should always destroy from the leaf down. if (@hasDecl(S, "_prototype_root")) { // A Event{._type == .generic} (or any other similar types) - // _should_ be destoyed directly. The _type = .generic is a pseudo + // _should_ be destroyed directly. The _type = .generic is a pseudo // child if (S != Event or value._type != .generic) { log.fatal(.bug, "factory.destroy.event", .{ .type = @typeName(S) }); diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index f5b38871..b90029ac 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -98,7 +98,7 @@ pending_robots_queue: std.StringHashMapUnmanaged(std.ArrayList(Request)) = .empt // Once we have a handle/easy to process a request with, we create a Transfer // which contains the Request as well as any state we need to process the -// request. These wil come and go with each request. +// request. These will come and go with each request. transfer_pool: std.heap.MemoryPool(Transfer), // The current proxy. CDP can change it, changeProxy(null) restores @@ -635,7 +635,7 @@ fn waitForInterceptedResponse(self: *Client, transfer: *Transfer) !bool { } // Above, request will not process if there's an interception request. In such -// cases, the interecptor is expected to call resume to continue the transfer +// cases, the interceptor is expected to call resume to continue the transfer // or transfer.abort() to abort it. fn process(self: *Client, transfer: *Transfer) !void { // libcurl doesn't allow recursive calls, if we're in a `perform()` operation @@ -772,7 +772,7 @@ fn makeRequest(self: *Client, conn: *http.Connection, transfer: *Transfer) anyer // cleaning things up. That's why the above code is in a block. If anything // fails BEFORE `curl_multi_add_handle` succeeds, the we still need to do // cleanup. But if things fail after `curl_multi_add_handle`, we expect - // perfom to pickup the failure and cleanup. + // perform to pickup the failure and cleanup. self.trackConn(conn) catch |err| { transfer._conn = null; transfer.deinit(); @@ -859,7 +859,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T } transfer._intercept_state = .pending; - // Wether or not this is a blocking request, we're not going + // Whether or not this is a blocking request, we're not going // to process it now. We can end the transfer, which will // release the easy handle back into the pool. The transfer // is still valid/alive (just has no handle). diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 7c60a573..f12b606b 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -97,7 +97,7 @@ _parse_mode: enum { document, fragment, document_write } = .document, // identity (a given attribute should return the same *Attribute), so we do // a look here. We don't store this in the Element or Attribute.List.Entry // because that would require additional space per element / Attribute.List.Entry -// even thoug we'll create very few (if any) actual *Attributes. +// even though we'll create very few (if any) actual *Attributes. _attribute_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute) = .empty, // Same as _atlribute_lookup, but instead of individual attributes, this is for @@ -479,7 +479,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi // even though this might be the same _data_ as `default_location`, we // have to do this to make sure window.location is at a unique _address_. - // If we don't do this, mulitple window._location will have the same + // If we don't do this, multiple window._location will have the same // address and thus be mapped to the same v8::Object in the identity map. self.window._location = try Location.init(self.url, self); @@ -689,7 +689,7 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: }); // This is a micro-optimization. Terminate any inflight request as early - // as we can. This will be more propery shutdown when we process the + // as we can. This will be more properly shutdown when we process the // scheduled navigation. if (target.parent == null) { session.browser.http_client.abort(); @@ -1173,7 +1173,7 @@ pub fn iframeAddedCallback(self: *Page, iframe: *IFrame) !void { iframe._window = page_frame.window; errdefer iframe._window = null; - // on first load, dispatch frame_created evnet + // on first load, dispatch frame_created event self._session.notification.dispatch(.page_frame_created, &.{ .frame_id = frame_id, .parent_id = self._frame_id, @@ -3202,7 +3202,7 @@ const IdleNotification = union(enum) { init, // timestamp where the state was first triggered. If the state stays - // true (e.g. 0 nework activity for NetworkIdle, or <= 2 for NetworkAlmostIdle) + // true (e.g. 0 network activity for NetworkIdle, or <= 2 for NetworkAlmostIdle) // for 500ms, it'll send the notification and transition to .done. If // the state doesn't stay true, it'll revert to .init. triggered: u64, @@ -3457,7 +3457,10 @@ pub fn handleClick(self: *Page, target: *Node) !void { pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { const event = keyboard_event.asEvent(); - const element = self.window._document._active_element orelse return; + const element = self.window._document._active_element orelse { + event.deinit(self._session); + return; + }; if (comptime IS_DEBUG) { log.debug(.page, "page keydown", .{ diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 546a05c5..984ecccc 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -71,7 +71,7 @@ allocator: Allocator, // source is placed here (keyed by the full url) for some point in the future // when v8 asks for it. // The type is confusing (too confusing? move to a union). Starts of as `null` -// then transitions to either an error (from errorCalback) or the completed +// then transitions to either an error (from errorCallback) or the completed // buffer from doneCallback imported_modules: std.StringHashMapUnmanaged(ImportedModule), diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index f7a69672..161ebca0 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -667,7 +667,7 @@ const VisibilityProperties = struct { opacity_zero: ?bool = null, pointer_events_none: ?bool = null, - // returne true if any field in VisibilityProperties is not null + // return true if any field in VisibilityProperties is not null fn isRelevant(self: VisibilityProperties) bool { return self.display_none != null or self.visibility_hidden != null or diff --git a/src/browser/dump.zig b/src/browser/dump.zig index bb666e7f..abe322a1 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -40,7 +40,7 @@ pub const Opts = struct { // Skip shadow DOM entirely (innerHTML/outerHTML) skip, - // Dump everyhting (like "view source") + // Dump everything (like "view source") complete, // Resolve slot elements (like what actually gets rendered) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index cd4d25d3..74b2d420 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -348,7 +348,7 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any error.InvalidArgument => isolate.createTypeError("invalid argument"), error.TypeError => isolate.createTypeError(""), error.OutOfMemory => isolate.createError("out of memory"), - error.IllegalConstructor => isolate.createError("Illegal Contructor"), + error.IllegalConstructor => isolate.createError("Illegal Constructor"), else => blk: { if (comptime opts.dom_exception) { const DOMException = @import("../webapi/DOMException.zig"); @@ -617,7 +617,7 @@ pub const Function = struct { if (v8.v8__Object__GetInternalField(js_this, idx)) |cached| { // means we can't cache undefined, since we can't tell the // difference between "it isn't in the cache" and "it's - // in the cache with a valud of undefined" + // in the cache with a value of undefined" if (!v8.v8__Value__IsUndefined(cached)) { return_value.set(cached); return true; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index beec0625..cea5930c 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -114,7 +114,7 @@ scheduler: Scheduler, unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {}, const ModuleEntry = struct { - // Can be null if we're asynchrously loading the module, in + // Can be null if we're asynchronously loading the module, in // which case resolver_promise cannot be null. module: ?js.Module.Global = null, @@ -742,7 +742,7 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c // since we're going to be doing all the work. entry.resolver_promise = try promise.persist(); - // But we can skip direclty to `resolveDynamicModule` which is + // But we can skip directly to `resolveDynamicModule` which is // what the above callback will eventually do. self.resolveDynamicModule(state, entry.*, local); return promise; diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 16664cbe..8599bc73 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -146,7 +146,7 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args } break :blk values; }, - else => @compileError("JS Function called with invalid paremter type"), + else => @compileError("JS Function called with invalid parameter type"), }; const c_args = @as(?[*]const ?*v8.Value, @ptrCast(js_args.ptr)); diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index aa35be7b..d956cc52 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -195,7 +195,7 @@ pub const RemoteObject = struct { // Combines a v8::InspectorSession and a v8::InspectorChannelImpl. The // InspectorSession is for zig -> v8 (sending messages to the inspector). The // Channel is for v8 -> zig, getting events from the Inspector (that we'll pass -// back ot some opaque context, i.e the CDP BrowserContext). +// back to some opaque context, i.e the CDP BrowserContext). // The channel callbacks are defined below, as: // pub export fn v8_inspector__Channel__IMPL__XYZ pub const Session = struct { diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index c9a3b880..4d91ed2e 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -39,7 +39,7 @@ const CallOpts = Caller.CallOpts; // v8::Local. In V8, you need a Local or get anything // done, but the local only exists for the lifetime of the HandleScope it was // created on. When V8 calls into Zig, things are pretty straightforward, since -// that callback gives us the currenty-entered V8::Local. But when Zig +// that callback gives us the currently-entered V8::Local. But when Zig // has to call into V8, it's a bit more messy. // As a general rule, think of it this way: // 1 - Caller.zig is for V8 -> Zig @@ -503,7 +503,7 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T { .optional => |o| { // If type type is a ?js.Value or a ?js.Object, then we want to pass // a js.Object, not null. Consider a function, - // _doSomething(arg: ?Env.JsObjet) void { ... } + // _doSomething(arg: ?Env.JsObject) void { ... } // // And then these two calls: // doSomething(); diff --git a/src/browser/js/Scheduler.zig b/src/browser/js/Scheduler.zig index 322351f3..d9fb417e 100644 --- a/src/browser/js/Scheduler.zig +++ b/src/browser/js/Scheduler.zig @@ -82,7 +82,7 @@ pub fn run(self: *Scheduler) !void { pub fn hasReadyTasks(self: *Scheduler) bool { const now = milliTimestamp(.monotonic); - return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now); + return queueHasReadyTask(&self.low_priority, now) or queueHasReadyTask(&self.high_priority, now); } pub fn msToNextHigh(self: *Scheduler) ?u64 { @@ -125,7 +125,7 @@ fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void { return; } -fn queueuHasReadyTask(queue: *Queue, now: u64) bool { +fn queueHasReadyTask(queue: *Queue, now: u64) bool { const task = queue.peek() orelse return false; return task.run_at <= now; } diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index abea4ee5..5a04861a 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -32,7 +32,7 @@ const embedded_snapshot_blob = if (@import("build_config").snapshot_path) |path| // When creating our Snapshot, we use local function templates for every Zig type. // You cannot, from what I can tell, create persisted FunctionTemplates at -// snapshot creation time. But you can embedd those templates (or any other v8 +// snapshot creation time. But you can embed those templates (or any other v8 // Data) so that it's available to contexts created from the snapshot. This is // the starting index of those function templates, which we can extract. At // creation time, in debug, we assert that this is actually a consecutive integer diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index 0c06cbcc..df85c425 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -35,7 +35,7 @@ pub const ParsedNode = struct { // Data associated with this element to be passed back to html5ever as needed // We only have this for Elements. For other types, like comments, it's null. // html5ever should never ask us for this data on a non-element, and we'll - // assert that, with this opitonal, to make sure our assumption is correct. + // assert that, with this optional, to make sure our assumption is correct. data: ?*anyopaque, }; diff --git a/src/browser/tests/domimplementation.html b/src/browser/tests/domimplementation.html index 302db88a..76b1a50f 100644 --- a/src/browser/tests/domimplementation.html +++ b/src/browser/tests/domimplementation.html @@ -108,7 +108,7 @@ } -
- - - - diff --git a/src/browser/tests/node/normalize.html b/src/browser/tests/node/normalize.html index b85f8324..92c20a35 100644 --- a/src/browser/tests/node/normalize.html +++ b/src/browser/tests/node/normalize.html @@ -35,7 +35,7 @@ Atreides -