DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Your Email Security Is a DNS Configuration Problem
  • How to Verify Domain Ownership: A Technical Deep Dive
  • Penetration Testing: A Comprehensive Guide
  • Demystifying SPF Record Limitations

Trending

  • MCP Is Coming to Your Desktop Applications
  • Spring AI on Local LLMs
  • How Kestra Unifies Enterprise Workflow Orchestration and Agentic AI
  • Why AI Agents Need a Shared Enterprise Ontology
  1. DZone
  2. Software Design and Architecture
  3. Security
  4. SSRF Prevention in C#: Securing File Imports From User-Supplied URLs

SSRF Prevention in C#: Securing File Imports From User-Supplied URLs

SSRF risks make URL upload a risky feature. If we use the right tools, however, SSRF threat mitigation can be a really straightforward task.

By 
Brian O'Neill user avatar
Brian O'Neill
DZone Core CORE ·
Oct. 26, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
63 Views

Join the DZone community and get the full member experience.

Join For Free

A document portal that accepts a URL saves users the trouble of downloading a file and uploading it again. That’s particularly useful when large, complex, or sensitive documents are already hosted online.   With URL upload as an option, external business partners like vendors or suppliers can submit links to files like compliance reports or product specification sheets, which our application can subsequently download for review.  

That convenience comes with risk, of course. If we treat syntactically valid URLs as automatic permission to connect with our server, our file-import feature quickly becomes a Server-Side Request Forgery (SSRF) vulnerability. SSRF attacks occur when bad actors craft URLs to trick our server into accessing internal services they couldn’t reach directly otherwise.

Mitigating SSRF is essential for enabling a URL-based file submission feature. In C#, we can make URL-based file imports safer by separating URL screening from the network controls that determine where our application actually connects.

In this article, we’ll examine how to control those outbound requests and build a C# importer from an intentionally narrow scenario: HTTPS download from an approved partner with a verified, fixed destination IP address. We’ll also add a dedicated SSRF detection feature to our workflow without treating its result as a substitute for local enforcement.

A Valid URL Can Still Be an Unsafe Destination

With SSRF attacks, we’re concerned about the request our server makes, not just the bytes it receives (that’s a different problem requiring a different solution). A request to an internal administrative service can cause harm even if the response is never displayed. Blocking suspicious downloaded content happens too late to prevent that connection.

It’s certainly useful to check for an HTTPS scheme, but bear in mind that HTTPS can also protect connections to private services. Similarly, it’s important to remember that a hostname which looks familiar on the surface may actually be part of an attacker’s domain. We need to parse the URL and compare its host against an exact allowlist rather than search the submitted string for an approved name.

Additionally, URL redirects create another boundary to consider. An approved endpoint might return a redirect to a different host, so the initial approval can’t automatically authorize the next request. For this file importer, we’re rejecting redirects entirely; supporting them would require repeating the full destination policy for every hop.

Connecting to the Destination We Approve

DNS introduces a less visible problem: resolving a hostname, checking its address, and then letting an HTTP client resolve it again creates an opportunity for the two DNS lookups to return different IP addresses. A remote screening service can also see a different DNS answer from our application.

Our example in this article avoids that problem by connecting to an administrator-configured IP address through SocketsHttpHandler.ConnectCallback. Microsoft’s callback provides control over creating the connection; note that we don’t disable certificate checks.

Overall, this design essentially trades flexibility for a small, reviewable trust boundary. That’s why it suits a partner with a stable address; a CDN-backed download service may rotate addresses, so pinning downloads to a single IP address could cause legitimate imports to fail when that address changes. In that case, we need a maintained destination policy, or perhaps a controlled outbound proxy. If we simply replaced the fixed address with an unrestricted DNS lookup, we would remove the protection we intended to create.

What We Can Build With Open-Source Components

Through .NET’s open-source networking stack, we have access to URI parsing, HTTP handling, sockets, cancellation, and response streaming. With those tools, we can build a restricted importer without any dedicated SSRF library. The bulk of the engineering work here lies in defining permitted destinations, preserving that decision through connection establishment, and, of course, testing deployment-specific routing.

For arbitrary public URLs, however, the policy becomes larger. In that case, we need to account for IPv4 and IPv6, nonpublic and special-purpose ranges, mapped addresses, redirects, DNS changes, and network translation. A few string checks here and there for localhost and 127.0.0.1 do not represent a complete implementation. An outbound firewall should independently deny access to internal services and cloud metadata endpoints.

Also — we should isolate download credentials. Our importer here doesn’t forward the user’s Authorization header or reuse a browser cookie jar; if a partner requires authentication, credentials belong in a separate and narrowly scoped integration.

Add an SSRF Screening Step

As mentioned, we’re also going to incorporate an SSRF screening step in our example. Specifically, we’ll use an SSRF detection API that accepts a URL and optional blocked domains as parameters and returns a clean result + a threat-level diagnosis. This gives us an additional result before downloading, while the configured destination and network policy retain authority over the actual connection.

Getting Started

To begin, we can create a .NET 8 console project and install the published SDK version used here.

ASP.NET
 
dotnet new console --framework net8.0 -n PartnerImport
cd PartnerImport
dotnet add package Cloudmersive.APIClient.NETCore.Validate --version 2.2.6


Before running it, we set our API key, import host, and import IP in the process environment. Note that our import host MUST be an administrator-approved DNS hostname without a scheme or path, and our import IP must be that partner’s independently verified public address (permitted by the deployment’s outbound firewall). The user isn’t supplying either setting, and the program rejects missing configuration.

Complete C# Implementation

The following Program.cs performs the screening call, pins the connection, and (arbitrarily) caps the response at 10 MiB. Reading the body and receiving headers are covered by our timeout. Note that we buffer this deliberately small import so an incomplete response never becomes a completed output file.

C#
 
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using Cloudmersive.APIClient.NETCore.Validate.Api;
using Cloudmersive.APIClient.NETCore.Validate.Client;
using Cloudmersive.APIClient.NETCore.Validate.Model;

try
{
    if (args.Length != 2)
        throw new ArgumentException("Usage: PartnerImport <url> <new-output-path>");

    string Required(string name) =>
        Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
            ? value : throw new InvalidOperationException($"Set {name}.");

    string host = Required("IMPORT_HOST");
    IPAddress address = IPAddress.Parse(Required("IMPORT_IP"));
    string key = Required("CLOUDMERSIVE_API_KEY");

    if (!Uri.TryCreate(args[0], UriKind.Absolute, out var uri) ||
        uri.Scheme != Uri.UriSchemeHttps || uri.Port != 443 ||
        uri.HostNameType != UriHostNameType.Dns ||
        !string.Equals(uri.IdnHost, host, StringComparison.OrdinalIgnoreCase) ||
        uri.UserInfo.Length != 0 || uri.Fragment.Length != 0)
        throw new InvalidOperationException("Destination is not permitted.");

    var configuration = new Configuration();
    configuration.AddApiKey("Apikey", key);
    var api = new DomainApi(configuration);
    var verdict = api.DomainSsrfCheck(new UrlSsrfRequestFull
    {
        URL = uri.AbsoluteUri
    });
    if (verdict?.CleanURL != true)
        throw new InvalidOperationException("URL screening did not approve the import.");

    using var handler = new SocketsHttpHandler
    {
        AllowAutoRedirect = false,
        UseProxy = false,
        UseCookies = false,
        AutomaticDecompression = DecompressionMethods.None,
        ConnectCallback = async (context, token) =>
        {
            if (!string.Equals(context.DnsEndPoint.Host, host,
                    StringComparison.OrdinalIgnoreCase) ||
                context.DnsEndPoint.Port != 443)
                throw new InvalidOperationException("Unexpected destination.");

            var socket = new Socket(address.AddressFamily,
                SocketType.Stream, ProtocolType.Tcp);
            try
            {
                await socket.ConnectAsync(new IPEndPoint(address, 443), token);
                return new NetworkStream(socket, ownsSocket: true);
            }
            catch
            {
                socket.Dispose();
                throw;
            }
        }
    };
    using var client = new HttpClient(handler);
    using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    using var request = new HttpRequestMessage(HttpMethod.Get, uri)
    {
        Version = HttpVersion.Version11,
        VersionPolicy = HttpVersionPolicy.RequestVersionExact
    };
    using var response = await client.SendAsync(request,
        HttpCompletionOption.ResponseHeadersRead, deadline.Token);
    if (response.StatusCode != HttpStatusCode.OK)
        throw new InvalidOperationException("Expected a direct HTTP 200 response.");
    if (response.Content.Headers.ContentEncoding.Count != 0)
        throw new InvalidOperationException("Encoded responses are not supported.");

    const int maxBytes = 10 * 1024 * 1024;
    if (response.Content.Headers.ContentLength > maxBytes)
        throw new InvalidOperationException("Import exceeds the size limit.");

    await using var source = await response.Content.ReadAsStreamAsync(deadline.Token);
    using var content = new MemoryStream();
    byte[] buffer = new byte[81920];
    int count;
    while ((count = await source.ReadAsync(buffer.AsMemory(), deadline.Token)) != 0)
    {
        if (content.Length + count > maxBytes)
            throw new InvalidOperationException("Import exceeds the size limit.");
        content.Write(buffer, 0, count);
    }

    // NOTE that the Output path is chosen by the operator, 
  	// never by a remote HTTP caller.
    await using var output = new FileStream(args[1], FileMode.CreateNew,
        FileAccess.Write, FileShare.None);
    content.Position = 0;
    await content.CopyToAsync(output, deadline.Token);
    Console.WriteLine("Download completed; content approval is a separate step.");
}
catch (Exception error)
{
    // Avoid logging signed URL query strings or response bodies.
    Console.Error.WriteLine($"Import stopped ({error.GetType().Name}).");
    Environment.ExitCode = 1;
}


The key thing to note here is that only an explicit positive screening result permits the download attempt. As we’ve set it up, an exception stops the program entirely, and a redirect never reaches the second destination. The size check doesn’t trust Content-Length alone; it counts the actual bytes. Remember that a disk-write failure can leave a partial local output, so a production handoff should publish a temporary file only after successful completion.

Keep the Boundary Intact After Downloading

We can (and should) test our policy with a disallowed hostname, a redirect response, a mismatched certificate, an oversized body, and an “unavailable” screening service. If any of those doesn’t stop the import, something went wrong. Deployment testing should also confirm that the worker can’t reach the protected internal destinations independently of application code.

Finally, it’s important to remember that a permitted destination does not establish that its document is safe to parse. Things like renderers and converters can make their own network requests for embedded resources, so those processes need restricted outbound access too. A reliable importer preserves destination control across the entire workflow rather than approving a string once and assuming every later connection is magically covered.

Domain Name System security

Opinions expressed by DZone contributors are their own.

Related

  • Your Email Security Is a DNS Configuration Problem
  • How to Verify Domain Ownership: A Technical Deep Dive
  • Penetration Testing: A Comprehensive Guide
  • Demystifying SPF Record Limitations

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook