-
Notifications
You must be signed in to change notification settings - Fork 59
Server-Side Request Forgery (SSRF) protection #271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
e8c6cb1
3868a01
4223357
f983e4e
a6ece66
190bfc4
b071576
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
Thorium marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
Thorium marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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) | ||
sergey-tihon marked this conversation as resolved.
Show resolved
Hide resolved
sergey-tihon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
|
||
|
|
||
| return! response.Content.ReadAsStringAsync() |> Async.AwaitTask | ||
| } | ||
| |> Async.Catch | ||
|
|
@@ -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 | ||
Thorium marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
|
|
||
sergey-tihon marked this conversation as resolved.
Show resolved
Hide resolved
sergey-tihon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
| use! response = request.GetResponseAsync() |> Async.AwaitTask | ||
| use sr = new StreamReader(response.GetResponseStream()) | ||
| return! sr.ReadToEndAsync() |> Async.AwaitTask | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.