Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 17 additions & 0 deletions docs/OpenApiClientProvider.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,26 @@ let client = PetStore.Client()
| `IgnoreControllerPrefix` | Do not parse `operationsId` as `<controllerName>_<methodName>` 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).
Expand Down
17 changes: 17 additions & 0 deletions docs/SwaggerClientProvider.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,26 @@ When you use TP you can specify the following parameters
| `IgnoreControllerPrefix` | Do not parse `operationsId` as `<controllerName>_<methodName>` 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)
Expand Down
14 changes: 10 additions & 4 deletions src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,17 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this =
ProvidedStaticParameter("IgnoreOperationId", typeof<bool>, false)
ProvidedStaticParameter("IgnoreControllerPrefix", typeof<bool>, true)
ProvidedStaticParameter("PreferNullable", typeof<bool>, false)
ProvidedStaticParameter("PreferAsync", typeof<bool>, false) ]
ProvidedStaticParameter("PreferAsync", typeof<bool>, false)
ProvidedStaticParameter("SsrfProtection", typeof<bool>, true) ]

t.AddXmlDoc
"""<summary>Statically typed OpenAPI provider.</summary>
<param name='Schema'>Url or Path to OpenAPI schema file.</param>
<param name='IgnoreOperationId'>Do not use `operationsId` and generate method names using `path` only. Default value `false`.</param>
<param name='IgnoreControllerPrefix'>Do not parse `operationsId` as `<controllerName>_<methodName>` and generate one client class for all operations. Default value `true`.</param>
<param name='PreferNullable'>Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`.</param>
<param name='PreferAsync'>Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`.</param>"""
<param name='PreferAsync'>Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`.</param>
<param name='SsrfProtection'>Enable SSRF protection (blocks HTTP and localhost). Set to false for development/testing. Default value `true`.</param>"""

t.DefineStaticParameters(
staticParams,
Expand All @@ -57,15 +59,19 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this =
let ignoreControllerPrefix = unbox<bool> args.[2]
let preferNullable = unbox<bool> args.[3]
let preferAsync = unbox<bool> args.[4]
let ssrfProtection = unbox<bool> 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)
Expand Down
11 changes: 7 additions & 4 deletions src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this =
ProvidedStaticParameter("IgnoreOperationId", typeof<bool>, false)
ProvidedStaticParameter("IgnoreControllerPrefix", typeof<bool>, true)
ProvidedStaticParameter("PreferNullable", typeof<bool>, false)
ProvidedStaticParameter("PreferAsync", typeof<bool>, false) ]
ProvidedStaticParameter("PreferAsync", typeof<bool>, false)
ProvidedStaticParameter("SsrfProtection", typeof<bool>, true) ]

t.AddXmlDoc
"""<summary>Statically typed Swagger provider.</summary>
Expand All @@ -44,7 +45,8 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this =
<param name='IgnoreOperationId'>Do not use `operationsId` and generate method names using `path` only. Default value `false`.</param>
<param name='IgnoreControllerPrefix'>Do not parse `operationsId` as `<controllerName>_<methodName>` and generate one client class for all operations. Default value `true`.</param>
<param name='PreferNullable'>Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`.</param>
<param name='PreferAsync'>Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`.</param>"""
<param name='PreferAsync'>Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`.</param>
<param name='SsrfProtection'>Enable SSRF protection (blocks HTTP and localhost). Set to false for development/testing. Default value `true`.</param>"""

t.DefineStaticParameters(
staticParams,
Expand All @@ -58,15 +60,16 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this =
let ignoreControllerPrefix = unbox<bool> args.[3]
let preferNullable = unbox<bool> args.[4]
let preferAsync = unbox<bool> args.[5]
let ssrfProtection = unbox<bool> 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
Expand Down
155 changes: 144 additions & 11 deletions src/SwaggerProvider.DesignTime/Utils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,117 @@
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)

let request = new HttpRequestMessage(HttpMethod.Get, schemaPathRaw)

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
Expand Down Expand Up @@ -66,8 +150,57 @@
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)

Check warning on line 203 in src/SwaggerProvider.DesignTime/Utils.fs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

This construct is deprecated. WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.
use! response = request.GetResponseAsync() |> Async.AwaitTask
use sr = new StreamReader(response.GetResponseStream())
return! sr.ReadToEndAsync() |> Async.AwaitTask
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
module Swashbuckle.v2.ReturnControllersTests
module Swashbuckle.v2.ReturnControllersTests

open FsUnitTyped
open Xunit
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading