- 
                Notifications
    You must be signed in to change notification settings 
- Fork 59
Server-Side Request Forgery (SSRF) protection #271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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 = falseunconditionally
- 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 SsrfProtectionparameter to SwaggerClientProvider with documentation | 
| src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs | Adds SsrfProtectionparameter 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 | 
    
      
    
      Copilot
AI
    
    
    
      Oct 31, 2025 
    
  
There was a problem hiding this comment.
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.
| validateSchemaUrl ignoreSsrfProtection uri // Still validate private IPs even in dev mode | |
| validateSchemaUrl ignoreSsrfProtection uri | 
| 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 | 
    
      
    
      Copilot
AI
    
    
    
      Oct 31, 2025 
    
  
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| if | ||
| host = "localhost" | ||
| || host.StartsWith "127." | 
    
      
    
      Copilot
AI
    
    
    
      Oct 31, 2025 
    
  
There was a problem hiding this comment.
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.
| 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 | 
| // 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 | ||
|  | 
    
      
    
      Copilot
AI
    
    
    
      Oct 31, 2025 
    
  
There was a problem hiding this comment.
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.
| // 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 | 
There was a problem hiding this comment.
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?
| // 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 | ||
|  | 
    
      
    
      Copilot
AI
    
    
    
      Oct 31, 2025 
    
  
There was a problem hiding this comment.
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.
| // 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 | 
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:
use handler = new HttpClientHandler(UseDefaultCredentials = false)to avoid machine level credentials to be exposed.