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