Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
136 changes: 126 additions & 10 deletions src/SwaggerProvider.DesignTime/Utils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,100 @@
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)

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
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
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTTP response path includes Content-Type validation (lines 97-111), but the same validation is missing from the HTTPS response path when ignoreSsrfProtection is true (lines 140-184). This inconsistency could allow invalid content types in development mode. Consider extracting this validation into a reusable function and applying it consistently across both paths.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to unify validation? Otherwise in may work in development mode and then fail when you switch to https because of inconsistent validation.


return! response.Content.ReadAsStringAsync() |> Async.AwaitTask
}
|> Async.Catch
Expand Down Expand Up @@ -66,8 +137,53 @@
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)

Check warning on line 186 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