-
Notifications
You must be signed in to change notification settings - Fork 59
Expand file tree
/
Copy pathUtils.fs
More file actions
353 lines (300 loc) · 17.8 KB
/
Utils.fs
File metadata and controls
353 lines (300 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
namespace SwaggerProvider.Internal
module SchemaReader =
open System
open System.IO
open System.Net
open System.Net.Http
open System.Runtime.InteropServices
/// Checks if a path starts with relative markers like ../ or ./
let private startsWithRelativeMarker(path: string) =
let normalized = path.Replace('\\', '/')
normalized.StartsWith("/../") || normalized.StartsWith("/./")
/// Determines if a path is truly absolute (not just rooted)
/// On Windows: C:\path is absolute, \path is rooted (combine with drive), but \..\path is relative
/// On Unix: /path is absolute, but /../path or /./path are relative
let private isTrulyAbsolute(path: string) =
if not(Path.IsPathRooted path) then
false
else
let root = Path.GetPathRoot path
if String.IsNullOrEmpty root then
false
// On Windows, a truly absolute path has a volume (C:\, D:\, etc.)
// Paths like \path or /path are rooted but may be relative if they start with .. or .
else if RuntimeInformation.IsOSPlatform(OSPlatform.Windows) then
if root.Contains(":") then
// Has drive letter, truly absolute
true
else
// Rooted but no drive - check if it starts with relative markers
// \..\ or /../ are relative, not absolute
not(startsWithRelativeMarker path)
else
// On Unix, a rooted path is absolute if it starts with /
// BUT: if the path starts with /../ or /./, it's relative
root = "/" && not(startsWithRelativeMarker path)
let getAbsolutePath (resolutionFolder: string) (schemaPathRaw: string) =
if String.IsNullOrWhiteSpace(schemaPathRaw) then
invalidArg "schemaPathRaw" "The schema path cannot be null or empty."
let uri = Uri(schemaPathRaw, UriKind.RelativeOrAbsolute)
if uri.IsAbsoluteUri then
schemaPathRaw
elif isTrulyAbsolute schemaPathRaw then
// Truly absolute path (e.g., C:\path on Windows, /path on Unix)
// On Windows, if path is like \path without drive, combine with drive from resolutionFolder
if
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
&& not(Path.GetPathRoot(schemaPathRaw).Contains(":"))
then
Path.Combine(Path.GetPathRoot resolutionFolder, schemaPathRaw.Substring 1)
else
schemaPathRaw
else
Path.Combine(resolutionFolder, schemaPathRaw)
/// 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 = IPAddress.TryParse host
if isIp then
// Check address family first to apply family-specific rules
match ipAddr.AddressFamily with
| Sockets.AddressFamily.InterNetwork ->
// IPv4 validation
let bytes = ipAddr.GetAddressBytes()
// Check for IPv4 loopback or unspecified address
if 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
// Check for IPv4 private ranges
let isPrivateIPv4 =
match bytes with
// 10.0.0.0/8
| [| 10uy; _; _; _ |] -> true
// 172.16.0.0/12
| [| 172uy; secondByte; _; _ |] when secondByte >= 16uy && secondByte <= 31uy -> true
// 192.168.0.0/16
| [| 192uy; 168uy; _; _ |] -> true
// Link-local 169.254.0.0/16
| [| 169uy; 254uy; _; _ |] -> true
| _ -> false
if isPrivateIPv4 then
failwithf "Cannot fetch schemas from private or link-local IP addresses: %s (set SsrfProtection=false for development)" host
| Sockets.AddressFamily.InterNetworkV6 ->
// IPv6 validation
let bytes = ipAddr.GetAddressBytes()
// Check for IPv6 private or reserved ranges
let isPrivateIPv6 =
match bytes with
// Loopback (::1)
| [| 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 1uy |] -> true
// Unspecified address (::)
| [| 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy; 0uy |] -> true
// Link-local (fe80::/10) - first byte 0xFE, second byte 0x80-0xBF
| [| 0xFEuy; secondByte; _; _; _; _; _; _; _; _; _; _; _; _; _; _ |] when secondByte >= 0x80uy && secondByte <= 0xBFuy -> true
// Unique Local Unicast (fc00::/7) - first byte 0xFC or 0xFD
| [| 0xFCuy; _; _; _; _; _; _; _; _; _; _; _; _; _; _; _ |] -> true
| [| 0xFDuy; _; _; _; _; _; _; _; _; _; _; _; _; _; _; _ |] -> true
// Multicast (ff00::/8) - first byte 0xFF
| [| 0xFFuy; _; _; _; _; _; _; _; _; _; _; _; _; _; _; _ |] -> true
| _ -> false
if isPrivateIPv6 then
failwithf "Cannot fetch schemas from private or loopback IPv6 addresses: %s (set SsrfProtection=false for development)" host
| _ ->
// Unsupported address family
failwithf "Cannot fetch schemas from unsupported IP address type: %s (set SsrfProtection=false for development)" host
// Block localhost by hostname
else if host = "localhost" then
failwithf "Cannot fetch schemas from localhost/loopback addresses: %s (set SsrfProtection=false for development)" host
let validateContentType (ignoreSsrfProtection: bool) (contentType: Headers.MediaTypeHeaderValue) =
// Skip validation if SSRF protection is disabled
if ignoreSsrfProtection || isNull contentType then
()
else
let mediaType = contentType.MediaType.ToLowerInvariant()
// Allow only Content-Types that are valid for OpenAPI/Swagger schema files
// This prevents SSRF attacks where an attacker tries to make the provider
// fetch and process non-schema files (HTML, images, binaries, etc.)
let isValidSchemaContentType =
// JSON formats
mediaType = "application/json"
|| mediaType.StartsWith "application/json;"
// YAML formats
|| mediaType = "application/yaml"
|| mediaType = "application/x-yaml"
|| mediaType = "text/yaml"
|| mediaType = "text/x-yaml"
|| mediaType.StartsWith "application/yaml;"
|| mediaType.StartsWith "application/x-yaml;"
|| mediaType.StartsWith "text/yaml;"
|| mediaType.StartsWith "text/x-yaml;"
// Plain text (sometimes used for YAML)
|| mediaType = "text/plain"
|| mediaType.StartsWith "text/plain;"
// Generic binary (fallback for misconfigured servers)
|| mediaType = "application/octet-stream"
|| mediaType.StartsWith "application/octet-stream;"
if not isValidSchemaContentType then
failwithf
"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
let readSchemaPath (ignoreSsrfProtection: bool) (headersStr: string) (resolutionFolder: string) (schemaPathRaw: string) =
async {
// Resolve the schema path to absolute path first
let resolvedPath = getAbsolutePath resolutionFolder schemaPathRaw
// Check if this is a local file path (not a remote URL)
// First try to treat it as a local file path (absolute or relative)
let possibleFilePath =
try
if Path.IsPathRooted resolvedPath then
// Already a rooted path - normalize it to handle .. and . components
// This is important on Windows where paths like D:\foo\..\bar need normalization
let normalized = Path.GetFullPath resolvedPath
if File.Exists normalized then Some normalized else None
else
// Try to resolve relative paths (e.g., paths with ../ or from __SOURCE_DIRECTORY__)
let resolved = Path.GetFullPath resolvedPath
if File.Exists resolved then Some resolved else None
with _ ->
None
match possibleFilePath with
| Some filePath ->
// Handle local file - read from disk
try
return File.ReadAllText filePath
with
| :? FileNotFoundException -> return failwithf "Schema file not found: %s" filePath
| ex -> return failwithf "Error reading schema file '%s': %s" filePath ex.Message
| None ->
// Handle as remote URL (HTTP/HTTPS)
// First check if this looks like a local file path (Windows or Unix)
// On Windows, paths like D:\path are parsed as URIs with scheme "D", so we need special handling
let looksLikeWindowsPath =
resolvedPath.Length >= 2
&& Char.IsLetter(resolvedPath.[0])
&& resolvedPath.[1] = ':'
let looksLikeUnixAbsolutePath = resolvedPath.StartsWith("/")
// If it looks like a local file path, treat it as such (file not found)
if
looksLikeWindowsPath
|| looksLikeUnixAbsolutePath
|| not(resolvedPath.Contains("://"))
then
// If we reach here with a local file that wasn't found, report the error
return failwithf "Schema file not found: %s" resolvedPath
else
// Handle remote URL (HTTP/HTTPS)
let uri = Uri resolvedPath
match uri.Scheme with
| "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())
| "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"
resolvedPath
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())
| _ ->
// SECURITY: Reject unknown URL schemes to prevent SSRF attacks via file://, ftp://, etc.
return
failwithf
"Unsupported URL scheme in schema path: '%s'. Only HTTPS is supported for remote schemas (HTTP requires SsrfProtection=false). For local files, ensure the path is absolute or relative to the resolution folder."
resolvedPath
}
type UniqueNameGenerator(?occupiedNames: string seq) =
let hash = System.Collections.Generic.HashSet<_>()
do
for name in (defaultArg occupiedNames Seq.empty) do
hash.Add(name.ToLowerInvariant()) |> ignore
let rec findUniq prefix i =
let newName = sprintf "%s%s" prefix (if i = 0 then "" else i.ToString())
let key = newName.ToLowerInvariant()
match hash.Contains key with
| false ->
hash.Add key |> ignore
newName
| true -> findUniq prefix (i + 1)
member _.MakeUnique methodName =
findUniq methodName 0