Wiaoj.Net 0.2.0-alpha.3

This is a prerelease version of Wiaoj.Net.
dotnet add package Wiaoj.Net --version 0.2.0-alpha.3
                    
NuGet\Install-Package Wiaoj.Net -Version 0.2.0-alpha.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Wiaoj.Net" Version="0.2.0-alpha.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Wiaoj.Net" Version="0.2.0-alpha.3" />
                    
Directory.Packages.props
<PackageReference Include="Wiaoj.Net" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Wiaoj.Net --version 0.2.0-alpha.3
                    
#r "nuget: Wiaoj.Net, 0.2.0-alpha.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Wiaoj.Net@0.2.0-alpha.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Wiaoj.Net&version=0.2.0-alpha.3&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Wiaoj.Net&version=0.2.0-alpha.3&prerelease
                    
Install as a Cake Tool

Wiaoj.Net

Small building blocks on top of System.Net. The first one decides which IP addresses outbound connections may reach, and enforces that decision where the socket is opened. This is the defence against server-side request forgery (SSRF): a URL supplied by a user or by another service must not become a request to 169.254.169.254 (cloud metadata), localhost, or your internal network.

Installation

dotnet add package Wiaoj.Net

Usage

// Only public addresses
services.AddHttpClient<WebhookSender>()
    .AddOutboundNetworkPolicy(OutboundNetworkPolicy.PublicOnly);

// Public addresses, plus our own internal network
services.AddHttpClient<PartnerClient>()
    .AddOutboundNetworkPolicy(policy => policy with {
        AllowedNetworks = [IPNetwork.Parse("10.20.0.0/16")]
    });

Without dependency injection:

HttpClient client = new(new SocketsHttpHandler().UseOutboundNetworkPolicy(OutboundNetworkPolicy.PublicOnly));

A refused connection throws HttpRequestException, with an OutboundNetworkPolicyException as its inner exception. The message names the host and port but not the addresses it resolved to, so it gives no map of the internal network.

Checking a URL when it is registered

To validate a URL someone supplied before any request is made, for example when a webhook endpoint is registered, check its host. The check returns a result, not an exception:

OutboundHostCheck check = await OutboundNetworkPolicy.PublicOnly.CheckHostAsync(new Uri(url), ct);

switch(check.Status) {
    case OutboundHostStatus.Allowed:      /* register it */ break;
    case OutboundHostStatus.Refused:      /* "this address is not allowed" */ break;
    case OutboundHostStatus.Unresolvable: /* "this host does not exist" — check.ResolutionError */ break;
}

This is an early answer, not the protection. DNS can answer differently by the time a request is sent, so the connection-time enforcement below is still what stops the request.

The three parts

Type What it does Shape
IPAddressClassifier.Classify(address) Returns an IPAddressScope: Public, Private, Loopback, LinkLocal, CarrierGradeNat, Documentation, … Static, pure
OutboundNetworkPolicy Decides whether an address is allowed Immutable record, derived with with
UseOutboundNetworkPolicy / AddOutboundNetworkPolicy Enforces the policy on the connections a SocketsHttpHandler opens Handler / IHttpClientBuilder extension

How the policy decides

  1. Blocked: if the address, or the IPv4 address it carries, is in BlockedNetworks, it is refused.
  2. Allowed exception: if the address is in AllowedNetworks, it is allowed.
  3. Scope: otherwise it is allowed only when its scope is in AllowedScopes.

Presets:

Preset Addresses Ports
PublicOnly (the default) Public Any
WebOnly Public 80 and 443
Unrestricted (for development) Any Any

Ports

SSRF often targets an internal service by its port as much as by its address: Redis on 6379, the Docker API on 2375, SSH on 22. Ports are checked before addresses:

  • BlockedPorts: refused whatever else allows them.
  • AllowedPorts: when set, only these ports are allowed. null (the default) allows any port.
// A webhook receiver on 8443 as well as the web ports
OutboundNetworkPolicy policy = OutboundNetworkPolicy.PublicOnly with { AllowedPorts = new HashSet<int> { 80, 443, 8443 } };

PublicOnly keeps allowing any port, because webhook receivers legitimately listen on ports such as 8443.

  • At connection time: a refused port is refused before the host is resolved, with OutboundNetworkPolicyException whose Reason is Port.
  • CheckHostAsync(Uri): uses the URL's explicit port or its scheme's default (80 for http, 443 for https), and returns Refused with RefusalReason Port. A host name alone has no port, so CheckHostAsync(string) applies only the address rules.

IPv4 hidden inside IPv6

A check that looks only at the literal address can be bypassed. Every address below reaches the cloud metadata endpoint 169.254.169.254, and each one is recognised:

Form Example RFC
IPv4-mapped ::ffff:169.254.169.254 4291
6to4 2002:a9fe:a9fe:: 3056
NAT64, well-known prefix 64:ff9b::a9fe:a9fe 6052
IPv4-translated (SIIT) ::ffff:0:a9fe:a9fe 2765
Teredo 2001:0:…:5601:5601 (inverted client address) 4380
ISATAP interface identifier, under any prefix 2606:4700::5efe:a9fe:a9fe 5214

The classifier extracts the IPv4 address and classifies the whole address by it. When one address carries two IPv4 addresses (for example a 6to4 prefix with an ISATAP identifier), the more restrictive one decides.

The local-use translation prefix 64:ff9b:1::/48 (RFC 8215) is Reserved as a whole. It translates to addresses inside the operator's network with an embedding the RFC leaves undefined, so its IPv4 address can't be extracted. IANA lists the prefix as not globally reachable.

Allowed networks and embedded addresses: an AllowedNetworks entry also covers a translated address (NAT64 or SIIT) whose IPv4 address is inside it, because the translator delivers to that IPv4 host. A tunnelled address (6to4, Teredo, ISATAP) carries a tunnel endpoint, not the host it reaches, so it is not covered. Blocked networks are checked against every embedded address.

Limitation: a NAT64 translator with a network-specific prefix taken from an operator's own global address space (RFC 6052) looks like any public address. If your network has one, add its prefix to BlockedNetworks.

Why checking at connection time matters

If a URL's host is resolved, checked, and then connected to separately, DNS can return a different address the second time (DNS rebinding). Here the handler resolves the host itself and connects to the address it checked, so there is no second lookup.

AddOutboundNetworkPolicy applies the policy after all of the client's handler configuration has run. A later ConfigurePrimaryHttpMessageHandler call can't silently replace the protected handler, and that handler's own settings, such as AllowAutoRedirect = false, are kept.

Hosts with several addresses

A host often resolves to several addresses, for example an IPv6 and an IPv4 one. Addresses the policy refuses are dropped first and never attempted. The allowed ones are connected to as Happy Eyeballs v2 (RFC 8305) describes:

  • Order: address families are interleaved, starting with the family of the first address the resolver returned (IPv6, IPv4, IPv6, …).
  • Racing: each attempt gets a 250 ms head start (the delay RFC 8305 recommends). If it hasn't connected by then, the next attempt starts alongside it, and an attempt that fails starts the next one at once.
  • Winner: the first connection is used. The other attempts are cancelled, and a socket one of them still opens is closed.
  • Failure: the request fails only when every attempt has failed, with the last attempt's SocketException. The handler's ConnectTimeout bounds all attempts together.

Without racing, an IPv6 path that silently drops packets would use up the whole connect timeout, and the healthy IPv4 address would never be tried.

DNS resolution

Host names are resolved with a DnsResolver. The default is DnsResolver.System, the operating system resolver. To use a corporate resolver, or a fake one in tests, register a replacement. AddOutboundNetworkPolicy and Webhooks use the registered resolver automatically:

services.AddSingleton<DnsResolver, ConsulDnsResolver>();

sealed class ConsulDnsResolver : DnsResolver {
    public override ValueTask<IPAddress[]> ResolveAsync(string host, CancellationToken ct) => /* … */;
}

Without dependency injection, pass the resolver to UseOutboundNetworkPolicy(policy, resolver) or CheckHostAsync(host, resolver).

  • IP literals never reach the resolver; the policy decides them directly.
  • Unresolvable hosts: throw SocketException for a host that cannot be resolved, as the system resolver does. CheckHostAsync reports it as Unresolvable, and any other exception propagates.
  • Why an abstract class: like TimeProvider, so members added later can be virtual without breaking existing implementations.

Metrics

The Wiaoj.Net meter (System.Diagnostics.Metrics, no extra dependency) publishes:

Instrument Type Tags
wiaoj.net.outbound.refused Counter, {refusal} stage: connect (where the socket opens, exact) or request (before a proxied request is sent, best effort). reason: address, port or blocked_network. scope: the IPAddressScope of the address that decided the refusal, in snake case (loopback, link_local, carrier_grade_nat, …); absent for port
wiaoj.net.dns.resolution.duration Histogram, seconds outcome: success, failure or cancelled
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter("Wiaoj.Net"));
  • What is counted: outbound.refused counts destinations refused by UseOutboundNetworkPolicy / AddOutboundNetworkPolicy (stage=connect) and by ProxiedDestinationCheckHandler / AddProxiedDestinationCheck (stage=request). A proxied deployment therefore does not read as "no refusals". CheckHostAsync on its own is an answer to a question, not an outbound request, so it records no refusal.
  • Several addresses: when a host resolves to several refused addresses, the refusal is counted once, as blocked_network if any address is in a blocked network, otherwise as address.
  • DNS timing: the histogram covers host names resolved through the DnsResolver, including a custom one. IP literals are not resolved, so they are not recorded. For DnsResolver.System, .NET's own dns.lookup.duration measures the same lookup.
  • No host, port or address tags: those values come from whoever supplied the URL, and an attacker could make each one unique and flood the metrics backend.
  • Packages keep their own context: Webhooks counts refused deliveries per endpoint in wiaoj.webhooks.ssrf.blocked.count. That is the same event at another level, not a second one.

Proxies

Behind a proxy, the connection goes to the proxy, and the proxy reaches the destination. The client never sees the destination's address, so it can't check it:

  • An explicitly configured proxy is refused with InvalidOperationException.
  • The environment proxy (HTTPS_PROXY) is turned off (UseProxy = false), so it can't silently take over the connection.

If your traffic must go through a proxy, enforce egress rules at the proxy (for example Smokescreen or Squid ACLs). On the client, destinations can still be checked before each request is sent:

services.AddHttpClient<PartnerClient>()
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { Proxy = new WebProxy("http://egress:3128") })
    .AddProxiedDestinationCheck(OutboundNetworkPolicy.PublicOnly);

This check is best effort, so it doesn't replace the proxy's rules:

  • Ports and IP literals are decided exactly.
  • Host names are resolved here, but the proxy resolves them again and may get a different answer.
  • A name that doesn't resolve here is let through, because the proxy may resolve names this host can't.

A refusal throws OutboundNetworkPolicyException and is counted with stage=request.

Requirements

The primary handler must be a SocketsHttpHandler, which is the default in IHttpClientFactory. With any other handler, creating the client throws, rather than sending requests unprotected.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0-alpha.3 45 9/24/2026
0.2.0-alpha.2 44 9/24/2026
0.2.0-alpha.1 44 9/24/2026
0.1.0-alpha.9 49 9/21/2026
0.1.0-alpha.8 44 9/21/2026
0.1.0-alpha.7 52 9/18/2026
0.1.0-alpha.6 45 9/16/2026
0.1.0-alpha.5 49 9/16/2026
0.1.0-alpha.4 47 9/16/2026
0.1.0-alpha.3 41 9/15/2026
0.1.0-alpha.2 44 9/15/2026
0.1.0-alpha.1 47 9/14/2026