Skip to content

Commit fe7707f

Browse files
Thorium64J0
andauthored
Some security fixes for Giraffe (#691)
* Some security fixes for Giraffe: - URL validation in redirectTo to prevent XSS - CSRF validation helpers - Prevent XXE on XML deserialization * Unit tests added * Test to fix CI * Redirect http handler updates: - Add the old 'redirectTo' http handler back - Update the new 'redirectTo' to 'safeRedirectTo' and the 'redirectToExt' to 'safeRedirectToExt' - Add new documentation section about the safe redirection * Docs: Add new section for the CSRF helpers * Doc: Add small text explaining about Giraffe's secure XML parsing * Fix: fantomas * XML comment of redirectTo updated XML comment of redirectTo updated about a mention of safeRedirectTo * Add Obsolete attribute to redirectTo --------- Co-authored-by: 64J0 <[email protected]>
1 parent c00ace4 commit fe7707f

File tree

7 files changed

+922
-3
lines changed

7 files changed

+922
-3
lines changed

DOCUMENTATION.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ An in depth functional reference to all of Giraffe's default features.
3636
- [Content Negotiation](#content-negotiation)
3737
- [Streaming](#streaming)
3838
- [Redirection](#redirection)
39+
- [Safe Redirection](#safe-redirection)
3940
- [Response Caching](#response-caching)
4041
- [Response Compression](#response-compression)
4142
- [Giraffe View Engine](#giraffe-view-engine)
@@ -47,6 +48,7 @@ An in depth functional reference to all of Giraffe's default features.
4748
- [Short GUIDs and Short IDs](#short-guids-and-short-ids)
4849
- [Common Helper Functions](#common-helper-functions)
4950
- [Computation Expressions](#computation-expressions)
51+
- [CSRF Protection Helpers](#csrf-protection-helpers)
5052
- [Additional Features](#additional-features)
5153
- [Endpoint Routing](#endpoint-routing)
5254
- [TokenRouter](#tokenrouter)
@@ -2892,6 +2894,14 @@ let webApp =
28922894

28932895
Please note that if the `permanent` flag is set to `true` then the Giraffe web application will send a `301` HTTP status code to browsers which will tell them that the redirection is permanent. This often leads to browsers cache the information and not hit the deprecated URL a second time any more. If this is not desired then please set `permanent` to `false` in order to guarantee that browsers will continue hitting the old URL before redirecting to the (temporary) new one.
28942896

2897+
#### Safe Redirection
2898+
2899+
The `redirectTo` http handler, although giving you more freedom when specifying the redirection logic, does not validate for a common security problem named [open redirect](https://learn.snyk.io/lesson/open-redirect).
2900+
2901+
In order to deal with this threat you can either implement your own logic (example from Microsoft docs [Prevent open redirect attacks in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/security/preventing-open-redirects)), or you can leverage the `safeRedirectTo (permanent: bool) (location: string)` http handler, which provides a handler with the necessary validation and a default error handler.
2902+
2903+
Furthermore, if you want to use Giraffe's own open redirect validation, although with a different error handler, you can use the `safeRedirectToExt (permanent: bool) (location: string) (invalidRedirectHandler: HttpHandler option)` http handler, which as the signature suggests, accepts a custom `invalidRedirectHandler` that will be executed if the validation fails.
2904+
28952905
### Response Caching
28962906

28972907
ASP.NET Core comes with a standard [Response Caching Middleware](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/middleware?view=aspnetcore-2.1) which works out of the box with Giraffe.
@@ -3221,6 +3231,8 @@ By default Giraffe uses the `System.Xml.Serialization.XmlSerializer` for (de-)se
32213231

32223232
Customizing Giraffe's XML serialization can either happen via providing a custom object of `XmlWriterSettings` when instantiating the default `SystemXml.Serializer` or swap in an entire different XML library by creating a new class which implements the `Xml.ISerializer` interface.
32233233

3234+
Notice that Giraffe does secure XML parsing, i.e., when using the `Deserialize<'T>(xml: string)` method, both DTD (Document Type Definition) processing and external entities are disabled to prevent [XXE attacks](https://learn.snyk.io/lesson/xxe).
3235+
32243236
#### Customizing XmlWriterSettings
32253237

32263238
You can change the default `XmlWriterSettings` of the `SystemXml.Serializer` by registering a new instance of `SystemXml.Serializer` during application startup:
@@ -3489,6 +3501,24 @@ let someHttpHandler : HttpHandler =
34893501
| Error msg -> RequestErrors.BAD_REQUEST msg next ctx
34903502
```
34913503

3504+
### CSRF Protection Helpers
3505+
3506+
CSRF stands for Cross-Site Request Forgery, and according to the OWASP website can be defined as:
3507+
3508+
> Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. With a little help of social engineering (such as sending a link via email or chat), an attacker may trick the users of a web application into executing actions of the attacker’s choosing. If the victim is a normal user, a successful CSRF attack can force the user to perform state changing requests like transferring funds, changing their email address, and so forth. If the victim is an administrative account, CSRF can compromise the entire web application.
3509+
>
3510+
> -- Reference [link](https://owasp.org/www-community/attacks/csrf).
3511+
3512+
The ASP.NET documentation gives us a tutorial on how to deal with it ([link](https://learn.microsoft.com/en-us/aspnet/core/security/anti-request-forgery)), but you can also leverage the Giraffe's `HttpHandler` helpers from the `Csrf` module:
3513+
3514+
- `validateCsrfTokenExt (invalidTokenHandler: HttpHandler option)`: Validates the CSRF token from the request. Checks for token in header (`X-CSRF-TOKEN`) or form field (`__RequestVerificationToken`).
3515+
- `requireAntiforgeryTokenExt`: Alias for `validateCsrfTokenExt` - validates anti-forgery tokens from requests with custom error handler.
3516+
- `validateCsrfToken`: Validates the CSRF token from the request with default error handling. Checks for token in header (`X-CSRF-TOKEN`) or form field (`__RequestVerificationToken`). Uses default error handling (403 Forbidden) for invalid tokens.
3517+
- `requireAntiforgeryToken`: Alias for `validateCsrfToken` - validates anti-forgery tokens from requests.
3518+
- `generateCsrfToken`: Generates a CSRF token and adds it to the `HttpContext` items for use in views. The token can be accessed via `ctx.Items["CsrfToken"]` and `ctx.Items["CsrfTokenHeaderName"]`.
3519+
- `csrfTokenJson`: Returns the CSRF token as JSON for AJAX requests. Response format: `{ "token": "...", "headerName": "X-CSRF-TOKEN" }`.
3520+
- `csrfTokenHtml`: Returns the CSRF token as an HTML hidden input field. Can be included directly in forms.
3521+
34923522
## Additional Features
34933523

34943524
There's more features available for Giraffe web applications through additional NuGet packages:

src/Giraffe/Core.fs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ namespace Giraffe
22

33
[<AutoOpen>]
44
module Core =
5+
open System
56
open System.Text
67
open System.Threading.Tasks
78
open System.Globalization
@@ -242,16 +243,83 @@ module Core =
242243
| true -> next ctx
243244
| false -> skipPipeline
244245

246+
/// <summary>
247+
/// Validates if a redirect URL is safe (prevents open redirect vulnerabilities).
248+
/// Allows only relative URLs or URLs with the same host.
249+
/// </summary>
250+
/// <param name="ctx">The HttpContext to get the request host from.</param>
251+
/// <param name="url">The URL to validate.</param>
252+
/// <returns>True if the URL is safe to redirect to, false otherwise.</returns>
253+
let isValidRedirectUrl (ctx: HttpContext) (url: string) =
254+
if String.IsNullOrWhiteSpace url then
255+
false
256+
elif url.StartsWith '/' then
257+
true // Relative URL
258+
elif url.StartsWith "~/" then
259+
true // App-relative URL
260+
else
261+
match Uri.TryCreate(url, UriKind.Absolute) with
262+
| true, uri ->
263+
// Only allow redirects to the same host
264+
let requestHost = ctx.Request.Host
265+
uri.Host = requestHost.Host
266+
| false, _ -> false
267+
268+
/// <summary>
269+
/// Redirects to a different location with a `302` or `301` (when permanent) HTTP status code.
270+
/// Validates the redirect URL to prevent open redirect vulnerabilities.
271+
/// </summary>
272+
/// <param name="permanent">If true the redirect is permanent (301), otherwise temporary (302).</param>
273+
/// <param name="location">The URL to redirect the client to.</param>
274+
/// <param name="invalidRedirectHandler">Optional custom handler for invalid redirects. If None, returns 400 Bad Request with logged warning.</param>
275+
/// <param name="next"></param>
276+
/// <param name="ctx"></param>
277+
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
278+
let safeRedirectToExt
279+
(permanent: bool)
280+
(location: string)
281+
(invalidRedirectHandler: HttpHandler option)
282+
: HttpHandler =
283+
fun (_next: HttpFunc) (ctx: HttpContext) ->
284+
if isValidRedirectUrl ctx location then
285+
ctx.Response.Redirect(location, permanent)
286+
Task.FromResult(Some ctx)
287+
else
288+
let defaultHandler =
289+
fun (next: HttpFunc) (ctx: HttpContext) ->
290+
let logger = ctx.GetLogger("Giraffe.Core")
291+
logger.LogWarning("Blocked potential open redirect to: {Location}", location)
292+
ctx.Response.StatusCode <- 400
293+
Task.FromResult(Some ctx)
294+
295+
let handler = invalidRedirectHandler |> Option.defaultValue defaultHandler
296+
handler earlyReturn ctx
297+
298+
/// <summary>
299+
/// Redirects to a different location with a `302` or `301` (when permanent) HTTP status code.
300+
/// Validates the redirect URL to prevent **open redirect** vulnerabilities.
301+
/// Uses default error handling (400 Bad Request) for invalid redirects.
302+
/// </summary>
303+
/// <param name="permanent">If true the redirect is permanent (301), otherwise temporary (302).</param>
304+
/// <param name="location">The URL to redirect the client to.</param>
305+
/// <param name="next"></param>
306+
/// <param name="ctx"></param>
307+
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
308+
let safeRedirectTo (permanent: bool) (location: string) : HttpHandler =
309+
safeRedirectToExt permanent location None
310+
245311
/// <summary>
246312
/// Redirects to a different location with a `302` or `301` (when permanent) HTTP status code.
313+
/// Does not validate redirection. Consider alternative: safeRedirectTo
247314
/// </summary>
248315
/// <param name="permanent">If true the redirect is permanent (301), otherwise temporary (302).</param>
249316
/// <param name="location">The URL to redirect the client to.</param>
250317
/// <param name="next"></param>
251318
/// <param name="ctx"></param>
252319
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
320+
[<Obsolete("Use safeRedirectTo to prevent open redirect vulnerabilities.")>]
253321
let redirectTo (permanent: bool) (location: string) : HttpHandler =
254-
fun (next: HttpFunc) (ctx: HttpContext) ->
322+
fun (_next: HttpFunc) (ctx: HttpContext) ->
255323
ctx.Response.Redirect(location, permanent)
256324
Task.FromResult(Some ctx)
257325

src/Giraffe/Csrf.fs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
namespace Giraffe
2+
3+
/// <summary>
4+
/// CSRF (Cross-Site Request Forgery) protection helpers for Giraffe.
5+
/// Provides anti-forgery token generation and validation.
6+
/// </summary>
7+
[<RequireQualifiedAccess>]
8+
module Csrf =
9+
open System
10+
open System.Security.Cryptography
11+
open System.Text
12+
open System.Threading.Tasks
13+
open Microsoft.AspNetCore.Http
14+
open Microsoft.Extensions.Logging
15+
open Microsoft.AspNetCore.Antiforgery
16+
17+
// Defaults are selected to what developers would expect from ASP.NET Core application.
18+
19+
/// <summary>
20+
/// Default CSRF token header name
21+
/// </summary>
22+
[<Literal>]
23+
let DefaultCsrfTokenHeaderName = "X-CSRF-TOKEN"
24+
25+
/// <summary>
26+
/// Default CSRF token form field name
27+
/// </summary>
28+
[<Literal>]
29+
let DefaultCsrfTokenFormFieldName = "__RequestVerificationToken"
30+
31+
/// <summary>
32+
/// Validates the CSRF token from the request.
33+
/// Checks for token in header (X-CSRF-TOKEN) or form field (__RequestVerificationToken).
34+
/// </summary>
35+
/// <param name="invalidTokenHandler">Optional custom handler for invalid tokens. If None, returns 403 Forbidden with logged warning.</param>
36+
/// <param name="next">The next HttpFunc</param>
37+
/// <param name="ctx">The HttpContext</param>
38+
/// <returns>HttpFuncResult</returns>
39+
let validateCsrfTokenExt (invalidTokenHandler: HttpHandler option) : HttpHandler =
40+
fun (next: HttpFunc) (ctx: HttpContext) ->
41+
task {
42+
let antiforgery = ctx.GetService<IAntiforgery>()
43+
44+
try
45+
let! isValid = antiforgery.IsRequestValidAsync ctx
46+
47+
if isValid then
48+
return! next ctx
49+
else
50+
let defaultHandler =
51+
fun (next: HttpFunc) (ctx: HttpContext) ->
52+
let logger = ctx.GetLogger("Giraffe.Csrf")
53+
54+
logger.LogWarning(
55+
"CSRF token validation failed for request to {Path}",
56+
ctx.Request.Path
57+
)
58+
59+
ctx.Response.StatusCode <- 403
60+
Task.FromResult(Some ctx)
61+
62+
let handler = invalidTokenHandler |> Option.defaultValue defaultHandler
63+
return! handler earlyReturn ctx
64+
with ex ->
65+
let defaultHandler =
66+
fun (next: HttpFunc) (ctx: HttpContext) ->
67+
let logger = ctx.GetLogger("Giraffe.Csrf")
68+
logger.LogWarning(ex, "CSRF token validation error for request to {Path}", ctx.Request.Path)
69+
ctx.Response.StatusCode <- 403
70+
Task.FromResult(Some ctx)
71+
72+
let handler = invalidTokenHandler |> Option.defaultValue defaultHandler
73+
return! handler earlyReturn ctx
74+
}
75+
76+
/// <summary>
77+
/// Validates the CSRF token from the request with default error handling.
78+
/// Checks for token in header (X-CSRF-TOKEN) or form field (__RequestVerificationToken).
79+
/// Uses default error handling (403 Forbidden) for invalid tokens.
80+
/// </summary>
81+
/// <param name="next">The next HttpFunc</param>
82+
/// <param name="ctx">The HttpContext</param>
83+
/// <returns>HttpFuncResult</returns>
84+
let validateCsrfToken: HttpHandler = validateCsrfTokenExt None
85+
86+
/// <summary>
87+
/// Alias for validateCsrfToken - validates anti-forgery tokens from requests.
88+
/// </summary>
89+
let requireAntiforgeryToken = validateCsrfToken
90+
91+
/// <summary>
92+
/// Alias for validateCsrfTokenExt - validates anti-forgery tokens from requests with custom error handler.
93+
/// </summary>
94+
let requireAntiforgeryTokenExt = validateCsrfTokenExt
95+
96+
/// <summary>
97+
/// Generates a CSRF token and adds it to the HttpContext items for use in views.
98+
/// The token can be accessed via ctx.Items["CsrfToken"] and ctx.Items["CsrfTokenHeaderName"].
99+
/// </summary>
100+
/// <param name="next">The next HttpFunc</param>
101+
/// <param name="ctx">The HttpContext</param>
102+
/// <returns>HttpFuncResult</returns>
103+
let generateCsrfToken: HttpHandler =
104+
fun (next: HttpFunc) (ctx: HttpContext) ->
105+
task {
106+
let antiforgery = ctx.GetService<IAntiforgery>()
107+
let tokens = antiforgery.GetAndStoreTokens ctx
108+
109+
// Store token for view rendering
110+
ctx.Items.["CsrfToken"] <- tokens.RequestToken
111+
ctx.Items.["CsrfTokenHeaderName"] <- tokens.HeaderName
112+
113+
return! next ctx
114+
}
115+
116+
/// <summary>
117+
/// Returns the CSRF token as JSON for AJAX requests.
118+
/// Response format: { "token": "...", "headerName": "X-CSRF-TOKEN" }
119+
/// </summary>
120+
/// <param name="next">The next HttpFunc</param>
121+
/// <param name="ctx">The HttpContext</param>
122+
/// <returns>HttpFuncResult</returns>
123+
let csrfTokenJson: HttpHandler =
124+
fun (next: HttpFunc) (ctx: HttpContext) ->
125+
task {
126+
let antiforgery = ctx.GetService<IAntiforgery>()
127+
let tokens = antiforgery.GetAndStoreTokens ctx
128+
129+
let response =
130+
{|
131+
token = tokens.RequestToken
132+
headerName = tokens.HeaderName
133+
|}
134+
135+
return! Core.json response next ctx
136+
}
137+
138+
/// <summary>
139+
/// Returns the CSRF token as an HTML hidden input field.
140+
/// Can be included directly in forms.
141+
/// </summary>
142+
/// <param name="next">The next HttpFunc</param>
143+
/// <param name="ctx">The HttpContext</param>
144+
/// <returns>HttpFuncResult</returns>
145+
let csrfTokenHtml: HttpHandler =
146+
fun (next: HttpFunc) (ctx: HttpContext) ->
147+
task {
148+
let antiforgery = ctx.GetService<IAntiforgery>()
149+
let tokens = antiforgery.GetAndStoreTokens(ctx)
150+
151+
let html =
152+
sprintf "<input type=\"hidden\" name=\"%s\" value=\"%s\" />" tokens.HeaderName tokens.RequestToken
153+
154+
return! Core.htmlString html next ctx
155+
}

src/Giraffe/Giraffe.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
<Compile Include="ModelParser.fs" />
8383
<Compile Include="HttpContextExtensions.fs" />
8484
<Compile Include="Core.fs" />
85+
<Compile Include="Csrf.fs" />
8586
<Compile Include="ResponseCaching.fs" />
8687
<Compile Include="ModelValidation.fs" />
8788
<Compile Include="Auth.fs" />

src/Giraffe/Xml.fs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,14 @@ module SystemXml =
4444

4545
member __.Deserialize<'T>(xml: string) =
4646
let serializer = XmlSerializer(typeof<'T>)
47-
use reader = new StringReader(xml)
48-
serializer.Deserialize reader :?> 'T
47+
use stringReader = new StringReader(xml)
48+
// Secure XML parsing: disable DTD processing and external entities to prevent XXE attacks
49+
let xmlReaderSettings =
50+
new XmlReaderSettings(
51+
DtdProcessing = DtdProcessing.Prohibit,
52+
XmlResolver = null,
53+
MaxCharactersFromEntities = 1024L * 1024L
54+
) // 1MB limit
55+
56+
use xmlReader = XmlReader.Create(stringReader, xmlReaderSettings)
57+
serializer.Deserialize xmlReader :?> 'T

tests/Giraffe.Tests/Giraffe.Tests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<Compile Include="PreconditionalTests.fs" />
2323
<Compile Include="JsonTests.fs" />
2424
<Compile Include="XmlTests.fs" />
25+
<Compile Include="SecurityTests.fs" />
2526
</ItemGroup>
2627

2728
<ItemGroup>

0 commit comments

Comments
 (0)