//! Outbound fetches to URLs that arrive from callers, DID documents, or client //! metadata. Such a URL may only reach a public unicast address, and the //! connection is pinned to the address that was checked so a second DNS answer //! cannot move it. Redirects are refused because their targets are unchecked. const std = @import("std"); const zat = @import("zat"); const config = @import("../core/config.zig"); pub const Error = error{ ForbiddenScheme, MissingHost, ForbiddenAddress, NoPublicAddress, }; pub const Target = struct { host: []const u8, dial_host: ?[]const u8, pub fn resolvedConnection(self: Target) ?zat.HttpTransport.ResolvedConnection { return .{ .dial_host = self.dial_host orelse return null, .logical_host = self.host }; } }; /// Fetch `options.url` only if it reaches public unicast addresses. pub fn fetch( allocator: std.mem.Allocator, io: std.Io, transport: *zat.HttpTransport, options: zat.HttpTransport.FetchOptions, ) !zat.HttpTransport.FetchResult { return transport.fetch(try guard(allocator, io, options)); } /// Pin `options` to a checked public address and refuse redirects. pub fn guard( allocator: std.mem.Allocator, io: std.Io, options: zat.HttpTransport.FetchOptions, ) !zat.HttpTransport.FetchOptions { var guarded = options; guarded.on_redirect = .refuse; if (config.allowPrivateFetch()) return guarded; const target = try resolve(allocator, io, options.url); guarded.resolved_connection = target.resolvedConnection(); return guarded; } /// Check a URL's host and choose the IPv4 address to dial. Every address the /// name resolves to must be public; one private answer rejects the name. pub fn resolve(allocator: std.mem.Allocator, io: std.Io, url: []const u8) !Target { const uri = std.Uri.parse(url) catch return error.MissingHost; if (!std.ascii.eqlIgnoreCase(uri.scheme, "https")) return error.ForbiddenScheme; const raw_host = switch (uri.host orelse return error.MissingHost) { .raw, .percent_encoded => |text| text, }; if (std.Io.net.IpAddress.parse(std.mem.trim(u8, raw_host, "[]"), 0)) |address| { if (!isPublic(address)) return error.ForbiddenAddress; return .{ .host = try allocator.dupe(u8, raw_host), .dial_host = null }; } else |_| {} var host_buf: [std.Io.net.HostName.max_len]u8 = undefined; const host_name = std.Io.net.HostName.fromUri(uri, &host_buf) catch return error.MissingHost; const host = try allocator.dupe(u8, host_name.bytes); const bare = std.mem.trimEnd(u8, host, "."); if (bare.len == 0 or std.ascii.eqlIgnoreCase(bare, "localhost") or std.ascii.endsWithIgnoreCase(bare, ".localhost")) { return error.ForbiddenAddress; } var results: [32]std.Io.net.HostName.LookupResult = undefined; var queue: std.Io.Queue(std.Io.net.HostName.LookupResult) = .init(&results); host_name.lookup(io, &queue, .{ .port = 443 }) catch return error.NoPublicAddress; var dial: ?[4]u8 = null; var seen = false; while (queue.getOne(io)) |result| switch (result) { .address => |address| { seen = true; if (!isPublic(address)) return error.ForbiddenAddress; if (dial == null) switch (address) { .ip4 => |ip4| dial = ip4.bytes, .ip6 => {}, }; }, .canonical_name => {}, } else |err| switch (err) { error.Canceled => |e| return e, error.Closed => {}, } if (!seen) return error.NoPublicAddress; const bytes = dial orelse return error.NoPublicAddress; return .{ .host = host, .dial_host = try std.fmt.allocPrint(allocator, "{d}.{d}.{d}.{d}", .{ bytes[0], bytes[1], bytes[2], bytes[3] }), }; } pub fn isPublic(address: std.Io.net.IpAddress) bool { return switch (address) { .ip4 => |ip4| isPublicIp4(ip4.bytes), .ip6 => |ip6| isPublicIp6(ip6.bytes), }; } const Ip4Range = struct { network: [4]u8, prefix: u6 }; const blocked_ip4 = [_]Ip4Range{ .{ .network = .{ 0, 0, 0, 0 }, .prefix = 8 }, .{ .network = .{ 10, 0, 0, 0 }, .prefix = 8 }, .{ .network = .{ 100, 64, 0, 0 }, .prefix = 10 }, .{ .network = .{ 127, 0, 0, 0 }, .prefix = 8 }, .{ .network = .{ 169, 254, 0, 0 }, .prefix = 16 }, .{ .network = .{ 172, 16, 0, 0 }, .prefix = 12 }, .{ .network = .{ 192, 0, 0, 0 }, .prefix = 24 }, .{ .network = .{ 192, 0, 2, 0 }, .prefix = 24 }, .{ .network = .{ 192, 88, 99, 0 }, .prefix = 24 }, .{ .network = .{ 192, 168, 0, 0 }, .prefix = 16 }, .{ .network = .{ 198, 18, 0, 0 }, .prefix = 15 }, .{ .network = .{ 198, 51, 100, 0 }, .prefix = 24 }, .{ .network = .{ 203, 0, 113, 0 }, .prefix = 24 }, .{ .network = .{ 224, 0, 0, 0 }, .prefix = 4 }, .{ .network = .{ 240, 0, 0, 0 }, .prefix = 4 }, }; fn isPublicIp4(ip: [4]u8) bool { const candidate = std.mem.readInt(u32, &ip, .big); for (blocked_ip4) |range| { const network = std.mem.readInt(u32, &range.network, .big); const mask = ~@as(u32, 0) << @intCast(32 - range.prefix); if (candidate & mask == network & mask) return false; } return true; } fn isPublicIp6(ip: [16]u8) bool { // IPv4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96) inherit the IPv4 policy. if (std.mem.allEqual(u8, ip[0..10], 0) and ip[10] == 0xff and ip[11] == 0xff) return isPublicIp4(ip[12..16].*); if (std.mem.eql(u8, ip[0..12], &[_]u8{ 0x00, 0x64, 0xff, 0x9b } ++ @as([8]u8, @splat(0)))) return isPublicIp4(ip[12..16].*); // Only global unicast (2000::/3), minus the special-purpose 2001::/23, // documentation 2001:db8::/32, and 6to4 2002::/16 blocks. if (ip[0] & 0xe0 != 0x20) return false; if (ip[0] == 0x20 and ip[1] == 0x01 and ip[2] & 0xfe == 0) return false; if (ip[0] == 0x20 and ip[1] == 0x01 and ip[2] == 0x0d and ip[3] == 0xb8) return false; if (ip[0] == 0x20 and ip[1] == 0x02) return false; return true; } test "only public unicast addresses pass" { const cases = [_]struct { text: []const u8, public: bool }{ .{ .text = "8.8.8.8", .public = true }, .{ .text = "127.0.0.1", .public = false }, .{ .text = "10.1.2.3", .public = false }, .{ .text = "172.31.0.1", .public = false }, .{ .text = "172.32.0.1", .public = true }, .{ .text = "192.168.1.1", .public = false }, .{ .text = "169.254.169.254", .public = false }, .{ .text = "100.100.100.100", .public = false }, .{ .text = "0.0.0.0", .public = false }, .{ .text = "255.255.255.255", .public = false }, .{ .text = "2606:4700::1111", .public = true }, .{ .text = "::1", .public = false }, .{ .text = "fc00::1", .public = false }, .{ .text = "fdaa::3", .public = false }, .{ .text = "fe80::1", .public = false }, .{ .text = "::ffff:127.0.0.1", .public = false }, .{ .text = "::ffff:8.8.8.8", .public = true }, .{ .text = "64:ff9b::a00:1", .public = false }, .{ .text = "2001:db8::1", .public = false }, .{ .text = "2002:a00:1::", .public = false }, }; for (cases) |case| { const address = try std.Io.net.IpAddress.parse(case.text, 0); try std.testing.expectEqual(case.public, isPublic(address)); } } test "literal and named private hosts are refused before any connection" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); const io = std.Options.debug_io; try std.testing.expectError(error.ForbiddenScheme, resolve(a, io, "http://example.com/x")); try std.testing.expectError(error.ForbiddenAddress, resolve(a, io, "https://127.0.0.1/x")); try std.testing.expectError(error.ForbiddenAddress, resolve(a, io, "https://[::1]:8443/x")); try std.testing.expectError(error.ForbiddenAddress, resolve(a, io, "https://169.254.169.254/latest/meta-data")); try std.testing.expectError(error.ForbiddenAddress, resolve(a, io, "https://localhost/x")); try std.testing.expectError(error.ForbiddenAddress, resolve(a, io, "https://app.localhost./x")); const public = try resolve(a, io, "https://8.8.8.8/x"); try std.testing.expect(public.dial_host == null); }