Skip to content

URL Safety Validation

Granit.Http.UrlSafety is a tiny, dependency-light package that consolidates the URL-safety primitives that used to be duplicated across Granit.Webhooks (WebhookSsrfGuard) and Granit.Browsing (private SSRF guard). Any code in the framework that takes a Uri from untrusted input — webhook subscriber URLs, headless browser navigation, OEmbed/Open Graph unfurling, federated identity discovery documents — funnels through IUrlSafetyValidator.

Server-Side Request Forgery (SSRF) is one of the highest-impact classes of bugs in modern applications:

  • OWASP API Security Top 10 — API7:2023 — Server-Side Request Forgery.
  • OWASP ASVS v4.0.3 — V12.6.1 — “Verify that the web or application server is configured with a deny list of resources […] including loopback, link-local, and private addresses.”
  • Cloud metadata endpoints. AWS IMDSv1 at 169.254.169.254 is the canonical example; GCP (metadata.google.internal / 169.254.169.254), Azure (169.254.169.254), Alibaba, DigitalOcean all expose similar endpoints. A successful SSRF against IMDS can return temporary IAM credentials.
  • DNS rebinding. An attacker registers evil.example.com, returns 203.0.113.10 on the initial DNS lookup (passes validation), then flips to 127.0.0.1 on the second resolution (executed by the HTTP client). Mitigated by validating resolved addresses and re-checking at connect time.
  • Internal-only services. Many production deployments expose Redis, Elasticsearch, internal admin panels, Kubernetes API at 10.0.0.0/8 or localhost without authentication; an unguarded outbound HTTP call from the application is enough to reach them.
  • Reserved TLDs. .local (mDNS / Bonjour), .onion (Tor), .test, .example, .invalid MUST never be reached from a production service.

IUrlSafetyValidator is the single point in the framework that codifies the deny list.

public interface IUrlSafetyValidator
{
ValueTask<UrlSafetyResult> ValidateAsync(Uri uri, CancellationToken ct = default);
}
public readonly record struct UrlSafetyResult
{
public bool IsValid { get; }
public UrlSafetyViolation? Violation { get; }
public IReadOnlyList<IPAddress> ResolvedAddresses { get; }
}
public sealed record UrlSafetyViolation(
UrlSafetyViolationKind Kind,
string Reason, // English fallback — logs/telemetry
string LocalizationKey, // e.g. "UrlSafety:Loopback" (18 cultures)
IReadOnlyList<object?> Args);

A UrlSafetyResult is the only return type — there is no overload that throws. Callers decide whether a violation becomes a ValidationException, a Problem(400), an audit log entry, or a silent drop. The LocalizationKey resolves through Granit’s IStringLocalizer stack, so a violation surfaced to an end user is localized like any other framework message.

UrlSafetyViolationKindTrigger
SchemeNotAllowedURL scheme not in AllowedSchemes (default: https).
MalformedUrlParsed Uri is relative, has no host, or has an embedded credential (https://user:pass@host).
HostBlockedHost is on the built-in deny list (e.g. metadata.google.internal).
HostPatternNotAllowedHost doesn’t match any pattern in AllowedHostPatterns (when configured).
HostPatternDeniedHost matches a pattern in DeniedHostPatterns.
PrivateNetworkResolved address is in RFC 1918 (10/8, 172.16/12, 192.168/16) or carrier-grade NAT (100.64/10).
LoopbackResolved address is in 127/8 or ::1.
LinkLocalResolved address is link-local (169.254/16, fe80::/10).
IPv6UniqueLocalResolved address is an IPv6 unique-local address (fc00::/7).
IPv6EmbeddedIPv4Resolved address is an IPv6 transition address whose embedded IPv4 is sensitive.
MetadataEndpointHost or resolved address matches a known cloud metadata IP (169.254.169.254, metadata.google.internal, fd00:ec2::254).
ReservedAddressResolved address is multicast, broadcast, or in IETF-reserved blocks (0/8, 240/4, etc.).
ReservedTldHost TLD is reserved (.local, .onion, .test, .example, .invalid, .localhost).
DnsResolutionFailedDNS lookup timed out or returned NXDOMAIN. Fail-closed: treated as a violation, never as “OK”.
UrlTooLongURL length exceeds MaxUrlLength (default: 2048).
appsettings.json
{
"Http:UrlSafety": {
"AllowedSchemes": [ "https" ],
"AllowPrivateNetworks": false,
"AllowLoopback": false,
"AllowedHostPatterns": [ ],
"DeniedHostPatterns": [ "*.internal.example.com" ],
"MaxUrlLength": 2048,
"DnsResolveTimeout": "00:00:01"
}
}

UrlSafetyOptions properties:

PropertyDefaultNotes
AllowedSchemes[ "https" ]http MUST be opt-in. data, ftp, gopher, dict are always denied.
AllowPrivateNetworksfalseSet to true only for on-prem deployments where reaching 10.0.0.0/8 is intended.
AllowLoopbackfalseSet to true only for integration tests against localhost:N.
AllowIdntrueAccept internationalized domain names (punycode-normalized before validation).
AllowFileSchemefalseEscape hatch for trusted local pipelines. Leave false in web hosts.
AllowedHostPatterns[]When non-empty, host MUST match at least one pattern. Glob (*.example.com).
DeniedHostPatterns[]Evaluated before allow-list; explicit deny always wins.
MaxUrlLength2048RFC 7230 §3.1.1 recommends ≥8000; 2048 matches IE-era infrastructure floor.
DnsResolveTimeout1 sShort timeout — DNS lookups should be cache-hot. Exceeding it is a violation.
Program.cs
services.AddGranitUrlSafety(opts =>
{
opts.AllowedHostPatterns = [ "api.partner.com", "*.cdn.example.com" ];
});

AddGranitUrlSafety registers IUrlSafetyValidator as a singleton. The validator is DNS-cache-aware — repeated calls within DnsResolveTimeout for the same host reuse the previous resolution.

services.AddGranitUrlSafety();
services.Replace(ServiceDescriptor.Singleton<IUrlSafetyValidator, MyCorporateUrlSafetyValidator>());

The module emits OpenTelemetry metrics via the Granit.Http.UrlSafety meter:

MetricTypeDescription
granit.http.urlsafety.url.validatedCounterURLs passed through IUrlSafetyValidator
granit.http.urlsafety.url.blockedCounterURLs blocked, tagged with the violation kind

Deliberately no tenant_id tag: outbound URL checks are high-frequency and a raw tenant dimension is an unbounded-cardinality liability on counters. Tenant correlation belongs to traces and logs via the Serilog enrichers (ADR-001).

public sealed class CreateSubscriptionRequestValidator : AbstractValidator<CreateSubscriptionRequest>
{
public CreateSubscriptionRequestValidator(IUrlSafetyValidator urlSafety)
{
RuleFor(x => x.CallbackUrl)
.NotEmpty()
.MustAsync(async (url, ct) =>
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
var result = await urlSafety.ValidateAsync(uri, ct).ConfigureAwait(false);
return result.IsValid;
})
.WithErrorCodeAndMessage("Webhooks:Validation:UnsafeCallbackUrl");
}
}
var result = await _urlSafety.ValidateAsync(target, ct).ConfigureAwait(false);
if (!result.IsValid)
{
var violation = result.Violation!;
_logger.UnsafeOutboundUrlBlocked(target.Host, violation.Kind, violation.Reason);
throw new HttpRequestException($"Outbound URL blocked: {violation.Reason}");
}
using var response = await _http.GetAsync(target, ct).ConfigureAwait(false);

HttpClient connect-callback (DNS-rebinding mitigation)

Section titled “HttpClient connect-callback (DNS-rebinding mitigation)”
services.AddHttpClient("partner")
.ConfigurePrimaryHttpMessageHandler(sp =>
new SocketsHttpHandler
{
ConnectCallback = async (ctx, ct) =>
{
var addresses = await Dns.GetHostAddressesAsync(ctx.DnsEndPoint.Host, ct);
foreach (var address in addresses)
{
if (PrivateNetworkClassifier.IsBlocked(address))
{
throw new HttpRequestException(
$"{address} is in a denied range (DNS rebinding blocked).");
}
}
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(addresses, ctx.DnsEndPoint.Port, ct);
return new NetworkStream(socket, ownsSocket: true);
},
});

Tip — why both layers? The up-front ValidateAsync resolves DNS at request-build time. The connect callback re-validates at the moment the socket is opened. An attacker controlling DNS can flip the answer between those two events; the connect callback is the closing layer that prevents the rebind.

public static class PrivateNetworkClassifier
{
public static bool IsBlocked(IPAddress ip);
public static bool Classify(IPAddress ip, out UrlSafetyViolationKind kind);
}

Use this directly when your code already has an IPAddress and you want a single boolean (e.g. inside a SocketsHttpHandler.ConnectCallback). Cross-link: the Webhooks security flow uses it in exactly this shape.

public static class ReservedTldClassifier
{
public static bool IsReserved(string host, out string tld);
}

Reserved TLDs ship as a constant list — .local (RFC 6762 mDNS), .onion (RFC 7686 Tor), .test, .example, .invalid, .localhost (RFC 2606), .home.arpa (RFC 8375). Host-suffix match is case-insensitive and handles trailing-dot FQDNs.

  1. At validation time (request entry, FluentValidation): ValidateAsync resolves DNS once, returns ResolvedAddresses.
  2. At HTTP client construction: SocketsHttpHandler.ConnectCallback re-resolves and re-checks against PrivateNetworkClassifier immediately before opening the socket.
  3. At provider level (Browsing): the in-process request router consults IBrowserSandboxProfile on every request, including sub-resource loads triggered by the rendered page — see Browsing security.

The three layers compose: the attacker would have to win the race at all three points, and the resolved address would have to be in a public range at each check.