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..ee31712 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -12,19 +12,98 @@ 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) - 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, and private IP ranges using proper IP address parsing + 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 + failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host + + 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() + + // 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.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 { - 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 +111,18 @@ 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 + validateContentType ignoreSsrfProtection response.Content.Headers.ContentType + return! response.Content.ReadAsStringAsync() |> Async.AwaitTask } |> Async.Catch @@ -66,6 +150,55 @@ 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 + + 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 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)