Wiaoj.Net
0.2.0-alpha.3
dotnet add package Wiaoj.Net --version 0.2.0-alpha.3
NuGet\Install-Package Wiaoj.Net -Version 0.2.0-alpha.3
<PackageReference Include="Wiaoj.Net" Version="0.2.0-alpha.3" />
<PackageVersion Include="Wiaoj.Net" Version="0.2.0-alpha.3" />
<PackageReference Include="Wiaoj.Net" />
paket add Wiaoj.Net --version 0.2.0-alpha.3
#r "nuget: Wiaoj.Net, 0.2.0-alpha.3"
#:package Wiaoj.Net@0.2.0-alpha.3
#addin nuget:?package=Wiaoj.Net&version=0.2.0-alpha.3&prerelease
#tool nuget:?package=Wiaoj.Net&version=0.2.0-alpha.3&prerelease
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
- Blocked: if the address, or the IPv4 address it carries, is in
BlockedNetworks, it is refused. - Allowed exception: if the address is in
AllowedNetworks, it is allowed. - 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
OutboundNetworkPolicyExceptionwhoseReasonisPort. CheckHostAsync(Uri): uses the URL's explicit port or its scheme's default (80 for http, 443 for https), and returnsRefusedwithRefusalReasonPort. A host name alone has no port, soCheckHostAsync(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'sConnectTimeoutbounds 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
SocketExceptionfor a host that cannot be resolved, as the system resolver does.CheckHostAsyncreports it asUnresolvable, and any other exception propagates. - Why an abstract class: like
TimeProvider, so members added later can bevirtualwithout 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.refusedcounts destinations refused byUseOutboundNetworkPolicy/AddOutboundNetworkPolicy(stage=connect) and byProxiedDestinationCheckHandler/AddProxiedDestinationCheck(stage=request). A proxied deployment therefore does not read as "no refusals".CheckHostAsyncon 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_networkif any address is in a blocked network, otherwise asaddress. - 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. ForDnsResolver.System, .NET's owndns.lookup.durationmeasures 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 | Versions 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. |
-
net10.0
- Microsoft.Extensions.Http (>= 10.0.0)
- Wiaoj.Preconditions (>= 0.2.0-alpha.3)
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 |