Skip to content

Conversation

@Thorium
Copy link
Member

@Thorium Thorium commented Oct 28, 2025

Add Server-Side Request Forgery (SSRF) protection.
https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/

This is a potentially breaking change, but an important security update:

  • If you want to disable the protection, you need to add a static parameter "SsrfProtection=false"
  • Also this changes use handler = new HttpClientHandler(UseDefaultCredentials = false) to avoid machine level credentials to be exposed.

@sergey-tihon sergey-tihon requested a review from Copilot October 31, 2025 06:52
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds SSRF (Server-Side Request Forgery) protection to SwaggerProvider by blocking HTTP URLs and localhost/private IP addresses by default. A new SsrfProtection parameter (default: true) allows developers to disable these protections for local development and testing.

Key changes:

  • Added SSRF validation that blocks HTTP, localhost, and private IP ranges when protection is enabled
  • Disabled credential leakage by setting UseDefaultCredentials = false unconditionally
  • Added timeout configuration for HTTP client requests

Reviewed Changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/SwaggerProvider.DesignTime/Utils.fs Implements SSRF protection validation logic and enforces secure HTTP client configuration
src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs Adds SsrfProtection parameter to SwaggerClientProvider with documentation
src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs Adds SsrfProtection parameter to OpenApiClientProvider with documentation
tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs Updates test to disable SSRF protection for localhost testing
tests/SwaggerProvider.ProviderTests/v2/Swashbuckle.ReturnControllers.Tests.fs Updates test to disable SSRF protection for localhost testing
docs/SwaggerClientProvider.md Documents the new security feature and usage examples
docs/OpenApiClientProvider.md Documents the new security feature and usage examples
README.md Adds security notice about SSRF protection

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

schemaPathRaw
else
// Development mode: allow HTTP
validateSchemaUrl ignoreSsrfProtection uri // Still validate private IPs even in dev mode
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When ignoreSsrfProtection is true, the validateSchemaUrl function returns early without performing any validation (line 22-23), so the comment 'Still validate private IPs even in dev mode' is misleading. Either the validation logic should be split to always validate private IPs regardless of the flag, or the comment should be removed.

Suggested change
validateSchemaUrl ignoreSsrfProtection uri // Still validate private IPs even in dev mode
validateSchemaUrl ignoreSsrfProtection uri

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +111
if not(isNull contentType) then
let mediaType = contentType.MediaType.ToLowerInvariant()

if
not(
mediaType.Contains "json"
|| mediaType.Contains "yaml"
|| mediaType.Contains "text"
|| mediaType.Contains "application/octet-stream"
)
then
failwithf "Invalid Content-Type for schema: %s. Expected JSON or YAML." mediaType
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTTP response path includes Content-Type validation (lines 97-111), but the same validation is missing from the HTTPS response path when ignoreSsrfProtection is true (lines 140-184). This inconsistency could allow invalid content types in development mode. Consider extracting this validation into a reusable function and applying it consistently across both paths.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to unify validation? Otherwise in may work in development mode and then fail when you switch to https because of inconsistent validation.

Comment on lines +33 to +35
if
host = "localhost"
|| host.StartsWith "127."
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IPv4 loopback check using StartsWith \"127.\" only validates the first octet. Hostnames like 127.0.0.1.example.com would incorrectly be blocked. Use proper IP address parsing (e.g., System.Net.IPAddress.TryParse) to validate that the entire host is an IP address before applying IP-based checks, or use more precise string validation like checking that the host exactly matches known patterns.

Suggested change
if
host = "localhost"
|| host.StartsWith "127."
let isLoopback =
let mutable ip = Unchecked.defaultof<IPAddress>
IPAddress.TryParse(host, &ip) && IPAddress.IsLoopback(ip)
if
host = "localhost"
|| isLoopback

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +67
// Block localhost and loopback
if
host = "localhost"
|| host.StartsWith "127."
|| host = "::1"
|| host = "0.0.0.0"
then
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host

// Block private IP ranges (RFC 1918)
if
host.StartsWith "10."
|| host.StartsWith "192.168."
|| host.StartsWith "172.16."
|| host.StartsWith "172.17."
|| host.StartsWith "172.18."
|| host.StartsWith "172.19."
|| host.StartsWith "172.20."
|| host.StartsWith "172.21."
|| host.StartsWith "172.22."
|| host.StartsWith "172.23."
|| host.StartsWith "172.24."
|| host.StartsWith "172.25."
|| host.StartsWith "172.26."
|| host.StartsWith "172.27."
|| host.StartsWith "172.28."
|| host.StartsWith "172.29."
|| host.StartsWith "172.30."
|| host.StartsWith "172.31."
then
failwithf "Cannot fetch schemas from private IP addresses: %s (set SsrfProtection=false for development)" host

// Block link-local addresses
if host.StartsWith "169.254." then
failwithf "Cannot fetch schemas from link-local addresses: %s (set SsrfProtection=false for development)" host

Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The private IP range validation using StartsWith is vulnerable to bypass. For example, 10.0.0.1.example.com would incorrectly match host.StartsWith \"10.\". Use proper IP address parsing with System.Net.IPAddress.TryParse to verify the host is actually an IP address, then check if it falls within the private ranges using proper IP address comparison.

Suggested change
// Block localhost and loopback
if
host = "localhost"
|| host.StartsWith "127."
|| host = "::1"
|| host = "0.0.0.0"
then
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host
// Block private IP ranges (RFC 1918)
if
host.StartsWith "10."
|| host.StartsWith "192.168."
|| host.StartsWith "172.16."
|| host.StartsWith "172.17."
|| host.StartsWith "172.18."
|| host.StartsWith "172.19."
|| host.StartsWith "172.20."
|| host.StartsWith "172.21."
|| host.StartsWith "172.22."
|| host.StartsWith "172.23."
|| host.StartsWith "172.24."
|| host.StartsWith "172.25."
|| host.StartsWith "172.26."
|| host.StartsWith "172.27."
|| host.StartsWith "172.28."
|| host.StartsWith "172.29."
|| host.StartsWith "172.30."
|| host.StartsWith "172.31."
then
failwithf "Cannot fetch schemas from private IP addresses: %s (set SsrfProtection=false for development)" host
// Block link-local addresses
if host.StartsWith "169.254." then
failwithf "Cannot fetch schemas from link-local addresses: %s (set SsrfProtection=false for development)" host
// Block localhost and loopback, and private IP ranges using proper IP address parsing
let isIp, ipAddr = System.Net.IPAddress.TryParse(host)
if isIp then
// Loopback
if System.Net.IPAddress.IsLoopback(ipAddr) || ipAddr.ToString() = "0.0.0.0" then
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host
// Private IPv4 ranges
let bytes = ipAddr.GetAddressBytes()
let isPrivate =
// 10.0.0.0/8
(ipAddr.AddressFamily = System.Net.Sockets.AddressFamily.InterNetwork && bytes.[0] = 10uy)
// 172.16.0.0/12
|| (ipAddr.AddressFamily = System.Net.Sockets.AddressFamily.InterNetwork && bytes.[0] = 172uy && bytes.[1] >= 16uy && bytes.[1] <= 31uy)
// 192.168.0.0/16
|| (ipAddr.AddressFamily = System.Net.Sockets.AddressFamily.InterNetwork && bytes.[0] = 192uy && bytes.[1] = 168uy)
// Link-local 169.254.0.0/16
|| (ipAddr.AddressFamily = System.Net.Sockets.AddressFamily.InterNetwork && bytes.[0] = 169uy && bytes.[1] = 254uy)
if isPrivate then
failwithf "Cannot fetch schemas from private or link-local IP addresses: %s (set SsrfProtection=false for development)" host
else
// Block localhost by name
if host = "localhost" then
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggestion looks reasonable, especially if we introduce active pattern for matching. @Thorium what do you think?

Comment on lines +32 to +67
// Block localhost and loopback
if
host = "localhost"
|| host.StartsWith "127."
|| host = "::1"
|| host = "0.0.0.0"
then
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host

// Block private IP ranges (RFC 1918)
if
host.StartsWith "10."
|| host.StartsWith "192.168."
|| host.StartsWith "172.16."
|| host.StartsWith "172.17."
|| host.StartsWith "172.18."
|| host.StartsWith "172.19."
|| host.StartsWith "172.20."
|| host.StartsWith "172.21."
|| host.StartsWith "172.22."
|| host.StartsWith "172.23."
|| host.StartsWith "172.24."
|| host.StartsWith "172.25."
|| host.StartsWith "172.26."
|| host.StartsWith "172.27."
|| host.StartsWith "172.28."
|| host.StartsWith "172.29."
|| host.StartsWith "172.30."
|| host.StartsWith "172.31."
then
failwithf "Cannot fetch schemas from private IP addresses: %s (set SsrfProtection=false for development)" host

// Block link-local addresses
if host.StartsWith "169.254." then
failwithf "Cannot fetch schemas from link-local addresses: %s (set SsrfProtection=false for development)" host

Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The link-local address check using StartsWith \"169.254.\" is vulnerable to the same bypass as the other IP checks. A hostname like 169.254.1.1.example.com would incorrectly match. Use proper IP address parsing to ensure the entire host is actually an IP address before performing range checks.

Suggested change
// Block localhost and loopback
if
host = "localhost"
|| host.StartsWith "127."
|| host = "::1"
|| host = "0.0.0.0"
then
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host
// Block private IP ranges (RFC 1918)
if
host.StartsWith "10."
|| host.StartsWith "192.168."
|| host.StartsWith "172.16."
|| host.StartsWith "172.17."
|| host.StartsWith "172.18."
|| host.StartsWith "172.19."
|| host.StartsWith "172.20."
|| host.StartsWith "172.21."
|| host.StartsWith "172.22."
|| host.StartsWith "172.23."
|| host.StartsWith "172.24."
|| host.StartsWith "172.25."
|| host.StartsWith "172.26."
|| host.StartsWith "172.27."
|| host.StartsWith "172.28."
|| host.StartsWith "172.29."
|| host.StartsWith "172.30."
|| host.StartsWith "172.31."
then
failwithf "Cannot fetch schemas from private IP addresses: %s (set SsrfProtection=false for development)" host
// Block link-local addresses
if host.StartsWith "169.254." then
failwithf "Cannot fetch schemas from link-local addresses: %s (set SsrfProtection=false for development)" host
// Try to parse host as IP address
let isIp, ipAddr = System.Net.IPAddress.TryParse(host)
// Block localhost and loopback
if
host = "localhost"
|| (isIp && (ipAddr.Equals(System.Net.IPAddress.Loopback) || ipAddr.Equals(System.Net.IPAddress.IPv6Loopback)))
|| host = "0.0.0.0"
then
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host
// Block private IP ranges (RFC 1918) and link-local only if host is an IP address
if isIp then
let bytes = ipAddr.GetAddressBytes()
// IPv4 private ranges
let isPrivate =
(bytes.[0] = 10uy) ||
(bytes.[0] = 192uy && bytes.[1] = 168uy) ||
(bytes.[0] = 172uy && bytes.[1] >= 16uy && bytes.[1] <= 31uy)
if isPrivate then
failwithf "Cannot fetch schemas from private IP addresses: %s (set SsrfProtection=false for development)" host
// Link-local 169.254.0.0/16
let isLinkLocal = (bytes.[0] = 169uy && bytes.[1] = 254uy)
if isLinkLocal then
failwithf "Cannot fetch schemas from link-local addresses: %s (set SsrfProtection=false for development)" host

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants