Review request-influenced outbound HTTP calls (SSRF)

Rule ND3136 Security

Matches public actions of MVC controllers, API controllers, Razor page models and SignalR hubs that take a string or a Uri parameter and either call an HTTP client directly (HttpClient, HttpRequestMessage, WebRequest, WebClient, RestSharp) or carry a name that says they fetch a remote resource (Url, Uri, Remote, Fetch, Download, Proxy, Webhook, Callback). The callDepthToHttpClient column gives the length of the shortest call path to an HTTP client (0 when the client sits behind an interface), and validatesUri tells whether the action itself inspects the host, scheme or IP address of a URI.

The rule does not prove that the received value flows into the outbound request: it lists the endpoints where that flow must be reviewed. When the value does reach the request address, an attacker steers the server toward hosts it can reach and the attacker cannot: internal admin services, cloud instance metadata (169.254.169.254), databases. This is server-side request forgery, CWE-918 (OWASP Top 10 A10).

Flagged Code
public class PreviewController : ControllerBase {
   [HttpGet("preview")]
   public async Task<IActionResult> Preview(string url) {          // flagged: calls HttpClient
      var html = await _httpClient.GetStringAsync(url);            // url chosen by the caller
      return Content(Summarize(html));
   }
}
Fixed Code
public class PreviewController : ControllerBase {
   [HttpGet("preview")]
   public async Task<IActionResult> Preview(Guid feedId) {         // an identifier, not an address
      var feed = await _feeds.FindAsync(feedId);                   // address from trusted storage
      if (feed == null) return NotFound();
      var html = await _httpClient.GetStringAsync(feed.Url);
      return Content(Summarize(html));
   }
}

How to fix

Never build the outbound address from a value received in the request. Map the value to an identifier (a row, a configured entry) and read the address from trusted storage, or validate the received URI against an allow-list of hosts and schemes: parse it with Uri.TryCreate(), accept https only, compare Uri.Host with the allowed hosts, reject loopback, link-local and private IP ranges, and disable redirects on the HttpClientHandler so a permitted host cannot bounce the request.

Suppress the issue when the string parameter cannot influence the address, for instance when it only names a format or a culture.

CQLinq source

This rule is written in CQLinq, C# LINQ over the NDepend code model. It ships with every new NDepend project: you can read it, adapt it, or use it as a starting point for your own custom rules.

// <Name>Review request-influenced outbound HTTP calls (SSRF)</Name>
// <Id>ND3136:ReviewRequestInfluencedOutboundHttpCalls</Id>
warnif count > 0

// Third-party types are part of the code model only when the application uses them.
let controllerBaseTypes = ThirdParty.Types.WithFullNameIn(
  "Microsoft.AspNetCore.Mvc.ControllerBase",
  "Microsoft.AspNetCore.Mvc.RazorPages.PageModel",
  "Microsoft.AspNetCore.SignalR.Hub",
  "System.Web.Mvc.Controller",
  "System.Web.Http.ApiController").ToArray()
where controllerBaseTypes.Any()

// Members that open an outbound HTTP connection to an address given as a string or a Uri.
let httpClientMembers = ThirdParty.Methods.Where(m =>
   (m.ParentType.FullName == "System.Net.Http.HttpClient" && m.IsPublic && !m.IsConstructor &&
    m.SimpleName.EndsWith("Async") && m.Name.ContainsAny("String", "Uri")) ||
   (m.ParentType.FullName == "System.Net.Http.HttpRequestMessage" && m.IsConstructor && m.Name.ContainsAny("String", "Uri")) ||
   (m.ParentType.FullNameEqualsAny("System.Net.WebRequest", "System.Net.HttpWebRequest") && m.SimpleName.StartsWith("Create")) ||
   (m.ParentType.FullName == "System.Net.WebClient" && m.SimpleName.StartsWithAny("Download", "Upload", "OpenRead", "OpenWrite")) ||
   (m.ParentType.FullName.StartsWith("RestSharp.") && (m.SimpleNameEqualsAny("ExecuteAsync", "Execute") || (m.IsConstructor && m.ParentType.SimpleName == "RestRequest"))))
   .ToHashSetEx()
where httpClientMembers.Any()

// Length of the shortest call path from each application method down to an HTTP client member:
// 1 = direct call, 2 = through one helper...  The fill only touches methods that actually reach the client.
// A path broken by an interface (the client is behind an injected service) is not seen.
let depthToHttpClient = httpClientMembers.FillIterative(methods => methods.SelectMany(m => m.MethodsCallingMe))

// The action itself inspects the host, scheme or IP address: a hint that an allow-list is applied.
let uriValidationMembers = ThirdParty.Methods.Where(m =>
   (m.ParentType.FullName == "System.Uri" && m.SimpleNameEqualsAny("get_Host", "get_IsLoopback", "get_Scheme", "get_DnsSafeHost", "get_IdnHost")) ||
   (m.ParentType.FullName == "System.Net.IPAddress" && m.SimpleNameEqualsAny("TryParse", "Parse", "IsLoopback")))
   .ToHashSetEx()

from t in Application.Types.ThatDeriveFromAny(controllerBaseTypes)
where !t.IsGeneratedByCompiler

from m in t.Methods
where m.IsPublic && !m.IsConstructor && !m.IsGeneratedByCompiler &&

      // The endpoint receives a string or a Uri from the caller...
      m.Name.ContainsAny("String", "Uri") &&

      // ...and either calls an HTTP client itself, or its name says it fetches a remote resource.
      // The name test is case-sensitive on purpose: PascalCase words only ("Download" but not "loaded").
      ((depthToHttpClient[m] != null && depthToHttpClient[m] <= 1) ||
        m.SimpleNameContainsAny("Url", "Uri", "Remote", "Fetch", "Download", "Proxy", "Webhook", "Callback"))

// 0 when the HTTP client is reached only through an interface
let callDepthToHttpClient = depthToHttpClient[m] != null ? (int)depthToHttpClient[m].Value : 0
let validatesUri = m.MethodsCalled.Intersect(uriValidationMembers).Any()
orderby m.FullName

select new {
  m,
  callDepthToHttpClient,
  validatesUri,
  Debt = 20.ToMinutes().ToDebt(),
  Severity = validatesUri ? Severity.Medium : Severity.High
}

//<Expl>
// The web endpoint *{0}* receives a string or a URI and fetches a remote
// resource. If the destination derives from the value received, the
// server can be made to request internal services, cloud metadata
// endpoints or arbitrary hosts.
//</Expl>

// <Desc>
// Matches public actions of MVC controllers, API controllers, Razor
// page models and SignalR hubs that take a string or a *Uri* parameter
// and either call an HTTP client directly (*HttpClient*,
// *HttpRequestMessage*, *WebRequest*, *WebClient*, RestSharp) or carry a
// name that says they fetch a remote resource (*Url*, *Uri*, *Remote*,
// *Fetch*, *Download*, *Proxy*, *Webhook*, *Callback*). The
// *callDepthToHttpClient* column gives the length of the shortest call
// path to an HTTP client (0 when the client sits behind an interface),
// and *validatesUri* tells whether the action itself inspects the host,
// scheme or IP address of a URI.
//
// The rule does not prove that the received value flows into the
// outbound request: it lists the endpoints where that flow must be
// reviewed. When the value does reach the request address, an attacker
// steers the server toward hosts it can reach and the attacker cannot:
// internal admin services, cloud instance metadata (*169.254.169.254*),
// databases. This is **server-side request forgery**, CWE-918 (OWASP
// Top 10 A10).
//
// **Flagged Code**
// ```csharp
// public class PreviewController : ControllerBase {
//    [HttpGet("preview")]
//    public async Task<IActionResult> Preview(string url) {          // flagged: calls HttpClient
//       var html = await _httpClient.GetStringAsync(url);            // url chosen by the caller
//       return Content(Summarize(html));
//    }
// }
// ```
//
// **Fixed Code**
// ```csharp
// public class PreviewController : ControllerBase {
//    [HttpGet("preview")]
//    public async Task<IActionResult> Preview(Guid feedId) {         // an identifier, not an address
//       var feed = await _feeds.FindAsync(feedId);                   // address from trusted storage
//       if (feed == null) return NotFound();
//       var html = await _httpClient.GetStringAsync(feed.Url);
//       return Content(Summarize(html));
//    }
// }
// ```
// </Desc>

// <HowToFix>
// Never build the outbound address from a value received in the request.
// Map the value to an identifier (a row, a configured entry) and read the
// address from trusted storage, or validate the received URI against an
// allow-list of hosts and schemes: parse it with *Uri.TryCreate()*,
// accept *https* only, compare *Uri.Host* with the allowed hosts, reject
// loopback, link-local and private IP ranges, and disable redirects on
// the *HttpClientHandler* so a permitted host cannot bounce the request.
//
// Suppress the issue when the string parameter cannot influence the
// address, for instance when it only names a format or a culture.
// </HowToFix>

Security: all 39 items