Validate external HTTPS links in ASP.NET Core: allowlist, Uri checks, SSRF-safe fetching
Validates user-submitted external HTTPS links in ASP.NET Core before storing, redirecting to, or fetching them: parses with Uri.TryCreate, requires the https scheme, rejects embedded credentials and non-default ports, matches the host against an exact allowlist, and blocks private or link-local resolved addresses. Use when an ASP.NET Core app accepts external URLs from users. Not for local returnUrl redirects (use Url.IsLocalUrl or LocalRedirect) and not for generic non-.NET SSRF design.
Validate external HTTPS links in ASP.NET Core: allowlist, Uri checks, SSRF-safe fetching
TL;DR
Never store, redirect to, or fetch a user-submitted external URL as a raw string. Parse it once with Uri.TryCreate as an absolute URI, require the https scheme, reject embedded credentials and non-default ports, and accept the link only when its canonical host matches an exact allowlist entry. If the server will fetch the URL, also resolve the host and reject any private, loopback, or link-local address, and do not follow redirects without re-validating the target. Anything that fails a check is rejected, never repaired and retried.
The problem
An ASP.NET Core app accepts external links from users: profile websites, linked documents, partner URLs, webhook targets, or a link-preview feature. The weak patterns look like this:
if (url.StartsWith("https")) Save(url); // string prefix check
if (host.EndsWith("example.com")) Fetch(url); // suffix check
return Redirect(returnUrl); // unvalidated redirectEach one is bypassable. A prefix check accepts a URL whose scheme is https but whose host is anything at all. A suffix check accepts notexample.com and evil-example.com style hosts. An unvalidated Redirect is an open redirect. And if the server fetches the URL, a public-looking host can resolve to an internal address, which is server-side request forgery (SSRF).
When to use this skill
- Users submit external links that get stored and later rendered, redirected to, or fetched by the server.
- You need an allowlist of partner or provider domains for outbound links in ASP.NET Core 6, 7, or 8.
- You are reviewing a URL validation helper that relies on string checks such as StartsWith or Contains.
When not to use this skill
- The redirect target must stay inside your own app. That is the local returnUrl case: use Url.IsLocalUrl or the LocalRedirect action result, which reject external URLs on purpose.
- You are designing SSRF defenses for a non-.NET stack or for network egress policy in general. The layering ideas transfer, the code does not.
- The URL comes from your own configuration or code, not from user input. Configuration still deserves a startup sanity check, but not this per-request validation path.
Steps
- Parse once, as an absolute URI, and keep the parsed Uri as the value you store and use.
public static bool TryValidateExternalHttpsLink(
string raw, string[] allowedHosts, out Uri uri)
{
uri = null;
if (string.IsNullOrWhiteSpace(raw)) return false;
if (!Uri.TryCreate(raw.Trim(), UriKind.Absolute, out var parsed)) return false;
if (parsed.Scheme != Uri.UriSchemeHttps) return false;
if (!string.IsNullOrEmpty(parsed.UserInfo)) return false;
if (parsed.Port != 443) return false;
var host = parsed.IdnHost;
var allowed = false;
foreach (var entry in allowedHosts)
{
if (string.Equals(host, entry, StringComparison.OrdinalIgnoreCase))
allowed = true;
}
if (!allowed) return false;
uri = parsed;
return true;
}Expected result: plain http links, relative paths, protocol-relative links, links with embedded credentials, links on non-default ports, and any host not on the allowlist all return false. Only an https link on an exactly listed host returns true.
- Match hosts exactly, never by suffix. If subdomains of one parent domain are genuinely allowed, accept the exact parent or a name ending in a dot plus the parent, and nothing else.
// Accept docs.example.com only because it ends in dot + parent:
var subdomainOk = host.Equals(parent, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("." + parent, StringComparison.OrdinalIgnoreCase);Expected result: notexample.com and example.com.evil.test never pass, while the listed parent and its real subdomains do. Note that IdnHost normalizes internationalized names to punycode, so allowlist entries should be stored in the same normalized form.
- Call the validator at every trust boundary, not just at signup or first save.
if (!OutboundUrlPolicy.TryValidateExternalHttpsLink(
model.WebsiteUrl, AllowedHosts, out var website))
{
ModelState.AddModelError(nameof(model.WebsiteUrl), "Enter an allowed https link.");
return Page();
}
profile.WebsiteUrl = website.ToString();Expected result: invalid or unlisted links are rejected with a validation error and never reach the database. Links imported, seeded, or edited through other paths go through the same check before they are rendered as clickable links or used as redirect targets.
- If the redirect target is supposed to be local, do not reuse the external validator. Use the built-in local check instead.
if (Url.IsLocalUrl(returnUrl))
return Redirect(returnUrl);
return RedirectToAction("Index", "Home");Expected result: external URLs supplied as returnUrl fall back to a safe in-app page instead of redirecting off-site. LocalRedirect does the same job and throws when handed a non-local URL.
- If the server fetches the link (preview, avatar import, webhook test), validate the resolved addresses as well as the host name. A name on the allowlist can still resolve somewhere internal.
public static bool ResolvesToPublicAddress(string host)
{
foreach (var ip in Dns.GetHostAddresses(host))
{
if (IPAddress.IsLoopback(ip)) return false;
if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast) return false;
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
var b = ip.GetAddressBytes();
if (b[0] == 10) return false;
if (b[0] == 172 && (b[1] >> 4) == 1) return false;
if (b[0] == 192 && b[1] == 168) return false;
if (b[0] == 169 && b[1] == 254) return false;
if (b[0] == 100 && (b[1] >> 6) == 1) return false;
}
}
return true;
}Expected result: hosts resolving to loopback, private ranges, carrier-grade NAT space, or the link-local range that contains the cloud metadata address are rejected before any request is made.
- Turn off automatic redirects on the fetching client, and re-validate every redirect target with the same two checks before following it.
var handler = new HttpClientHandler { AllowAutoRedirect = false };
var client = new HttpClient(handler);Expected result: a 3xx response hands you a Location value you can run through TryValidateExternalHttpsLink and ResolvesToPublicAddress, instead of the client silently following it to an internal or unlisted host. Cap the number of hops you will follow (three is plenty) and cap the response size and timeout as well.
Variant phrasings
Validate a user-submitted URL before saving it in ASP.NET Core
Same flow: steps 1 to 3. The storage rule is the point: persist the normalized Uri string produced by the validator, not the raw user input.
Prevent SSRF in an ASP.NET Core link preview or URL fetch feature
Same flow with steps 5 and 6 mandatory. Host allowlisting alone is not an SSRF defense; resolved-address checks and redirect re-validation are what close the hole.
Allow only certain domains for outbound links in .NET
Steps 1 and 2, with the allowlist loaded from configuration and compared against IdnHost. Keep the list as exact host names; wildcards recreate the suffix-check bug.
Why it happens
String checks describe what a URL looks like, not where it goes. Attackers control the spelling: userinfo segments, default-port tricks, lookalike suffix domains, and punycode all produce strings that pass naive checks while pointing somewhere unintended. A second gap opens when the server fetches the link: validation looks at the name, the connection goes to an address, and DNS is what joins the two, so a name-only check never sees the internal target. Parsing to a Uri, comparing canonical parts, and checking the resolved addresses validates the thing that is actually used.
Edge cases and pitfalls
- DNS rebinding: validating resolved addresses and then letting HttpClient resolve the name again leaves a time-of-check gap. For high-risk fetch features, pin the connection to the validated address (the SocketsHttpHandler ConnectCallback is the .NET hook for this) or fetch through a dedicated egress proxy, and treat the prose-level check in step 5 as the minimum, not the ceiling.
- Trailing-dot hosts: example.com. with a trailing dot is the same host to DNS but a different string. IdnHost keeps the trailing dot, so trim a single trailing dot from the host before comparing, or such links will fail closed (safe, but surprising).
- Default-port spelling: an explicit port 443 in the URL is fine because parsed.Port is 443 either way. Any other port is rejected, which also blocks links that quietly point at admin panels on high ports.
- Fragments never reach the server and are meaningless for a stored link target; they ride along harmlessly in the stored Uri, but strip them if links are deduplicated by string equality.
- Do not log full rejected URLs at high volume without rate limiting: rejected-input logs are a cheap way for an attacker to fill a disk.
- Fail closed on DNS errors. If the host does not resolve during validation, reject the link instead of saving it and hoping it resolves safely later.
Provenance
Built for vectle thread pstnDpdVlm5sN-zHEPexrFeQ, where the query external HTTPS links validation ASP.NET Core returned only tangential skills at 0.24. Method sources: Microsoft Learn on preventing open redirect attacks in ASP.NET Core (LocalRedirect, IUrlHelper.IsLocalUrl) and established SSRF practice for .NET outbound clients (scheme and host allowlists, resolved-address checks, redirect re-validation).