From 2667cd792b5cdc64c09099a247075bb99e429f26 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:23:21 +0000 Subject: [PATCH 1/6] refactor: extract fetchUrlContent helper from readSchemaPath to eliminate duplication The HTTPS and HTTP branches of readSchemaPath shared ~50 lines of identical code for building headers, creating the HttpRequestMessage, creating the HttpClient, sending the request, and handling errors. Extract a private fetchUrlContent helper that: - Parses custom headers from the pipe-separated header string - Creates and sends the GET request with UseDefaultCredentials=false - Validates the response Content-Type - Handles all three error cases uniformly (OpenApiException, WebException, other) The HTTP branch now also handles OpenApiException (previously it would bubble up unhandled), which is a minor bug fix alongside the cleanup. readSchemaPath is reduced by ~60 lines with identical behaviour preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/Utils.fs | 146 +++++++++--------------- 1 file changed, 57 insertions(+), 89 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 6922593e..5841ffb6 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -167,6 +167,61 @@ module SchemaReader = "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 + /// Sends a GET request to the given URL with optional custom headers and returns the response body. + /// Validates the Content-Type to prevent processing non-schema responses (unless SSRF protection is off). + let private fetchUrlContent (ignoreSsrfProtection: bool) (headersStr: string) (resolvedPath: string) = + async { + 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, resolvedPath) + + for name, value in headers do + request.Headers.TryAddWithoutValidation(name, value) |> ignore + + // SECURITY: Disable default credentials to prevent credential leakage (always enforced) + use handler = new HttpClientHandler(UseDefaultCredentials = false) + use client = new HttpClient(handler, Timeout = 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 + + return + match res with + | Choice1Of2 x -> x + | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> + let content = + ex.Content.ReadAsStringAsync() + |> Async.AwaitTask + |> Async.RunSynchronously + + if String.IsNullOrEmpty content then + ex.Reraise() + else + content + | Choice2Of2(:? WebException as wex) when not <| isNull wex.Response -> + use stream = wex.Response.GetResponseStream() + use reader = new StreamReader(stream) + let err = reader.ReadToEnd() + + if String.IsNullOrEmpty err then + wex.Reraise() + else + err.ToString() + | Choice2Of2 e -> failwith(e.ToString()) + } + let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (resolutionFolder: string) (schemaPathRaw: string) = async { // Resolve the schema path to absolute path first @@ -223,56 +278,7 @@ module SchemaReader = | "https" -> // Validate URL to prevent SSRF (unless explicitly disabled) 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, resolvedPath) - - for name, value in headers do - request.Headers.TryAddWithoutValidation(name, value) |> ignore - - // SECURITY: Remove UseDefaultCredentials to prevent credential leakage (always enforced) - use handler = new HttpClientHandler(UseDefaultCredentials = false) - use client = new HttpClient(handler, Timeout = 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(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> - let content = - ex.Content.ReadAsStringAsync() - |> Async.AwaitTask - |> Async.RunSynchronously - - if String.IsNullOrEmpty content then - return ex.Reraise() - else - return content - | 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()) + return! fetchUrlContent ignoreSsrfProtection headersStr resolvedPath | "http" -> // HTTP is allowed only when SSRF protection is explicitly disabled (development/testing mode) @@ -284,45 +290,7 @@ module SchemaReader = 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, resolvedPath) - - for name, value in headers do - request.Headers.TryAddWithoutValidation(name, value) |> ignore - - use handler = new HttpClientHandler(UseDefaultCredentials = false) - use client = new HttpClient(handler, Timeout = 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()) + return! fetchUrlContent ignoreSsrfProtection headersStr resolvedPath | _ -> // SECURITY: Reject unknown URL schemes to prevent SSRF attacks via file://, ftp://, etc. From f72da57718de7c4d449154aff415546f2e15ce8b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 25 Apr 2026 13:23:24 +0000 Subject: [PATCH 2/6] ci: trigger checks From f43d9c8be40caab80e1a74ee9ad8bde18e1e0253 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:35:09 +0000 Subject: [PATCH 3/6] fix: address code review feedback in fetchUrlContent helper Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/afc4c3b8-fa37-45a7-9b26-ebaf1e5c5c64 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/Utils.fs | 59 ++++++++++++++----------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 5841ffb6..d9d73e79 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -174,10 +174,14 @@ module SchemaReader = 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 pair = x.Split([| '=' |], 2) - let request = new HttpRequestMessage(HttpMethod.Get, resolvedPath) + if (pair.Length = 2) then + Some(pair[0].Trim(), pair[1].Trim()) + else + None) + + use request = new HttpRequestMessage(HttpMethod.Get, resolvedPath) for name, value in headers do request.Headers.TryAddWithoutValidation(name, value) |> ignore @@ -188,7 +192,7 @@ module SchemaReader = let! res = async { - let! response = client.SendAsync request |> Async.AwaitTask + use! response = client.SendAsync request |> Async.AwaitTask // Validate Content-Type to ensure we're parsing the correct format validateContentType ignoreSsrfProtection response.Content.Headers.ContentType @@ -197,29 +201,30 @@ module SchemaReader = } |> Async.Catch - return - match res with - | Choice1Of2 x -> x - | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> - let content = - ex.Content.ReadAsStringAsync() - |> Async.AwaitTask - |> Async.RunSynchronously - - if String.IsNullOrEmpty content then - ex.Reraise() - else - content - | Choice2Of2(:? WebException as wex) when not <| isNull wex.Response -> - use stream = wex.Response.GetResponseStream() - use reader = new StreamReader(stream) - let err = reader.ReadToEnd() - - if String.IsNullOrEmpty err then - wex.Reraise() - else - err.ToString() - | Choice2Of2 e -> failwith(e.ToString()) + return! + async { + match res with + | Choice1Of2 x -> return x + | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> + let! content = ex.Content.ReadAsStringAsync() |> Async.AwaitTask + + return + if String.IsNullOrEmpty content then + ex.Reraise() + else + content + | 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 e.Reraise() + } } let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (resolutionFolder: string) (schemaPathRaw: string) = From e0959aeac154361a6f154e33c94fa59e98d9dce2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:36:37 +0000 Subject: [PATCH 4/6] fix: remove unnecessary nested async block in fetchUrlContent match expression Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/afc4c3b8-fa37-45a7-9b26-ebaf1e5c5c64 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/Utils.fs | 45 ++++++++++++------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index d9d73e79..4de4b7d6 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -201,30 +201,27 @@ module SchemaReader = } |> Async.Catch - return! - async { - match res with - | Choice1Of2 x -> return x - | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> - let! content = ex.Content.ReadAsStringAsync() |> Async.AwaitTask - - return - if String.IsNullOrEmpty content then - ex.Reraise() - else - content - | 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 e.Reraise() - } + match res with + | Choice1Of2 x -> return x + | Choice2Of2(:? Swagger.OpenApiException as ex) when not <| isNull ex.Content -> + let! content = ex.Content.ReadAsStringAsync() |> Async.AwaitTask + + return + if String.IsNullOrEmpty content then + ex.Reraise() + else + content + | 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 e.Reraise() } let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (resolutionFolder: string) (schemaPathRaw: string) = From 0008135a70a86589c2f27b41bde69fe1806835e0 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sat, 25 Apr 2026 20:58:05 +0200 Subject: [PATCH 5/6] Update src/SwaggerProvider.DesignTime/Utils.fs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/Utils.fs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 4de4b7d6..a0e07a93 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -187,7 +187,8 @@ module SchemaReader = request.Headers.TryAddWithoutValidation(name, value) |> ignore // SECURITY: Disable default credentials to prevent credential leakage (always enforced) - use handler = new HttpClientHandler(UseDefaultCredentials = false) + // SECURITY: Prevent redirect-based SSRF bypasses when SSRF protection is enabled. + use handler = new HttpClientHandler(UseDefaultCredentials = false, AllowAutoRedirect = ignoreSsrfProtection) use client = new HttpClient(handler, Timeout = TimeSpan.FromSeconds 60.0) let! res = From 4ab78205ccebe6d56335239b5f07d719506a21a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:02:26 +0000 Subject: [PATCH 6/6] fix: apply Fantomas formatting after AllowAutoRedirect change Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/129f72d4-4d34-42d1-b945-80161589697c Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/Utils.fs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index a0e07a93..6eeb3435 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -188,7 +188,9 @@ module SchemaReader = // SECURITY: Disable default credentials to prevent credential leakage (always enforced) // SECURITY: Prevent redirect-based SSRF bypasses when SSRF protection is enabled. - use handler = new HttpClientHandler(UseDefaultCredentials = false, AllowAutoRedirect = ignoreSsrfProtection) + use handler = + new HttpClientHandler(UseDefaultCredentials = false, AllowAutoRedirect = ignoreSsrfProtection) + use client = new HttpClient(handler, Timeout = TimeSpan.FromSeconds 60.0) let! res =