Skip to content

Commit 942d80c

Browse files
ThoriumCopilotsergey-tihon
authored
Server-Side Request Forgery (SSRF) protection (#271)
* Server-Side Request Forgery (SSRF) protection * Update src/SwaggerProvider.DesignTime/Utils.fs Co-authored-by: Copilot <[email protected]> * Update src/SwaggerProvider.DesignTime/Utils.fs Co-authored-by: Copilot <[email protected]> * Copilot feedback implemented, and formatted with Fantomas * Content validation improved * refact: pattern matching * fix: remove duplicated condition --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Sergey Tihon <[email protected]>
1 parent 2d6f5c7 commit 942d80c

File tree

8 files changed

+200
-22
lines changed

8 files changed

+200
-22
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This SwaggerProvider can be used to access RESTful API generated using [Swagger.
66

77
Documentation: http://fsprojects.github.io/SwaggerProvider/
88

9+
**Security:** SSRF protection is enabled by default. For local development, use static parameter `SsrfProtection=false`.
10+
911
## Swagger RESTful API Documentation Specification
1012

1113
Swagger is available for ASP.NET WebAPI APIs with [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle).

docs/OpenApiClientProvider.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,26 @@ let client = PetStore.Client()
2121
| `IgnoreControllerPrefix` | Do not parse `operationsId` as `<controllerName>_<methodName>` and generate one client class for all operations. Default value `true`. |
2222
| `PreferNullable` | Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`. |
2323
| `PreferAsync` | Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`. |
24+
| `SsrfProtection` | Enable SSRF protection (blocks HTTP and localhost). Set to `false` for development/testing. Default value `true`. |
2425

2526
More configuration scenarios are described in [Customization section](/Customization)
2627

28+
## Security (SSRF Protection)
29+
30+
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).
31+
32+
For **development and testing** with local servers, disable SSRF protection:
33+
34+
```fsharp
35+
// Development: Allow HTTP and localhost
36+
type LocalApi = OpenApiClientProvider<"http://localhost:5000/swagger.json", SsrfProtection=false>
37+
38+
// Production: HTTPS with SSRF protection (default)
39+
type ProdApi = OpenApiClientProvider<"https://api.example.com/swagger.json">
40+
```
41+
42+
**Warning:** Never set `SsrfProtection=false` in production code.
43+
2744
## Sample
2845

2946
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).

docs/SwaggerClientProvider.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,26 @@ When you use TP you can specify the following parameters
2828
| `IgnoreControllerPrefix` | Do not parse `operationsId` as `<controllerName>_<methodName>` and generate one client class for all operations. Default value `true`. |
2929
| `PreferNullable` | Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`. |
3030
| `PreferAsync` | Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`. |
31+
| `SsrfProtection` | Enable SSRF protection (blocks HTTP and localhost). Set to `false` for development/testing. Default value `true`. |
3132

3233
More configuration scenarios are described in [Customization section](/Customization)
3334

35+
## Security (SSRF Protection)
36+
37+
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).
38+
39+
For **development and testing** with local servers, disable SSRF protection:
40+
41+
```fsharp
42+
// Development: Allow HTTP and localhost
43+
type LocalApi = SwaggerClientProvider<"http://localhost:5000/swagger.json", SsrfProtection=false>
44+
45+
// Production: HTTPS with SSRF protection (default)
46+
type ProdApi = SwaggerClientProvider<"https://api.example.com/swagger.json">
47+
```
48+
49+
**Warning:** Never set `SsrfProtection=false` in production code.
50+
3451
## Sample
3552

3653
The usage is very similar to [OpenApiClientProvider](/OpenApiClientProvider#sample)

src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,17 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this =
3636
ProvidedStaticParameter("IgnoreOperationId", typeof<bool>, false)
3737
ProvidedStaticParameter("IgnoreControllerPrefix", typeof<bool>, true)
3838
ProvidedStaticParameter("PreferNullable", typeof<bool>, false)
39-
ProvidedStaticParameter("PreferAsync", typeof<bool>, false) ]
39+
ProvidedStaticParameter("PreferAsync", typeof<bool>, false)
40+
ProvidedStaticParameter("SsrfProtection", typeof<bool>, true) ]
4041

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

4951
t.DefineStaticParameters(
5052
staticParams,
@@ -57,15 +59,19 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this =
5759
let ignoreControllerPrefix = unbox<bool> args.[2]
5860
let preferNullable = unbox<bool> args.[3]
5961
let preferAsync = unbox<bool> args.[4]
62+
let ssrfProtection = unbox<bool> args.[5]
6063

6164
let cacheKey =
62-
(schemaPath, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync)
65+
(schemaPath, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync, ssrfProtection)
6366
|> sprintf "%A"
6467

6568

6669
let addCache() =
6770
lazy
68-
let schemaData = SchemaReader.readSchemaPath "" schemaPath |> Async.RunSynchronously
71+
let schemaData =
72+
SchemaReader.readSchemaPath (not ssrfProtection) "" schemaPath
73+
|> Async.RunSynchronously
74+
6975
let openApiReader = Microsoft.OpenApi.Readers.OpenApiStringReader()
7076

7177
let (schema, diagnostic) = openApiReader.Read(schemaData)

src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this =
3535
ProvidedStaticParameter("IgnoreOperationId", typeof<bool>, false)
3636
ProvidedStaticParameter("IgnoreControllerPrefix", typeof<bool>, true)
3737
ProvidedStaticParameter("PreferNullable", typeof<bool>, false)
38-
ProvidedStaticParameter("PreferAsync", typeof<bool>, false) ]
38+
ProvidedStaticParameter("PreferAsync", typeof<bool>, false)
39+
ProvidedStaticParameter("SsrfProtection", typeof<bool>, true) ]
3940

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

4951
t.DefineStaticParameters(
5052
staticParams,
@@ -58,15 +60,16 @@ type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this =
5860
let ignoreControllerPrefix = unbox<bool> args.[3]
5961
let preferNullable = unbox<bool> args.[4]
6062
let preferAsync = unbox<bool> args.[5]
63+
let ssrfProtection = unbox<bool> args.[6]
6164

6265
let cacheKey =
63-
(schemaPath, headersStr, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync)
66+
(schemaPath, headersStr, ignoreOperationId, ignoreControllerPrefix, preferNullable, preferAsync, ssrfProtection)
6467
|> sprintf "%A"
6568

6669
let addCache() =
6770
lazy
6871
let schemaData =
69-
SchemaReader.readSchemaPath headersStr schemaPath
72+
SchemaReader.readSchemaPath (not ssrfProtection) headersStr schemaPath
7073
|> Async.RunSynchronously
7174

7275
let schema = SwaggerParser.parseSchema schemaData

src/SwaggerProvider.DesignTime/Utils.fs

Lines changed: 144 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,33 +12,117 @@ module SchemaReader =
1212
if uri.IsAbsoluteUri then
1313
schemaPathRaw
1414
elif Path.IsPathRooted schemaPathRaw then
15-
Path.Combine(Path.GetPathRoot(resolutionFolder), schemaPathRaw.Substring(1))
15+
Path.Combine(Path.GetPathRoot resolutionFolder, schemaPathRaw.Substring 1)
1616
else
1717
Path.Combine(resolutionFolder, schemaPathRaw)
1818

19-
let readSchemaPath (headersStr: string) (schemaPathRaw: string) =
19+
/// Validates URL to prevent SSRF attacks
20+
/// Pass ignoreSsrfProtection=true to disable validation (for development/testing only)
21+
let validateSchemaUrl (ignoreSsrfProtection: bool) (url: Uri) =
22+
if ignoreSsrfProtection then
23+
() // Skip validation when explicitly disabled
24+
else
25+
// Only allow HTTPS for security (prevent MITM)
26+
if url.Scheme <> "https" then
27+
failwithf "Only HTTPS URLs are allowed for remote schemas. Got: %s (set SsrfProtection=false for development)" url.Scheme
28+
29+
// Prevent access to private IP ranges (SSRF protection)
30+
let host = url.Host.ToLowerInvariant()
31+
32+
// Block localhost and loopback, and private IP ranges using proper IP address parsing
33+
let isIp, ipAddr = IPAddress.TryParse host
34+
35+
if isIp then
36+
// Loopback
37+
if IPAddress.IsLoopback ipAddr || ipAddr.ToString() = "0.0.0.0" then
38+
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host
39+
// Private IPv4 ranges
40+
let bytes = ipAddr.GetAddressBytes()
41+
42+
let isPrivate =
43+
ipAddr.AddressFamily = Sockets.AddressFamily.InterNetwork
44+
&& match bytes with
45+
| [| 10uy; _; _; _ |] -> true // 10.0.0.0/8
46+
| [| 172uy; b1; _; _ |] when b1 >= 16uy && b1 <= 31uy -> true // 172.16.0.0/12
47+
| [| 192uy; 168uy; _; _ |] -> true // 192.168.0.0/16
48+
| [| 169uy; 254uy; _; _ |] -> true // Link-local 169.254.0.0/16
49+
| _ -> false
50+
51+
if isPrivate then
52+
failwithf "Cannot fetch schemas from private or link-local IP addresses: %s (set SsrfProtection=false for development)" host
53+
else if
54+
// Block localhost by name
55+
host = "localhost"
56+
then
57+
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host
58+
59+
let validateContentType (ignoreSsrfProtection: bool) (contentType: Headers.MediaTypeHeaderValue) =
60+
// Skip validation if SSRF protection is disabled
61+
if ignoreSsrfProtection || isNull contentType then
62+
()
63+
else
64+
let mediaType = contentType.MediaType.ToLowerInvariant()
65+
66+
// Allow only Content-Types that are valid for OpenAPI/Swagger schema files
67+
// This prevents SSRF attacks where an attacker tries to make the provider
68+
// fetch and process non-schema files (HTML, images, binaries, etc.)
69+
let isValidSchemaContentType =
70+
// JSON formats
71+
mediaType = "application/json"
72+
|| mediaType.StartsWith "application/json;"
73+
// YAML formats
74+
|| mediaType = "application/yaml"
75+
|| mediaType = "application/x-yaml"
76+
|| mediaType = "text/yaml"
77+
|| mediaType = "text/x-yaml"
78+
|| mediaType.StartsWith "application/yaml;"
79+
|| mediaType.StartsWith "application/x-yaml;"
80+
|| mediaType.StartsWith "text/yaml;"
81+
|| mediaType.StartsWith "text/x-yaml;"
82+
// Plain text (sometimes used for YAML)
83+
|| mediaType = "text/plain"
84+
|| mediaType.StartsWith "text/plain;"
85+
// Generic binary (fallback for misconfigured servers)
86+
|| mediaType = "application/octet-stream"
87+
|| mediaType.StartsWith "application/octet-stream;"
88+
89+
if not isValidSchemaContentType then
90+
failwithf
91+
"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."
92+
mediaType
93+
94+
let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (schemaPathRaw: string) =
2095
async {
21-
match Uri(schemaPathRaw).Scheme with
22-
| "https"
23-
| "http" ->
96+
let uri = Uri schemaPathRaw
97+
98+
match uri.Scheme with
99+
| "https" ->
100+
// Validate URL to prevent SSRF (unless explicitly disabled)
101+
validateSchemaUrl ignoreSsrfProtection uri
102+
24103
let headers =
25-
headersStr.Split('|')
104+
headersStr.Split '|'
26105
|> Seq.choose(fun x ->
27-
let pair = x.Split('=')
106+
let pair = x.Split '='
28107

29108
if (pair.Length = 2) then Some(pair[0], pair[1]) else None)
30109

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

33112
for name, value in headers do
34113
request.Headers.TryAddWithoutValidation(name, value) |> ignore
35-
// using a custom handler means that we can set the default credentials.
36-
use handler = new HttpClientHandler(UseDefaultCredentials = true)
37-
use client = new HttpClient(handler)
114+
115+
// SECURITY: Remove UseDefaultCredentials to prevent credential leakage (always enforced)
116+
use handler = new HttpClientHandler(UseDefaultCredentials = false)
117+
use client = new HttpClient(handler, Timeout = System.TimeSpan.FromSeconds 60.0)
38118

39119
let! res =
40120
async {
41-
let! response = client.SendAsync(request) |> Async.AwaitTask
121+
let! response = client.SendAsync request |> Async.AwaitTask
122+
123+
// Validate Content-Type to ensure we're parsing the correct format
124+
validateContentType ignoreSsrfProtection response.Content.Headers.ContentType
125+
42126
return! response.Content.ReadAsStringAsync() |> Async.AwaitTask
43127
}
44128
|> Async.Catch
@@ -66,6 +150,55 @@ module SchemaReader =
66150
else
67151
err.ToString()
68152
| Choice2Of2 e -> return failwith(e.ToString())
153+
| "http" ->
154+
// HTTP is allowed only when SSRF protection is explicitly disabled (development/testing mode)
155+
if not ignoreSsrfProtection then
156+
return
157+
failwithf
158+
"HTTP URLs are not supported for security reasons. Use HTTPS or set SsrfProtection=false for development: %s"
159+
schemaPathRaw
160+
else
161+
// Development mode: allow HTTP
162+
validateSchemaUrl ignoreSsrfProtection uri
163+
164+
let headers =
165+
headersStr.Split '|'
166+
|> Seq.choose(fun x ->
167+
let pair = x.Split '='
168+
if (pair.Length = 2) then Some(pair[0], pair[1]) else None)
169+
170+
let request = new HttpRequestMessage(HttpMethod.Get, schemaPathRaw)
171+
172+
for name, value in headers do
173+
request.Headers.TryAddWithoutValidation(name, value) |> ignore
174+
175+
use handler = new HttpClientHandler(UseDefaultCredentials = false)
176+
use client = new HttpClient(handler, Timeout = System.TimeSpan.FromSeconds 60.0)
177+
178+
let! res =
179+
async {
180+
let! response = client.SendAsync(request) |> Async.AwaitTask
181+
182+
// Validate Content-Type to ensure we're parsing the correct format
183+
validateContentType ignoreSsrfProtection response.Content.Headers.ContentType
184+
185+
return! response.Content.ReadAsStringAsync() |> Async.AwaitTask
186+
}
187+
|> Async.Catch
188+
189+
match res with
190+
| Choice1Of2 x -> return x
191+
| Choice2Of2(:? WebException as wex) when not <| isNull wex.Response ->
192+
use stream = wex.Response.GetResponseStream()
193+
use reader = new StreamReader(stream)
194+
let err = reader.ReadToEnd()
195+
196+
return
197+
if String.IsNullOrEmpty err then
198+
wex.Reraise()
199+
else
200+
err.ToString()
201+
| Choice2Of2 e -> return failwith(e.ToString())
69202
| _ ->
70203
let request = WebRequest.Create(schemaPathRaw)
71204
use! response = request.GetResponseAsync() |> Async.AwaitTask

tests/SwaggerProvider.ProviderTests/v2/Swashbuckle.ReturnControllers.Tests.fs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
module Swashbuckle.v2.ReturnControllersTests
1+
module Swashbuckle.v2.ReturnControllersTests
22

33
open FsUnitTyped
44
open Xunit
55
open SwaggerProvider
66
open System
77
open System.Net.Http
88

9-
type WebAPI = SwaggerClientProvider<"http://localhost:5000/swagger/v1/swagger.json", IgnoreOperationId=true>
9+
type WebAPI = SwaggerClientProvider<"http://localhost:5000/swagger/v1/swagger.json", IgnoreOperationId=true, SsrfProtection=false>
1010

1111
let api =
1212
let handler = new HttpClientHandler(UseCookies = false)

tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type CallLoggingHandler(messageHandler) =
1313
printfn $"[SendAsync]: %A{request.RequestUri}"
1414
base.SendAsync(request, cancellationToken)
1515

16-
type WebAPI = OpenApiClientProvider<"http://localhost:5000/swagger/v1/openapi.json", IgnoreOperationId=true>
16+
type WebAPI = OpenApiClientProvider<"http://localhost:5000/swagger/v1/openapi.json", IgnoreOperationId=true, SsrfProtection=false>
1717

1818
let api =
1919
let handler = new HttpClientHandler(UseCookies = false)

0 commit comments

Comments
 (0)