Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
144 changes: 134 additions & 10 deletions src/SwaggerProvider.DesignTime/Utils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,104 @@
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 = 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 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(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 {
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 response.Content.Headers.ContentType

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