From e8c6cb1e9f2e327feebb953327f35c9adf097d24 Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Tue, 28 Oct 2025 12:48:20 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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" From 9d07d5aa1a4bcc4e0d00a26d11f84789a949c3ff Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 2 Nov 2025 13:37:47 +0100 Subject: [PATCH 08/10] fix: handle relative file paths that expand to absolute paths The readSchemaPath function now properly handles relative file paths (e.g., those created with __SOURCE_DIRECTORY__) by attempting to resolve them to absolute paths before treating them as remote URLs. This ensures paths like '__SOURCE_DIRECTORY__ + "/../Schemas/v2/petstore.json"' are correctly recognized as local files instead of being rejected by SSRF validation. --- .config/dotnet-tools.json | 2 +- AGENTS.md | 63 ++++ src/SwaggerProvider.DesignTime/Utils.fs | 345 ++++++++++------- .../SsrfSecurityTests.fs | 352 ++++++++++++++++++ .../SwaggerProvider.Tests.fsproj | 1 + 5 files changed, 629 insertions(+), 134 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/SwaggerProvider.Tests/SsrfSecurityTests.fs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 13f3a42..8ba49f1 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "fantomas": { - "version": "7.0.0", + "version": "7.0.3", "commands": [ "fantomas" ], diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..90ec38b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +## Build, Test & Lint Commands + +- **Build**: `dotnet fake build -t Build` (Release configuration) +- **Format Check**: `dotnet fake build -t CheckFormat` (validates Fantomas formatting) +- **Format**: `dotnet fake build -t Format` (applies Fantomas formatting) +- **All Tests**: `dotnet fake build -t RunTests` (builds + starts test server + runs all tests) +- **Unit Tests Only**: `dotnet build && dotnet tests/SwaggerProvider.Tests/bin/Release/net9.0/SwaggerProvider.Tests.dll` +- **Provider Tests (Integration)**: + 1. Build test server: `dotnet build tests/Swashbuckle.WebApi.Server/Swashbuckle.WebApi.Server.fsproj -c Release` + 2. Start server in background: `dotnet tests/Swashbuckle.WebApi.Server/bin/Release/net9.0/Swashbuckle.WebApi.Server.dll` + 3. Build tests: `dotnet build SwaggerProvider.TestsAndDocs.sln -c Release` + 4. Run tests: `dotnet tests/SwaggerProvider.ProviderTests/bin/Release/net9.0/SwaggerProvider.ProviderTests.dll` +- **Single Test**: Run via xunit runner: `dotnet [assembly] [filter]` + +## Code Style Guidelines + +**Language**: F# (net9.0 target framework) + +**Imports & Namespaces**: + +- `namespace [Module]` at file start; no `open` statements at module level +- Use `module [Name]` for nested modules +- Open dependencies after namespace declaration (e.g., `open Xunit`, `open FsUnitTyped`) +- Fully qualify internal modules: `SwaggerProvider.Internal.v2.Parser`, `SwaggerProvider.Internal.v3.Compilers` + +**Formatting** (via Fantomas, EditorConfig enforced): + +- 4-space indents, max 150 char line length +- `fsharp_max_function_binding_width=10`, `fsharp_max_infix_operator_expression=70` +- No space before parameter/lowercase invocation +- Multiline block brackets on same column, Stroustrup style enabled +- Bar before discriminated union declarations, max 3 blank lines + +**Naming Conventions**: + +- PascalCase for classes, types, modules, public members +- camelCase for local/private bindings, parameters +- Suffix test functions with `Tests` or use attributes like `[]`, `[]` + +**Type Annotations**: + +- Explicit return types for public functions (recommended) +- Use type inference for local bindings when obvious +- Generic type parameters: `'a`, `'b` (single quote prefix) + +**Error Handling**: + +- Use `Result<'T, 'Error>` or `Option<'T>` for fallible operations +- `failwith` or `failwithf` for errors in type providers and compilers +- Task-based async for I/O: `task { }` expressions in tests +- Match failures with `| _ -> ...` or pattern guards with `when` + +**File Organization**: + +- Tests use Xunit attributes: `[]`, `[]`, `[]` +- Design-time providers in `src/SwaggerProvider.DesignTime/`, runtime in `src/SwaggerProvider.Runtime/` +- Test schemas organized by OpenAPI version: `tests/.../Schemas/{v2,v3}/` + +## Key Patterns + +- Type Providers use `ProvidedApiClientBase` and compiler pipeline (DefinitionCompiler, OperationCompiler) +- SSRF protection enabled by default; disable with `SsrfProtection=false` static parameter +- Target net9.0; use implicit async/await (task expressions) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index ee31712..1902ef1 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -7,6 +7,9 @@ module SchemaReader = open System.Net.Http let getAbsolutePath (resolutionFolder: string) (schemaPathRaw: string) = + if String.IsNullOrWhiteSpace(schemaPathRaw) then + invalidArg "schemaPathRaw" "The schema path cannot be null or empty." + let uri = Uri(schemaPathRaw, UriKind.RelativeOrAbsolute) if uri.IsAbsoluteUri then @@ -33,27 +36,60 @@ module SchemaReader = let isIp, ipAddr = IPAddress.TryParse host if isIp then - // Loopback - 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 = - 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 - else if - // Block localhost by name - host = "localhost" - then + // Check address family first to apply family-specific rules + match ipAddr.AddressFamily with + | Sockets.AddressFamily.InterNetwork -> + // IPv4 validation + let bytes = ipAddr.GetAddressBytes() + + // Check for IPv4 loopback or unspecified address + 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 + + // Check for IPv4 private ranges + let isPrivateIPv4 = + match bytes with + // 10.0.0.0/8 + | [| 10uy; _; _; _ |] -> true + // 172.16.0.0/12 + | [| 172uy; secondByte; _; _ |] when secondByte >= 16uy && secondByte <= 31uy -> true + // 192.168.0.0/16 + | [| 192uy; 168uy; _; _ |] -> true + // Link-local 169.254.0.0/16 + | [| 169uy; 254uy; _; _ |] -> true + | _ -> false + + if isPrivateIPv4 then + failwithf "Cannot fetch schemas from private or link-local IP addresses: %s (set SsrfProtection=false for development)" host + + | Sockets.AddressFamily.InterNetworkV6 -> + // IPv6 validation + let bytes = ipAddr.GetAddressBytes() + + // Check for IPv6 private or reserved ranges + let isPrivateIPv6 = + match bytes with + // Loopback (::1) + | [| 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 1uy |] -> true + // Unspecified address (::) + | [| 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy |] -> true + // Link-local (fe80::/10) - first byte 0xFE, second byte 0x80-0xBF + | [| 0xFEuy; secondByte; _; _; _; _; _; _; _; _; _; _; _; _; _; _ |] when secondByte >= 0x80uy && secondByte <= 0xBFuy -> true + // Unique Local Unicast (fc00::/7) - first byte 0xFC or 0xFD + | [| 0xFCuy; _; _; _; _; _; _; _; _; _; _; _; _; _; _; _ |] -> true + | [| 0xFDuy; _; _; _; _; _; _; _; _; _; _; _; _; _; _; _ |] -> true + // Multicast (ff00::/8) - first byte 0xFF + | [| 0xFFuy; _; _; _; _; _; _; _; _; _; _; _; _; _; _; _ |] -> true + | _ -> false + + if isPrivateIPv6 then + failwithf "Cannot fetch schemas from private or loopback IPv6 addresses: %s (set SsrfProtection=false for development)" host + + | _ -> + // Unsupported address family + failwithf "Cannot fetch schemas from unsupported IP address type: %s (set SsrfProtection=false for development)" host + // Block localhost by hostname + else if host = "localhost" then failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host let validateContentType (ignoreSsrfProtection: bool) (contentType: Headers.MediaTypeHeaderValue) = @@ -92,118 +128,161 @@ module SchemaReader = mediaType let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (schemaPathRaw: string) = - async { - let uri = Uri schemaPathRaw - - match uri.Scheme with - | "https" -> - // Validate URL to prevent SSRF (unless explicitly disabled) - validateSchemaUrl ignoreSsrfProtection uri - - 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 - - // 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 - - // Validate Content-Type to ensure we're parsing the correct format - validateContentType ignoreSsrfProtection response.Content.Headers.ContentType - - return! response.Content.ReadAsStringAsync() |> Async.AwaitTask - } - |> Async.Catch - - match res with - | Choice1Of2 x -> return x - | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> - let content = - ex.Content.ReadAsStringAsync() - |> Async.AwaitTask - |> Async.RunSynchronously - - if String.IsNullOrEmpty content then - return ex.Reraise() - else - return content - | 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()) - | "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 - - 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 - - // Validate Content-Type to ensure we're parsing the correct format - validateContentType ignoreSsrfProtection response.Content.Headers.ContentType - - 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 - use sr = new StreamReader(response.GetResponseStream()) - return! sr.ReadToEndAsync() |> Async.AwaitTask + async { + // Check if this is a local file path (not a remote URL) + // First try to treat it as a local file path (absolute or relative) + let possibleFilePath = + try + if Path.IsPathRooted schemaPathRaw then + // Already an absolute path + if File.Exists schemaPathRaw then Some schemaPathRaw else None + else + // Try to resolve relative paths (e.g., paths with ../ or from __SOURCE_DIRECTORY__) + let resolved = Path.GetFullPath schemaPathRaw + if File.Exists resolved then Some resolved else None + with + | _ -> None + + match possibleFilePath with + | Some filePath -> + // Handle local file - read from disk + try + return File.ReadAllText filePath + with + | :? FileNotFoundException -> return failwithf "Schema file not found: %s" filePath + | ex -> return failwithf "Error reading schema file '%s': %s" filePath ex.Message + | None -> + // Handle as remote URL (HTTP/HTTPS) + let checkUri = Uri(schemaPathRaw, UriKind.RelativeOrAbsolute) + // Only treat truly local paths as local files (no scheme or relative paths) + // Reject file:// scheme as unsupported to prevent SSRF attacks + let isLocalFile = not checkUri.IsAbsoluteUri + + if isLocalFile then + // This shouldn't happen as we already checked for file existence above, + // but keep this for consistency with original logic + let filePath = getAbsolutePath "" schemaPathRaw + + try + return File.ReadAllText filePath + with + | :? FileNotFoundException -> return failwithf "Schema file not found: %s" filePath + | ex -> return failwithf "Error reading schema file '%s': %s" filePath ex.Message + else + // Handle remote URL (HTTP/HTTPS) + let uri = Uri schemaPathRaw + + match uri.Scheme with + | "https" -> + // Validate URL to prevent SSRF (unless explicitly disabled) + validateSchemaUrl ignoreSsrfProtection uri + + 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 + + // SECURITY: Remove UseDefaultCredentials to prevent credential leakage (always enforced) + use handler = new HttpClientHandler(UseDefaultCredentials = false) + use client = new HttpClient(handler, Timeout = TimeSpan.FromSeconds 60.0) + + let! res = + async { + let! response = client.SendAsync request |> Async.AwaitTask + + // Validate Content-Type to ensure we're parsing the correct format + validateContentType ignoreSsrfProtection response.Content.Headers.ContentType + + return! response.Content.ReadAsStringAsync() |> Async.AwaitTask + } + |> Async.Catch + + match res with + | Choice1Of2 x -> return x + | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> + let content = + ex.Content.ReadAsStringAsync() + |> Async.AwaitTask + |> Async.RunSynchronously + + if String.IsNullOrEmpty content then + return ex.Reraise() + else + return content + | 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()) + + | "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 + + 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 = TimeSpan.FromSeconds 60.0) + + let! res = + async { + let! response = client.SendAsync(request) |> Async.AwaitTask + + // Validate Content-Type to ensure we're parsing the correct format + validateContentType ignoreSsrfProtection response.Content.Headers.ContentType + + 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()) + + | _ -> + // SECURITY: Reject unknown URL schemes to prevent SSRF attacks via file://, ftp://, etc. + return + failwithf + "Unsupported URL scheme in schema path: '%s'. Only HTTPS and HTTP schemes are supported for remote schemas. If using a local file, ensure the path is absolute or relative to the resolution folder." + schemaPathRaw } type UniqueNameGenerator() = diff --git a/tests/SwaggerProvider.Tests/SsrfSecurityTests.fs b/tests/SwaggerProvider.Tests/SsrfSecurityTests.fs new file mode 100644 index 0000000..fd57a7c --- /dev/null +++ b/tests/SwaggerProvider.Tests/SsrfSecurityTests.fs @@ -0,0 +1,352 @@ +namespace SwaggerProvider.Tests.SsrfSecurityTests + +open System +open Xunit +open SwaggerProvider.Internal.SchemaReader + +/// Tests for SSRF protection - Critical: Unknown URL schemes +/// These tests verify that only safe URL schemes are allowed +module UnknownSchemeTests = + + [] + let ``Reject file protocol to prevent local file access``() = + task { + // Test: file:// protocol should be rejected to prevent SSRF via local file access + let fileUrl = "file:///etc/passwd" + + let! ex = + Assert.ThrowsAsync(fun () -> + task { + let! _ = readSchemaPath false "" fileUrl + return () + }) + + + Assert.Contains("Unsupported URL scheme", ex.Message) + Assert.Contains("file://", ex.Message) + } + + [] + let ``Reject FTP protocol to prevent remote protocol access``() = + task { + // Test: ftp:// protocol should be rejected to prevent SSRF via FTP + let ftp_url = "ftp://internal-server/schema.json" + + let! ex = + Assert.ThrowsAsync(fun () -> + task { + let! _ = readSchemaPath false "" ftp_url + return () + }) + + Assert.Contains("Unsupported URL scheme", ex.Message) + } + + [] + let ``Reject Gopher protocol to prevent remote protocol access``() = + task { + // Test: gopher:// protocol should be rejected to prevent SSRF via Gopher + let gopher_url = "gopher://internal-server/schema.json" + + let! ex = + Assert.ThrowsAsync(fun () -> + task { + let! _ = readSchemaPath false "" gopher_url + return () + }) + + Assert.Contains("Unsupported URL scheme", ex.Message) + } + + [] + let ``Reject DICT protocol to prevent remote protocol access``() = + task { + // Test: dict:// protocol should be rejected to prevent SSRF via DICT + let dict_url = "dict://internal-server/schema.json" + + let! ex = + Assert.ThrowsAsync(fun () -> + task { + let! _ = readSchemaPath false "" dict_url + return () + }) + + Assert.Contains("Unsupported URL scheme", ex.Message) + } + + +/// Tests for SSRF protection - High: IPv6 private ranges +/// These tests verify that IPv6 loopback, link-local, ULA, multicast addresses are rejected +module IPv6SecurityTests = + + [] + let ``Reject IPv6 loopback address ::1``() = + // Test: IPv6 loopback ::1 should be rejected to prevent access to localhost services + let ipv6_loopback_uri = Uri("https://[::1]/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false ipv6_loopback_uri) + + Assert.Contains("private or loopback IPv6 addresses", thrown_exception.Message) + Assert.Contains("::1", thrown_exception.Message) + + [] + let ``Reject IPv6 link-local address fe80::1``() = + // Test: IPv6 link-local fe80::1 should be rejected to prevent access to link-local services + let ipv6_link_local_uri = Uri("https://[fe80::1]/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false ipv6_link_local_uri) + + Assert.Contains("private or loopback IPv6 addresses", thrown_exception.Message) + + [] + let ``Reject IPv6 unique local address fd00::1``() = + // Test: IPv6 ULA fd00::1 should be rejected to prevent access to private network ranges + let ipv6_ula_uri = Uri("https://[fd00::1]/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false ipv6_ula_uri) + + Assert.Contains("private or loopback IPv6 addresses", thrown_exception.Message) + + [] + let ``Reject IPv6 unique local address fc00::1``() = + // Test: IPv6 ULA fc00::1 should be rejected to prevent access to private network ranges + let ipv6_ula_fc_uri = Uri("https://[fc00::1]/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false ipv6_ula_fc_uri) + + Assert.Contains("private or loopback IPv6 addresses", thrown_exception.Message) + + [] + let ``Reject IPv6 unspecified address ::``() = + // Test: IPv6 unspecified address :: should be rejected to prevent access to localhost services + let ipv6_unspecified_uri = Uri("https://[::]/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false ipv6_unspecified_uri) + + Assert.Contains("private or loopback IPv6 addresses", thrown_exception.Message) + + [] + let ``Reject IPv6 multicast address ff02::1``() = + // Test: IPv6 multicast ff02::1 should be rejected to prevent access to multicast addresses + let ipv6_multicast_uri = Uri("https://[ff02::1]/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false ipv6_multicast_uri) + + Assert.Contains("private or loopback IPv6 addresses", thrown_exception.Message) + + [] + let ``Reject IPv6 multicast address ff00::1``() = + // Test: IPv6 multicast ff00::1 should be rejected to prevent access to multicast addresses + let ipv6_multicast_ff00_uri = Uri("https://[ff00::1]/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false ipv6_multicast_ff00_uri) + + Assert.Contains("private or loopback IPv6 addresses", thrown_exception.Message) + + [] + let ``Allow public IPv6 documentation address 2001:db8::1``() = + // Test: Public IPv6 documentation range 2001:db8::1 should pass SSRF validation + // (Note: May fail due to network access, but SSRF validation should pass) + let public_ipv6_uri = Uri("https://[2001:db8::1]/schema.json") + + try + validateSchemaUrl false public_ipv6_uri + with + | ex when ex.Message.Contains("private or loopback") -> + // SSRF validation failed incorrectly + Assert.True(false, $"Public IPv6 should not be blocked by SSRF validation: {ex.Message}") + | _ -> + // Other errors are also acceptable (network, etc.) + () + + + +/// Tests for IPv4 private ranges +/// These tests verify that IPv4 loopback and private ranges are rejected +module IPv4PrivateRangeTests = + + [] + let ``Reject IPv4 loopback address 127.0.0.1``() = + // Test: IPv4 loopback 127.0.0.1 should be rejected to prevent access to localhost services + let loopback_uri = Uri("https://127.0.0.1/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false loopback_uri) + + Assert.Contains("localhost/loopback", thrown_exception.Message) + + [] + let ``Reject IPv4 private range 10.0.0.0/8``() = + // Test: IPv4 private range 10.0.0.1 should be rejected to prevent access to private networks + let private_10_uri = Uri("https://10.0.0.1/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false private_10_uri) + + Assert.Contains("private or link-local", thrown_exception.Message) + + [] + let ``Reject IPv4 private range 172.16.0.0/12``() = + // Test: IPv4 private range 172.16.0.1 should be rejected to prevent access to private networks + let private_172_uri = Uri("https://172.16.0.1/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false private_172_uri) + + Assert.Contains("private or link-local", thrown_exception.Message) + + [] + let ``Reject IPv4 private range 172.31.255.255``() = + // Test: IPv4 private range upper bound 172.31.255.255 should be rejected + let private_172_upper_uri = Uri("https://172.31.255.255/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false private_172_upper_uri) + + Assert.Contains("private or link-local", thrown_exception.Message) + + [] + let ``Reject IPv4 private range 192.168.0.0/16``() = + // Test: IPv4 private range 192.168.1.1 should be rejected to prevent access to private networks + let private_192_uri = Uri("https://192.168.1.1/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false private_192_uri) + + Assert.Contains("private or link-local", thrown_exception.Message) + + [] + let ``Reject IPv4 link-local address 169.254.0.0/16``() = + // Test: IPv4 link-local 169.254.0.1 should be rejected to prevent access to link-local services + let link_local_uri = Uri("https://169.254.0.1/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false link_local_uri) + + Assert.Contains("private or link-local", thrown_exception.Message) + + +/// Tests for hostname validation +/// These tests verify that localhost hostname and public hostnames are handled correctly +module HostnameValidationTests = + + [] + let ``Reject localhost hostname``() = + // Test: localhost hostname should be rejected to prevent access to localhost services + let localhost_uri = Uri("https://localhost/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false localhost_uri) + + Assert.Contains("localhost/loopback", thrown_exception.Message) + + [] + let ``Allow valid public hostname api.example.com``() = + // Test: Valid public hostname should pass SSRF validation + // (Note: May fail due to network access, but SSRF validation should pass) + let public_uri = Uri("https://api.example.com/schema.json") + + try + validateSchemaUrl false public_uri + with + | ex when ex.Message.Contains("localhost") || ex.Message.Contains("private") -> + Assert.Fail($"Public hostname should not be blocked by SSRF validation: {ex.Message}") + | _ -> () + + +/// Tests for relative file paths +/// These tests verify that relative file paths with __SOURCE_DIRECTORY__ work correctly +module RelativeFilePathTests = + + [] + let ``Allow relative file paths with __SOURCE_DIRECTORY__``() = + task { + // Test: Relative file paths using __SOURCE_DIRECTORY__ should work correctly + // This ensures that development-time file references like: + // let Schema = __SOURCE_DIRECTORY__ + "/../Schemas/v2/petstore.json" + // are properly handled (not rejected by SSRF validation) + let schemaPath = __SOURCE_DIRECTORY__ + "/../Schemas/v2/petstore.json" + + try + let! _ = readSchemaPath false "" schemaPath + () // If file exists, that's fine + with + | :? Swagger.OpenApiException -> + // Swagger parsing errors are okay - means file was read + () + | ex when ex.Message.Contains("Schema file not found") -> + // File not found is okay - path was resolved correctly + () + | ex when + ex.Message.Contains("Unsupported URL scheme") + || ex.Message.Contains("localhost") + || ex.Message.Contains("private") + -> + // SSRF validation errors mean relative paths are being blocked - this is the bug we're checking for + Assert.Fail($"Relative file paths should not be rejected by SSRF validation: {ex.Message}") + | _ -> + // Other errors (file reading issues, etc.) are acceptable + () + } + + +/// Tests for disabled SSRF protection (development mode) +/// These tests verify that when SSRF protection is disabled, all addresses are allowed +module SsrfBypassTests = + + [] + let ``Allow IPv4 loopback when ignoreSsrfProtection is true``() = + // Test: IPv4 loopback should be allowed when SSRF protection is disabled + let loopback_uri = Uri("https://127.0.0.1/schema.json") + // Should not throw when ignoreSsrfProtection=true + validateSchemaUrl true loopback_uri + + [] + let ``Allow IPv6 loopback when ignoreSsrfProtection is true``() = + // Test: IPv6 loopback should be allowed when SSRF protection is disabled + let ipv6_loopback_uri = Uri("https://[::1]/schema.json") + // Should not throw when ignoreSsrfProtection=true + validateSchemaUrl true ipv6_loopback_uri + + [] + let ``Allow IPv4 private range when ignoreSsrfProtection is true``() = + // Test: IPv4 private range should be allowed when SSRF protection is disabled + let private_uri = Uri("https://192.168.1.1/schema.json") + // Should not throw when ignoreSsrfProtection=true + validateSchemaUrl true private_uri + + [] + let ``Allow IPv6 private range when ignoreSsrfProtection is true``() = + // Test: IPv6 private range should be allowed when SSRF protection is disabled + let ipv6_private_uri = Uri("https://[fd00::1]/schema.json") + // Should not throw when ignoreSsrfProtection=true + validateSchemaUrl true ipv6_private_uri + + [] + let ``Reject HTTP in production mode``() = + // Test: HTTP should be rejected in production mode (HTTPS only) + let http_url = Uri("http://api.example.com/schema.json") + + let thrown_exception = + Assert.Throws(fun () -> validateSchemaUrl false http_url) + + Assert.Contains("Only HTTPS URLs are allowed", thrown_exception.Message) + + [] + let ``Allow HTTP when ignoreSsrfProtection is true``() = + // Test: HTTP should be allowed when SSRF protection is disabled (development mode) + let http_url = Uri("http://localhost/schema.json") + + try + validateSchemaUrl true http_url + with + | ex when ex.Message.Contains("Only HTTPS") -> + Assert.True(false, $"HTTP should not be rejected by SSRF validation when disabled: {ex.Message}") + | _ -> () diff --git a/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj b/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj index 3292314..5b94161 100644 --- a/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj +++ b/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj @@ -16,6 +16,7 @@ + From 5f73506e9ccdfda778233003a71d3de9ef0e6d12 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 2 Nov 2025 14:09:06 +0100 Subject: [PATCH 09/10] refactor: consolidate readSchemaPath and getAbsolutePath functions Merge path resolution into readSchemaPath to eliminate redundant calls and improve code clarity: - Move getAbsolutePath logic into readSchemaPath, making it fully self-contained - Remove dead code path that attempted to re-resolve with empty resolutionFolder - Rename Cache modules to SwaggerCache and OpenApiCache to avoid naming conflicts - Update test calls to pass resolutionFolder parameter - All 274 tests pass (116 unit + 158 integration) --- .../Provider.OpenApiClient.fs | 20 +- .../Provider.SwaggerClient.fs | 16 +- src/SwaggerProvider.DesignTime/Utils.fs | 308 +++++++++--------- .../SsrfSecurityTests.fs | 10 +- 4 files changed, 173 insertions(+), 181 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs index 8611c97..bf08144 100644 --- a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs +++ b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs @@ -8,7 +8,7 @@ open Swagger open SwaggerProvider.Internal open SwaggerProvider.Internal.v3.Compilers -module Cache = +module OpenApiCache = let providedTypes = Caching.createInMemoryCache(TimeSpan.FromSeconds 30.0) /// The Open API Provider. @@ -51,10 +51,7 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = t.DefineStaticParameters( staticParams, fun typeName args -> - let schemaPath = - let schemaPathRaw = unbox args.[0] - SchemaReader.getAbsolutePath cfg.ResolutionFolder schemaPathRaw - + let schemaPathRaw = unbox args.[0] let ignoreOperationId = unbox args.[1] let ignoreControllerPrefix = unbox args.[2] let preferNullable = unbox args.[3] @@ -62,14 +59,13 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = let ssrfProtection = unbox args.[5] let cacheKey = - (schemaPath, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync, ssrfProtection) + (schemaPathRaw, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync, ssrfProtection) |> sprintf "%A" - let addCache() = lazy let schemaData = - SchemaReader.readSchemaPath (not ssrfProtection) "" schemaPath + SchemaReader.readSchemaPath (not ssrfProtection) "" cfg.ResolutionFolder schemaPathRaw |> Async.RunSynchronously let openApiReader = Microsoft.OpenApi.Readers.OpenApiStringReader() @@ -96,18 +92,18 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = let ty = ProvidedTypeDefinition(tempAsm, ns, typeName, Some typeof, isErased = false, hideObjectMethods = true) - ty.AddXmlDoc("OpenAPI Provider for " + schemaPath) + ty.AddXmlDoc("OpenAPI Provider for " + schemaPathRaw) ty.AddMembers tys tempAsm.AddTypes [ ty ] ty try - Cache.providedTypes.GetOrAdd(cacheKey, addCache).Value + OpenApiCache.providedTypes.GetOrAdd(cacheKey, addCache).Value with _ -> - Cache.providedTypes.Remove(cacheKey) |> ignore + OpenApiCache.providedTypes.Remove(cacheKey) |> ignore - Cache.providedTypes.GetOrAdd(cacheKey, addCache).Value + OpenApiCache.providedTypes.GetOrAdd(cacheKey, addCache).Value ) t diff --git a/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs b/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs index 8e44ffd..0102e01 100644 --- a/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs +++ b/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs @@ -9,6 +9,9 @@ open SwaggerProvider.Internal open SwaggerProvider.Internal.v2.Parser open SwaggerProvider.Internal.v2.Compilers +module SwaggerCache = + let providedTypes = Caching.createInMemoryCache(TimeSpan.FromSeconds 30.0) + /// The Swagger Type Provider. [] type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this = @@ -51,10 +54,7 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this = t.DefineStaticParameters( staticParams, fun typeName args -> - let schemaPath = - let schemaPathRaw = unbox args.[0] - SchemaReader.getAbsolutePath cfg.ResolutionFolder schemaPathRaw - + let schemaPathRaw = unbox args.[0] let headersStr = unbox args.[1] let ignoreOperationId = unbox args.[2] let ignoreControllerPrefix = unbox args.[3] @@ -63,13 +63,13 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this = let ssrfProtection = unbox args.[6] let cacheKey = - (schemaPath, headersStr, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync, ssrfProtection) + (schemaPathRaw, headersStr, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync, ssrfProtection) |> sprintf "%A" let addCache() = lazy let schemaData = - SchemaReader.readSchemaPath (not ssrfProtection) headersStr schemaPath + SchemaReader.readSchemaPath (not ssrfProtection) headersStr cfg.ResolutionFolder schemaPathRaw |> Async.RunSynchronously let schema = SwaggerParser.parseSchema schemaData @@ -87,13 +87,13 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this = let ty = ProvidedTypeDefinition(tempAsm, ns, typeName, Some typeof, isErased = false, hideObjectMethods = true) - ty.AddXmlDoc("Swagger Provider for " + schemaPath) + ty.AddXmlDoc("Swagger Provider for " + schemaPathRaw) ty.AddMembers tys tempAsm.AddTypes [ ty ] ty - Cache.providedTypes.GetOrAdd(cacheKey, addCache).Value + SwaggerCache.providedTypes.GetOrAdd(cacheKey, addCache).Value ) t diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 1902ef1..d55a0cb 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -127,162 +127,158 @@ module SchemaReader = "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 { - // Check if this is a local file path (not a remote URL) - // First try to treat it as a local file path (absolute or relative) - let possibleFilePath = - try - if Path.IsPathRooted schemaPathRaw then - // Already an absolute path - if File.Exists schemaPathRaw then Some schemaPathRaw else None - else - // Try to resolve relative paths (e.g., paths with ../ or from __SOURCE_DIRECTORY__) - let resolved = Path.GetFullPath schemaPathRaw - if File.Exists resolved then Some resolved else None - with - | _ -> None - - match possibleFilePath with - | Some filePath -> - // Handle local file - read from disk - try - return File.ReadAllText filePath - with - | :? FileNotFoundException -> return failwithf "Schema file not found: %s" filePath - | ex -> return failwithf "Error reading schema file '%s': %s" filePath ex.Message - | None -> - // Handle as remote URL (HTTP/HTTPS) - let checkUri = Uri(schemaPathRaw, UriKind.RelativeOrAbsolute) - // Only treat truly local paths as local files (no scheme or relative paths) - // Reject file:// scheme as unsupported to prevent SSRF attacks - let isLocalFile = not checkUri.IsAbsoluteUri - - if isLocalFile then - // This shouldn't happen as we already checked for file existence above, - // but keep this for consistency with original logic - let filePath = getAbsolutePath "" schemaPathRaw - - try - return File.ReadAllText filePath - with - | :? FileNotFoundException -> return failwithf "Schema file not found: %s" filePath - | ex -> return failwithf "Error reading schema file '%s': %s" filePath ex.Message - else - // Handle remote URL (HTTP/HTTPS) - let uri = Uri schemaPathRaw - - match uri.Scheme with - | "https" -> - // Validate URL to prevent SSRF (unless explicitly disabled) - validateSchemaUrl ignoreSsrfProtection uri - - 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 - - // SECURITY: Remove UseDefaultCredentials to prevent credential leakage (always enforced) - use handler = new HttpClientHandler(UseDefaultCredentials = false) - use client = new HttpClient(handler, Timeout = TimeSpan.FromSeconds 60.0) - - let! res = - async { - let! response = client.SendAsync request |> Async.AwaitTask - - // Validate Content-Type to ensure we're parsing the correct format - validateContentType ignoreSsrfProtection response.Content.Headers.ContentType - - return! response.Content.ReadAsStringAsync() |> Async.AwaitTask - } - |> Async.Catch - - match res with - | Choice1Of2 x -> return x - | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> - let content = - ex.Content.ReadAsStringAsync() - |> Async.AwaitTask - |> Async.RunSynchronously - - if String.IsNullOrEmpty content then - return ex.Reraise() - else - return content - | 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()) - - | "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 - - 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 = TimeSpan.FromSeconds 60.0) - - let! res = - async { - let! response = client.SendAsync(request) |> Async.AwaitTask - - // Validate Content-Type to ensure we're parsing the correct format - validateContentType ignoreSsrfProtection response.Content.Headers.ContentType - - 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()) - - | _ -> - // SECURITY: Reject unknown URL schemes to prevent SSRF attacks via file://, ftp://, etc. - return - failwithf - "Unsupported URL scheme in schema path: '%s'. Only HTTPS and HTTP schemes are supported for remote schemas. If using a local file, ensure the path is absolute or relative to the resolution folder." - schemaPathRaw + let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (resolutionFolder: string) (schemaPathRaw: string) = + async { + // Resolve the schema path to absolute path first + let resolvedPath = getAbsolutePath resolutionFolder schemaPathRaw + + // Check if this is a local file path (not a remote URL) + // First try to treat it as a local file path (absolute or relative) + let possibleFilePath = + try + if Path.IsPathRooted resolvedPath then + // Already an absolute path + if File.Exists resolvedPath then Some resolvedPath else None + else + // Try to resolve relative paths (e.g., paths with ../ or from __SOURCE_DIRECTORY__) + let resolved = Path.GetFullPath resolvedPath + if File.Exists resolved then Some resolved else None + with _ -> + None + + match possibleFilePath with + | Some filePath -> + // Handle local file - read from disk + try + return File.ReadAllText filePath + with + | :? FileNotFoundException -> return failwithf "Schema file not found: %s" filePath + | ex -> return failwithf "Error reading schema file '%s': %s" filePath ex.Message + | None -> + // Handle as remote URL (HTTP/HTTPS) + let checkUri = Uri(resolvedPath, UriKind.RelativeOrAbsolute) + // Only treat truly local paths as local files (no scheme or relative paths) + // Reject file:// scheme as unsupported to prevent SSRF attacks + let isLocalFile = not checkUri.IsAbsoluteUri + + if isLocalFile then + // If we reach here with a local file that wasn't found, report the error + return failwithf "Schema file not found: %s" resolvedPath + else + // Handle remote URL (HTTP/HTTPS) + let uri = Uri resolvedPath + + match uri.Scheme with + | "https" -> + // Validate URL to prevent SSRF (unless explicitly disabled) + validateSchemaUrl ignoreSsrfProtection uri + + 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, resolvedPath) + + for name, value in headers do + request.Headers.TryAddWithoutValidation(name, value) |> ignore + + // SECURITY: Remove UseDefaultCredentials to prevent credential leakage (always enforced) + use handler = new HttpClientHandler(UseDefaultCredentials = false) + use client = new HttpClient(handler, Timeout = TimeSpan.FromSeconds 60.0) + + let! res = + async { + let! response = client.SendAsync request |> Async.AwaitTask + + // Validate Content-Type to ensure we're parsing the correct format + validateContentType ignoreSsrfProtection response.Content.Headers.ContentType + + return! response.Content.ReadAsStringAsync() |> Async.AwaitTask + } + |> Async.Catch + + match res with + | Choice1Of2 x -> return x + | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> + let content = + ex.Content.ReadAsStringAsync() + |> Async.AwaitTask + |> Async.RunSynchronously + + if String.IsNullOrEmpty content then + return ex.Reraise() + else + return content + | 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()) + + | "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" + resolvedPath + else + // Development mode: allow HTTP + validateSchemaUrl ignoreSsrfProtection uri + + 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, resolvedPath) + + for name, value in headers do + request.Headers.TryAddWithoutValidation(name, value) |> ignore + + use handler = new HttpClientHandler(UseDefaultCredentials = false) + use client = new HttpClient(handler, Timeout = TimeSpan.FromSeconds 60.0) + + let! res = + async { + let! response = client.SendAsync(request) |> Async.AwaitTask + + // Validate Content-Type to ensure we're parsing the correct format + validateContentType ignoreSsrfProtection response.Content.Headers.ContentType + + 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()) + + | _ -> + // SECURITY: Reject unknown URL schemes to prevent SSRF attacks via file://, ftp://, etc. + return + failwithf + "Unsupported URL scheme in schema path: '%s'. Only HTTPS and HTTP schemes are supported for remote schemas. If using a local file, ensure the path is absolute or relative to the resolution folder." + resolvedPath } type UniqueNameGenerator() = diff --git a/tests/SwaggerProvider.Tests/SsrfSecurityTests.fs b/tests/SwaggerProvider.Tests/SsrfSecurityTests.fs index fd57a7c..34be244 100644 --- a/tests/SwaggerProvider.Tests/SsrfSecurityTests.fs +++ b/tests/SwaggerProvider.Tests/SsrfSecurityTests.fs @@ -17,7 +17,7 @@ module UnknownSchemeTests = let! ex = Assert.ThrowsAsync(fun () -> task { - let! _ = readSchemaPath false "" fileUrl + let! _ = readSchemaPath false "" "" fileUrl return () }) @@ -35,7 +35,7 @@ module UnknownSchemeTests = let! ex = Assert.ThrowsAsync(fun () -> task { - let! _ = readSchemaPath false "" ftp_url + let! _ = readSchemaPath false "" "" ftp_url return () }) @@ -51,7 +51,7 @@ module UnknownSchemeTests = let! ex = Assert.ThrowsAsync(fun () -> task { - let! _ = readSchemaPath false "" gopher_url + let! _ = readSchemaPath false "" "" gopher_url return () }) @@ -67,7 +67,7 @@ module UnknownSchemeTests = let! ex = Assert.ThrowsAsync(fun () -> task { - let! _ = readSchemaPath false "" dict_url + let! _ = readSchemaPath false "" "" dict_url return () }) @@ -275,7 +275,7 @@ module RelativeFilePathTests = let schemaPath = __SOURCE_DIRECTORY__ + "/../Schemas/v2/petstore.json" try - let! _ = readSchemaPath false "" schemaPath + let! _ = readSchemaPath false "" "" schemaPath () // If file exists, that's fine with | :? Swagger.OpenApiException -> From daef0fa6004de97c51e93cdd32ef807761cae74a Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 2 Nov 2025 14:33:39 +0100 Subject: [PATCH 10/10] Apply suggestion from @Copilot 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 d55a0cb..b375a58 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -277,7 +277,7 @@ module SchemaReader = // SECURITY: Reject unknown URL schemes to prevent SSRF attacks via file://, ftp://, etc. return failwithf - "Unsupported URL scheme in schema path: '%s'. Only HTTPS and HTTP schemes are supported for remote schemas. If using a local file, ensure the path is absolute or relative to the resolution folder." + "Unsupported URL scheme in schema path: '%s'. Only HTTPS is supported for remote schemas (HTTP requires SsrfProtection=false). For local files, ensure the path is absolute or relative to the resolution folder." resolvedPath }