Skip to content

Commit 719fd46

Browse files
committed
Unit tests added
1 parent 841453e commit 719fd46

4 files changed

Lines changed: 684 additions & 36 deletions

File tree

src/Giraffe/Core.fs

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -243,42 +243,65 @@ module Core =
243243
| true -> next ctx
244244
| false -> skipPipeline
245245

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+
246268
/// <summary>
247269
/// 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.
248271
/// </summary>
249272
/// <param name="permanent">If true the redirect is permanent (301), otherwise temporary (302).</param>
250273
/// <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>
251275
/// <param name="next"></param>
252276
/// <param name="ctx"></param>
253277
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
254-
let redirectTo (permanent: bool) (location: string) : HttpHandler =
278+
let redirectToExt (permanent: bool) (location: string) (invalidRedirectHandler: HttpHandler option) : HttpHandler =
255279
fun (next: HttpFunc) (ctx: HttpContext) ->
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
280+
if isValidRedirectUrl ctx location then
274281
ctx.Response.Redirect(location, permanent)
275282
Task.FromResult(Some ctx)
276283
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)
284+
let defaultHandler =
285+
fun (next: HttpFunc) (ctx: HttpContext) ->
286+
let logger = ctx.GetLogger("Giraffe.Core")
287+
logger.LogWarning("Blocked potential open redirect to: {Location}", location)
288+
ctx.Response.StatusCode <- 400
289+
Task.FromResult(Some ctx)
290+
291+
let handler = invalidRedirectHandler |> Option.defaultValue defaultHandler
292+
handler earlyReturn ctx
293+
294+
/// <summary>
295+
/// Redirects to a different location with a `302` or `301` (when permanent) HTTP status code.
296+
/// Validates the redirect URL to prevent open redirect vulnerabilities.
297+
/// Uses default error handling (400 Bad Request) for invalid redirects.
298+
/// </summary>
299+
/// <param name="permanent">If true the redirect is permanent (301), otherwise temporary (302).</param>
300+
/// <param name="location">The URL to redirect the client to.</param>
301+
/// <param name="next"></param>
302+
/// <param name="ctx"></param>
303+
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
304+
let redirectTo (permanent: bool) (location: string) : HttpHandler = redirectToExt permanent location None
282305

283306
// ---------------------------
284307
// Model binding functions

src/Giraffe/Csrf.fs

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ module Csrf =
99
open System
1010
open System.Security.Cryptography
1111
open System.Text
12+
open System.Threading.Tasks
1213
open Microsoft.AspNetCore.Http
1314
open Microsoft.Extensions.Logging
1415
open Microsoft.AspNetCore.Antiforgery
1516

17+
// Defaults are selected to what developers would expect from ASP.NET Core application.
18+
1619
/// <summary>
1720
/// Default CSRF token header name
1821
/// </summary>
@@ -29,36 +32,67 @@ module Csrf =
2932
/// Validates the CSRF token from the request.
3033
/// Checks for token in header (X-CSRF-TOKEN) or form field (__RequestVerificationToken).
3134
/// </summary>
35+
/// <param name="invalidTokenHandler">Optional custom handler for invalid tokens. If None, returns 403 Forbidden with logged warning.</param>
3236
/// <param name="next">The next HttpFunc</param>
3337
/// <param name="ctx">The HttpContext</param>
3438
/// <returns>HttpFuncResult</returns>
35-
let validateCsrfToken: HttpHandler =
39+
let validateCsrfTokenExt (invalidTokenHandler: HttpHandler option) : HttpHandler =
3640
fun (next: HttpFunc) (ctx: HttpContext) ->
3741
task {
3842
let antiforgery = ctx.GetService<IAntiforgery>()
3943

4044
try
41-
let! isValid = antiforgery.IsRequestValidAsync(ctx)
45+
let! isValid = antiforgery.IsRequestValidAsync ctx
4246

4347
if isValid then
4448
return! next ctx
4549
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+
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
5064
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
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
5574
}
5675

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+
5786
/// <summary>
5887
/// Alias for validateCsrfToken - validates anti-forgery tokens from requests.
5988
/// </summary>
6089
let requireAntiforgeryToken = validateCsrfToken
6190

91+
/// <summary>
92+
/// Alias for validateCsrfTokenExt - validates anti-forgery tokens from requests with custom error handler.
93+
/// </summary>
94+
let requireAntiforgeryTokenExt = validateCsrfTokenExt
95+
6296
/// <summary>
6397
/// Generates a CSRF token and adds it to the HttpContext items for use in views.
6498
/// The token can be accessed via ctx.Items["CsrfToken"] and ctx.Items["CsrfTokenHeaderName"].
@@ -70,7 +104,7 @@ module Csrf =
70104
fun (next: HttpFunc) (ctx: HttpContext) ->
71105
task {
72106
let antiforgery = ctx.GetService<IAntiforgery>()
73-
let tokens = antiforgery.GetAndStoreTokens(ctx)
107+
let tokens = antiforgery.GetAndStoreTokens ctx
74108

75109
// Store token for view rendering
76110
ctx.Items.["CsrfToken"] <- tokens.RequestToken
@@ -90,7 +124,7 @@ module Csrf =
90124
fun (next: HttpFunc) (ctx: HttpContext) ->
91125
task {
92126
let antiforgery = ctx.GetService<IAntiforgery>()
93-
let tokens = antiforgery.GetAndStoreTokens(ctx)
127+
let tokens = antiforgery.GetAndStoreTokens ctx
94128

95129
let response =
96130
{|

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)