From e8c6cb1e9f2e327feebb953327f35c9adf097d24 Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Tue, 28 Oct 2025 12:48:20 +0000 Subject: [PATCH 1/7] Server-Side Request Forgery (SSRF) protection --- README.md | 2 + docs/OpenApiClientProvider.md | 17 +++ docs/SwaggerClientProvider.md | 17 +++ .../Provider.OpenApiClient.fs | 14 +- .../Provider.SwaggerClient.fs | 11 +- src/SwaggerProvider.DesignTime/Utils.fs | 136 ++++++++++++++++-- .../v2/Swashbuckle.ReturnControllers.Tests.fs | 4 +- .../v3/Swashbuckle.ReturnControllers.Tests.fs | 2 +- 8 files changed, 182 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 10819c3..ac3167a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ This SwaggerProvider can be used to access RESTful API generated using [Swagger. Documentation: http://fsprojects.github.io/SwaggerProvider/ +**Security:** SSRF protection is enabled by default. For local development, use static parameter `SsrfProtection=false`. + ## Swagger RESTful API Documentation Specification Swagger is available for ASP.NET WebAPI APIs with [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle). diff --git a/docs/OpenApiClientProvider.md b/docs/OpenApiClientProvider.md index 93f612d..49ba99f 100644 --- a/docs/OpenApiClientProvider.md +++ b/docs/OpenApiClientProvider.md @@ -21,9 +21,26 @@ let client = PetStore.Client() | `IgnoreControllerPrefix` | Do not parse `operationsId` as `_` and generate one client class for all operations. Default value `true`. | | `PreferNullable` | Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`. | | `PreferAsync` | Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`. | +| `SsrfProtection` | Enable SSRF protection (blocks HTTP and localhost). Set to `false` for development/testing. Default value `true`. | More configuration scenarios are described in [Customization section](/Customization) +## Security (SSRF Protection) + +By default, SwaggerProvider blocks HTTP URLs and localhost/private IP addresses to prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery). + +For **development and testing** with local servers, disable SSRF protection: + +```fsharp +// Development: Allow HTTP and localhost +type LocalApi = OpenApiClientProvider<"http://localhost:5000/swagger.json", SsrfProtection=false> + +// Production: HTTPS with SSRF protection (default) +type ProdApi = OpenApiClientProvider<"https://api.example.com/swagger.json"> +``` + +**Warning:** Never set `SsrfProtection=false` in production code. + ## Sample Sample uses [TaskBuilder.fs](https://github.com/rspeele/TaskBuilder.fs) (F# computation expression builder for System.Threading.Tasks) that will become part of [Fsharp.Core.dll] one day [[WIP, RFC FS-1072] task support](https://github.com/dotnet/fsharp/pull/6811). diff --git a/docs/SwaggerClientProvider.md b/docs/SwaggerClientProvider.md index 6a3d5e9..0c5c0e3 100644 --- a/docs/SwaggerClientProvider.md +++ b/docs/SwaggerClientProvider.md @@ -28,9 +28,26 @@ When you use TP you can specify the following parameters | `IgnoreControllerPrefix` | Do not parse `operationsId` as `_` and generate one client class for all operations. Default value `true`. | | `PreferNullable` | Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`. | | `PreferAsync` | Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`. | +| `SsrfProtection` | Enable SSRF protection (blocks HTTP and localhost). Set to `false` for development/testing. Default value `true`. | More configuration scenarios are described in [Customization section](/Customization) +## Security (SSRF Protection) + +By default, SwaggerProvider blocks HTTP URLs and localhost/private IP addresses to prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery). + +For **development and testing** with local servers, disable SSRF protection: + +```fsharp +// Development: Allow HTTP and localhost +type LocalApi = SwaggerClientProvider<"http://localhost:5000/swagger.json", SsrfProtection=false> + +// Production: HTTPS with SSRF protection (default) +type ProdApi = SwaggerClientProvider<"https://api.example.com/swagger.json"> +``` + +**Warning:** Never set `SsrfProtection=false` in production code. + ## Sample The usage is very similar to [OpenApiClientProvider](/OpenApiClientProvider#sample) diff --git a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs index 0103328..8611c97 100644 --- a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs +++ b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs @@ -36,7 +36,8 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = ProvidedStaticParameter("IgnoreOperationId", typeof, false) ProvidedStaticParameter("IgnoreControllerPrefix", typeof, true) ProvidedStaticParameter("PreferNullable", typeof, false) - ProvidedStaticParameter("PreferAsync", typeof, false) ] + ProvidedStaticParameter("PreferAsync", typeof, false) + ProvidedStaticParameter("SsrfProtection", typeof, true) ] t.AddXmlDoc """Statically typed OpenAPI provider. @@ -44,7 +45,8 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = Do not use `operationsId` and generate method names using `path` only. Default value `false`. Do not parse `operationsId` as `_` and generate one client class for all operations. Default value `true`. Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`. - Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`.""" + Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`. + Enable SSRF protection (blocks HTTP and localhost). Set to false for development/testing. Default value `true`.""" t.DefineStaticParameters( staticParams, @@ -57,15 +59,19 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = let ignoreControllerPrefix = unbox args.[2] let preferNullable = unbox args.[3] let preferAsync = unbox args.[4] + let ssrfProtection = unbox args.[5] let cacheKey = - (schemaPath, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync) + (schemaPath, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync, ssrfProtection) |> sprintf "%A" let addCache() = lazy - let schemaData = SchemaReader.readSchemaPath "" schemaPath |> Async.RunSynchronously + let schemaData = + SchemaReader.readSchemaPath (not ssrfProtection) "" schemaPath + |> Async.RunSynchronously + let openApiReader = Microsoft.OpenApi.Readers.OpenApiStringReader() let (schema, diagnostic) = openApiReader.Read(schemaData) diff --git a/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs b/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs index da2409d..8e44ffd 100644 --- a/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs +++ b/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs @@ -35,7 +35,8 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this = ProvidedStaticParameter("IgnoreOperationId", typeof, false) ProvidedStaticParameter("IgnoreControllerPrefix", typeof, true) ProvidedStaticParameter("PreferNullable", typeof, false) - ProvidedStaticParameter("PreferAsync", typeof, false) ] + ProvidedStaticParameter("PreferAsync", typeof, false) + ProvidedStaticParameter("SsrfProtection", typeof, true) ] t.AddXmlDoc """Statically typed Swagger provider. @@ -44,7 +45,8 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this = Do not use `operationsId` and generate method names using `path` only. Default value `false`. Do not parse `operationsId` as `_` and generate one client class for all operations. Default value `true`. Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`. - Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`.""" + Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`. + Enable SSRF protection (blocks HTTP and localhost). Set to false for development/testing. Default value `true`.""" t.DefineStaticParameters( staticParams, @@ -58,15 +60,16 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this = let ignoreControllerPrefix = unbox args.[3] let preferNullable = unbox args.[4] let preferAsync = unbox args.[5] + let ssrfProtection = unbox args.[6] let cacheKey = - (schemaPath, headersStr, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync) + (schemaPath, headersStr, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync, ssrfProtection) |> sprintf "%A" let addCache() = lazy let schemaData = - SchemaReader.readSchemaPath headersStr schemaPath + SchemaReader.readSchemaPath (not ssrfProtection) headersStr schemaPath |> Async.RunSynchronously let schema = SwaggerParser.parseSchema schemaData diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 7def369..9fe855f 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -16,15 +16,68 @@ module SchemaReader = else Path.Combine(resolutionFolder, schemaPathRaw) - let readSchemaPath (headersStr: string) (schemaPathRaw: string) = + /// Validates URL to prevent SSRF attacks + /// Pass ignoreSsrfProtection=true to disable validation (for development/testing only) + let validateSchemaUrl (ignoreSsrfProtection: bool) (url: Uri) = + if ignoreSsrfProtection then + () // Skip validation when explicitly disabled + else + // Only allow HTTPS for security (prevent MITM) + if url.Scheme <> "https" then + failwithf "Only HTTPS URLs are allowed for remote schemas. Got: %s (set SsrfProtection=false for development)" url.Scheme + + // Prevent access to private IP ranges (SSRF protection) + let host = url.Host.ToLowerInvariant() + + // 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 + + let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (schemaPathRaw: string) = async { - match Uri(schemaPathRaw).Scheme with - | "https" - | "http" -> + let uri = Uri schemaPathRaw + + match uri.Scheme with + | "https" -> + // Validate URL to prevent SSRF (unless explicitly disabled) + validateSchemaUrl ignoreSsrfProtection uri + let headers = - headersStr.Split('|') + headersStr.Split '|' |> Seq.choose(fun x -> - let pair = x.Split('=') + let pair = x.Split '=' if (pair.Length = 2) then Some(pair[0], pair[1]) else None) @@ -32,13 +85,31 @@ module SchemaReader = for name, value in headers do request.Headers.TryAddWithoutValidation(name, value) |> ignore - // using a custom handler means that we can set the default credentials. - use handler = new HttpClientHandler(UseDefaultCredentials = true) - use client = new HttpClient(handler) + + // SECURITY: Remove UseDefaultCredentials to prevent credential leakage (always enforced) + use handler = new HttpClientHandler(UseDefaultCredentials = false) + use client = new HttpClient(handler, Timeout = System.TimeSpan.FromSeconds 60.0) let! res = async { - let! response = client.SendAsync(request) |> Async.AwaitTask + let! response = client.SendAsync request |> Async.AwaitTask + + // Validate Content-Type to ensure we're parsing the correct format + let contentType = response.Content.Headers.ContentType + + 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 + return! response.Content.ReadAsStringAsync() |> Async.AwaitTask } |> Async.Catch @@ -66,6 +137,51 @@ module SchemaReader = else err.ToString() | Choice2Of2 e -> return failwith(e.ToString()) + | "http" -> + // HTTP is allowed only when SSRF protection is explicitly disabled (development/testing mode) + if not ignoreSsrfProtection then + return + failwithf + "HTTP URLs are not supported for security reasons. Use HTTPS or set SsrfProtection=false for development: %s" + schemaPathRaw + else + // Development mode: allow HTTP + validateSchemaUrl ignoreSsrfProtection uri // Still validate private IPs even in dev mode + + let headers = + headersStr.Split '|' + |> Seq.choose(fun x -> + let pair = x.Split '=' + if (pair.Length = 2) then Some(pair[0], pair[1]) else None) + + let request = new HttpRequestMessage(HttpMethod.Get, schemaPathRaw) + + for name, value in headers do + request.Headers.TryAddWithoutValidation(name, value) |> ignore + + use handler = new HttpClientHandler(UseDefaultCredentials = false) + use client = new HttpClient(handler, Timeout = System.TimeSpan.FromSeconds 60.0) + + let! res = + async { + let! response = client.SendAsync(request) |> Async.AwaitTask + return! response.Content.ReadAsStringAsync() |> Async.AwaitTask + } + |> Async.Catch + + match res with + | Choice1Of2 x -> return x + | Choice2Of2(:? WebException as wex) when not <| isNull wex.Response -> + use stream = wex.Response.GetResponseStream() + use reader = new StreamReader(stream) + let err = reader.ReadToEnd() + + return + if String.IsNullOrEmpty err then + wex.Reraise() + else + err.ToString() + | Choice2Of2 e -> return failwith(e.ToString()) | _ -> let request = WebRequest.Create(schemaPathRaw) use! response = request.GetResponseAsync() |> Async.AwaitTask diff --git a/tests/SwaggerProvider.ProviderTests/v2/Swashbuckle.ReturnControllers.Tests.fs b/tests/SwaggerProvider.ProviderTests/v2/Swashbuckle.ReturnControllers.Tests.fs index 15d7cab..97b7009 100644 --- a/tests/SwaggerProvider.ProviderTests/v2/Swashbuckle.ReturnControllers.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v2/Swashbuckle.ReturnControllers.Tests.fs @@ -1,4 +1,4 @@ -module Swashbuckle.v2.ReturnControllersTests +module Swashbuckle.v2.ReturnControllersTests open FsUnitTyped open Xunit @@ -6,7 +6,7 @@ open SwaggerProvider open System open System.Net.Http -type WebAPI = SwaggerClientProvider<"http://localhost:5000/swagger/v1/swagger.json", IgnoreOperationId=true> +type WebAPI = SwaggerClientProvider<"http://localhost:5000/swagger/v1/swagger.json", IgnoreOperationId=true, SsrfProtection=false> let api = let handler = new HttpClientHandler(UseCookies = false) diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs index 786fa9c..6bb5237 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs @@ -13,7 +13,7 @@ type CallLoggingHandler(messageHandler) = printfn $"[SendAsync]: %A{request.RequestUri}" base.SendAsync(request, cancellationToken) -type WebAPI = OpenApiClientProvider<"http://localhost:5000/swagger/v1/openapi.json", IgnoreOperationId=true> +type WebAPI = OpenApiClientProvider<"http://localhost:5000/swagger/v1/openapi.json", IgnoreOperationId=true, SsrfProtection=false> let api = let handler = new HttpClientHandler(UseCookies = false) From 3868a01cfb57faf764e3a44d53eb643f7ac6f20f Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Fri, 31 Oct 2025 20:09:39 +0000 Subject: [PATCH 2/7] Update src/SwaggerProvider.DesignTime/Utils.fs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/Utils.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 9fe855f..a712d5b 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -146,7 +146,7 @@ module SchemaReader = schemaPathRaw else // Development mode: allow HTTP - validateSchemaUrl ignoreSsrfProtection uri // Still validate private IPs even in dev mode + validateSchemaUrl ignoreSsrfProtection uri let headers = headersStr.Split '|' From 42233577f04e489b768b0fa579ac0fd63f7323e0 Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Fri, 31 Oct 2025 20:14:31 +0000 Subject: [PATCH 3/7] Update src/SwaggerProvider.DesignTime/Utils.fs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/Utils.fs | 59 ++++++++++--------------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index a712d5b..498b5ac 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -29,42 +29,29 @@ module SchemaReader = // Prevent access to private IP ranges (SSRF protection) let host = url.Host.ToLowerInvariant() - // 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 let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (schemaPathRaw: string) = async { let uri = Uri schemaPathRaw From f983e4e9950c776e6291b5eca28a2fa7555c96f7 Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Fri, 31 Oct 2025 20:26:36 +0000 Subject: [PATCH 4/7] Copilot feedback implemented, and formatted with Fantomas --- src/SwaggerProvider.DesignTime/Utils.fs | 65 ++++++++++++++++--------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 498b5ac..5b29a53 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -31,27 +31,57 @@ module SchemaReader = // 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 + 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) + (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) + || (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) + || (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) + || (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 + else if // Block localhost by name - if host = "localhost" then - failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host + host = "localhost" + then + failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host + + let validateContentType(contentType: Headers.MediaTypeHeaderValue) = + 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 + let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (schemaPathRaw: string) = async { let uri = Uri schemaPathRaw @@ -82,20 +112,7 @@ module SchemaReader = let! response = client.SendAsync request |> Async.AwaitTask // Validate Content-Type to ensure we're parsing the correct format - let contentType = response.Content.Headers.ContentType - - 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 + validateContentType response.Content.Headers.ContentType return! response.Content.ReadAsStringAsync() |> Async.AwaitTask } @@ -152,6 +169,10 @@ module SchemaReader = let! res = async { let! response = client.SendAsync(request) |> Async.AwaitTask + + // Validate Content-Type to ensure we're parsing the correct format + validateContentType response.Content.Headers.ContentType + return! response.Content.ReadAsStringAsync() |> Async.AwaitTask } |> Async.Catch From a6ece6646be9d73173649d73381680e6d009dc6a Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Sat, 1 Nov 2025 10:45:58 +0000 Subject: [PATCH 5/7] Content validation improved --- src/SwaggerProvider.DesignTime/Utils.fs | 48 ++++++++++++++++++------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 5b29a53..8c4092e 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -68,19 +68,41 @@ module SchemaReader = then failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host - let validateContentType(contentType: Headers.MediaTypeHeaderValue) = - if not(isNull contentType) then + let validateContentType (ignoreSsrfProtection: bool) (contentType: Headers.MediaTypeHeaderValue) = + // Skip validation if SSRF protection is disabled + if ignoreSsrfProtection || isNull contentType then + () + else 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 + // Allow only Content-Types that are valid for OpenAPI/Swagger schema files + // This prevents SSRF attacks where an attacker tries to make the provider + // fetch and process non-schema files (HTML, images, binaries, etc.) + let isValidSchemaContentType = + // JSON formats + mediaType = "application/json" + || mediaType = "application/json; charset=utf-8" + || mediaType.StartsWith "application/json;" + // YAML formats + || mediaType = "application/yaml" + || mediaType = "application/x-yaml" + || mediaType = "text/yaml" + || mediaType = "text/x-yaml" + || mediaType.StartsWith "application/yaml;" + || mediaType.StartsWith "application/x-yaml;" + || mediaType.StartsWith "text/yaml;" + || mediaType.StartsWith "text/x-yaml;" + // Plain text (sometimes used for YAML) + || mediaType = "text/plain" + || mediaType.StartsWith "text/plain;" + // Generic binary (fallback for misconfigured servers) + || mediaType = "application/octet-stream" + || mediaType.StartsWith "application/octet-stream;" + + if not isValidSchemaContentType then + failwithf + "Invalid Content-Type for schema: %s. Expected JSON or YAML content types only. This protects against SSRF attacks. Set SsrfProtection=false to disable this validation." + mediaType let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (schemaPathRaw: string) = async { @@ -112,7 +134,7 @@ module SchemaReader = let! response = client.SendAsync request |> Async.AwaitTask // Validate Content-Type to ensure we're parsing the correct format - validateContentType response.Content.Headers.ContentType + validateContentType ignoreSsrfProtection response.Content.Headers.ContentType return! response.Content.ReadAsStringAsync() |> Async.AwaitTask } @@ -171,7 +193,7 @@ module SchemaReader = let! response = client.SendAsync(request) |> Async.AwaitTask // Validate Content-Type to ensure we're parsing the correct format - validateContentType response.Content.Headers.ContentType + validateContentType ignoreSsrfProtection response.Content.Headers.ContentType return! response.Content.ReadAsStringAsync() |> Async.AwaitTask } From 190bfc4302e73e9f81f15ab447b8ed0cd09b31e3 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 2 Nov 2025 08:18:27 +0100 Subject: [PATCH 6/7] refact: pattern matching --- src/SwaggerProvider.DesignTime/Utils.fs | 32 ++++++++----------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 8c4092e..59e3857 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -12,7 +12,7 @@ module SchemaReader = if uri.IsAbsoluteUri then schemaPathRaw elif Path.IsPathRooted schemaPathRaw then - Path.Combine(Path.GetPathRoot(resolutionFolder), schemaPathRaw.Substring(1)) + Path.Combine(Path.GetPathRoot resolutionFolder, schemaPathRaw.Substring 1) else Path.Combine(resolutionFolder, schemaPathRaw) @@ -30,35 +30,23 @@ module SchemaReader = let host = url.Host.ToLowerInvariant() // Block localhost and loopback, and private IP ranges using proper IP address parsing - let isIp, ipAddr = System.Net.IPAddress.TryParse(host) + let isIp, ipAddr = IPAddress.TryParse host if isIp then // Loopback - if - System.Net.IPAddress.IsLoopback(ipAddr) - || ipAddr.ToString() = "0.0.0.0" - then + if 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) + ipAddr.AddressFamily = Sockets.AddressFamily.InterNetwork + && match bytes with + | [| 10uy; _; _; _ |] -> true // 10.0.0.0/8 + | [| 172uy; b1; _; _ |] when b1 >= 16uy && b1 <= 31uy -> true // 172.16.0.0/12 + | [| 192uy; 168uy; _; _ |] -> true // 192.168.0.0/16 + | [| 169uy; 254uy; _; _ |] -> true // Link-local 169.254.0.0/16 + | _ -> false if isPrivate then failwithf "Cannot fetch schemas from private or link-local IP addresses: %s (set SsrfProtection=false for development)" host From b0715764afc3162d7ecfa6b87c577496ba724018 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 2 Nov 2025 09:13:08 +0100 Subject: [PATCH 7/7] fix: remove duplicated condition --- src/SwaggerProvider.DesignTime/Utils.fs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 59e3857..ee31712 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -69,7 +69,6 @@ module SchemaReader = let isValidSchemaContentType = // JSON formats mediaType = "application/json" - || mediaType = "application/json; charset=utf-8" || mediaType.StartsWith "application/json;" // YAML formats || mediaType = "application/yaml"