Skip to content

Commit 841453e

Browse files
committed
Some security fixes for Giraffe:
- URL validation in redirectTo to prevent XSS - CSRF validation helpers - Prevent XXE on XML deserialization
1 parent c00ace4 commit 841453e

4 files changed

Lines changed: 160 additions & 4 deletions

File tree

src/Giraffe/Core.fs

Lines changed: 27 additions & 2 deletions
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
@@ -252,8 +253,32 @@ module Core =
252253
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
253254
let redirectTo (permanent: bool) (location: string) : HttpHandler =
254255
fun (next: HttpFunc) (ctx: HttpContext) ->
255-
ctx.Response.Redirect(location, permanent)
256-
Task.FromResult(Some ctx)
256+
// Validate redirect URL to prevent open redirect vulnerabilities
257+
// Allow only relative URLs or URLs with the same host
258+
let isValidRedirect (url: string) =
259+
if String.IsNullOrWhiteSpace(url) then
260+
false
261+
elif url.StartsWith("/") then
262+
true // Relative URL
263+
elif url.StartsWith("~/") then
264+
true // App-relative URL
265+
else
266+
match Uri.TryCreate(url, UriKind.Absolute) with
267+
| true, uri ->
268+
// Only allow redirects to the same host
269+
let requestHost = ctx.Request.Host
270+
uri.Host = requestHost.Host
271+
| false, _ -> false
272+
273+
if isValidRedirect location then
274+
ctx.Response.Redirect(location, permanent)
275+
Task.FromResult(Some ctx)
276+
else
277+
// Log suspicious redirect attempt
278+
let logger = ctx.GetLogger("Giraffe.Core")
279+
logger.LogWarning("Blocked potential open redirect to: {Location}", location)
280+
ctx.Response.StatusCode <- 400
281+
Task.FromResult(Some ctx)
257282

258283
// ---------------------------
259284
// Model binding functions

src/Giraffe/Csrf.fs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 Microsoft.AspNetCore.Http
13+
open Microsoft.Extensions.Logging
14+
open Microsoft.AspNetCore.Antiforgery
15+
16+
/// <summary>
17+
/// Default CSRF token header name
18+
/// </summary>
19+
[<Literal>]
20+
let DefaultCsrfTokenHeaderName = "X-CSRF-TOKEN"
21+
22+
/// <summary>
23+
/// Default CSRF token form field name
24+
/// </summary>
25+
[<Literal>]
26+
let DefaultCsrfTokenFormFieldName = "__RequestVerificationToken"
27+
28+
/// <summary>
29+
/// Validates the CSRF token from the request.
30+
/// Checks for token in header (X-CSRF-TOKEN) or form field (__RequestVerificationToken).
31+
/// </summary>
32+
/// <param name="next">The next HttpFunc</param>
33+
/// <param name="ctx">The HttpContext</param>
34+
/// <returns>HttpFuncResult</returns>
35+
let validateCsrfToken: HttpHandler =
36+
fun (next: HttpFunc) (ctx: HttpContext) ->
37+
task {
38+
let antiforgery = ctx.GetService<IAntiforgery>()
39+
40+
try
41+
let! isValid = antiforgery.IsRequestValidAsync(ctx)
42+
43+
if isValid then
44+
return! next ctx
45+
else
46+
let logger = ctx.GetLogger("Giraffe.Csrf")
47+
logger.LogWarning("CSRF token validation failed for request to {Path}", ctx.Request.Path)
48+
ctx.Response.StatusCode <- 403
49+
return Some ctx
50+
with ex ->
51+
let logger = ctx.GetLogger("Giraffe.Csrf")
52+
logger.LogWarning(ex, "CSRF token validation error for request to {Path}", ctx.Request.Path)
53+
ctx.Response.StatusCode <- 403
54+
return Some ctx
55+
}
56+
57+
/// <summary>
58+
/// Alias for validateCsrfToken - validates anti-forgery tokens from requests.
59+
/// </summary>
60+
let requireAntiforgeryToken = validateCsrfToken
61+
62+
/// <summary>
63+
/// Generates a CSRF token and adds it to the HttpContext items for use in views.
64+
/// The token can be accessed via ctx.Items["CsrfToken"] and ctx.Items["CsrfTokenHeaderName"].
65+
/// </summary>
66+
/// <param name="next">The next HttpFunc</param>
67+
/// <param name="ctx">The HttpContext</param>
68+
/// <returns>HttpFuncResult</returns>
69+
let generateCsrfToken: HttpHandler =
70+
fun (next: HttpFunc) (ctx: HttpContext) ->
71+
task {
72+
let antiforgery = ctx.GetService<IAntiforgery>()
73+
let tokens = antiforgery.GetAndStoreTokens(ctx)
74+
75+
// Store token for view rendering
76+
ctx.Items.["CsrfToken"] <- tokens.RequestToken
77+
ctx.Items.["CsrfTokenHeaderName"] <- tokens.HeaderName
78+
79+
return! next ctx
80+
}
81+
82+
/// <summary>
83+
/// Returns the CSRF token as JSON for AJAX requests.
84+
/// Response format: { "token": "...", "headerName": "X-CSRF-TOKEN" }
85+
/// </summary>
86+
/// <param name="next">The next HttpFunc</param>
87+
/// <param name="ctx">The HttpContext</param>
88+
/// <returns>HttpFuncResult</returns>
89+
let csrfTokenJson: HttpHandler =
90+
fun (next: HttpFunc) (ctx: HttpContext) ->
91+
task {
92+
let antiforgery = ctx.GetService<IAntiforgery>()
93+
let tokens = antiforgery.GetAndStoreTokens(ctx)
94+
95+
let response =
96+
{|
97+
token = tokens.RequestToken
98+
headerName = tokens.HeaderName
99+
|}
100+
101+
return! Core.json response next ctx
102+
}
103+
104+
/// <summary>
105+
/// Returns the CSRF token as an HTML hidden input field.
106+
/// Can be included directly in forms.
107+
/// </summary>
108+
/// <param name="next">The next HttpFunc</param>
109+
/// <param name="ctx">The HttpContext</param>
110+
/// <returns>HttpFuncResult</returns>
111+
let csrfTokenHtml: HttpHandler =
112+
fun (next: HttpFunc) (ctx: HttpContext) ->
113+
task {
114+
let antiforgery = ctx.GetService<IAntiforgery>()
115+
let tokens = antiforgery.GetAndStoreTokens(ctx)
116+
117+
let html =
118+
sprintf "<input type=\"hidden\" name=\"%s\" value=\"%s\" />" tokens.HeaderName tokens.RequestToken
119+
120+
return! Core.htmlString html next ctx
121+
}

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

0 commit comments

Comments
 (0)