Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ An in depth functional reference to all of Giraffe's default features.
- [Content Negotiation](#content-negotiation)
- [Streaming](#streaming)
- [Redirection](#redirection)
- [Safe Redirection](#safe-redirection)
- [Response Caching](#response-caching)
- [Response Compression](#response-compression)
- [Giraffe View Engine](#giraffe-view-engine)
Expand All @@ -47,6 +48,7 @@ An in depth functional reference to all of Giraffe's default features.
- [Short GUIDs and Short IDs](#short-guids-and-short-ids)
- [Common Helper Functions](#common-helper-functions)
- [Computation Expressions](#computation-expressions)
- [CSRF Protection Helpers](#csrf-protection-helpers)
- [Additional Features](#additional-features)
- [Endpoint Routing](#endpoint-routing)
- [TokenRouter](#tokenrouter)
Expand Down Expand Up @@ -2892,6 +2894,14 @@ let webApp =

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.

#### Safe Redirection

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).

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.

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.

### Response Caching

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.
Expand Down Expand Up @@ -3221,6 +3231,8 @@ By default Giraffe uses the `System.Xml.Serialization.XmlSerializer` for (de-)se

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.

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).

#### Customizing XmlWriterSettings

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

### CSRF Protection Helpers

CSRF stands for Cross-Site Request Forgery, and according to the OWASP website can be defined as:

> 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.
>
> -- Reference [link](https://owasp.org/www-community/attacks/csrf).

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:

- `validateCsrfTokenExt (invalidTokenHandler: HttpHandler option)`: Validates the CSRF token from the request. Checks for token in header (`X-CSRF-TOKEN`) or form field (`__RequestVerificationToken`).
- `requireAntiforgeryTokenExt`: Alias for `validateCsrfTokenExt` - validates anti-forgery tokens from requests with custom error handler.
- `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.
- `requireAntiforgeryToken`: Alias for `validateCsrfToken` - validates anti-forgery tokens from requests.
- `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"]`.
- `csrfTokenJson`: Returns the CSRF token as JSON for AJAX requests. Response format: `{ "token": "...", "headerName": "X-CSRF-TOKEN" }`.
- `csrfTokenHtml`: Returns the CSRF token as an HTML hidden input field. Can be included directly in forms.

## Additional Features

There's more features available for Giraffe web applications through additional NuGet packages:
Expand Down
70 changes: 69 additions & 1 deletion src/Giraffe/Core.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace Giraffe

[<AutoOpen>]
module Core =
open System
open System.Text
open System.Threading.Tasks
open System.Globalization
Expand Down Expand Up @@ -242,16 +243,83 @@ module Core =
| true -> next ctx
| false -> skipPipeline

/// <summary>
/// Validates if a redirect URL is safe (prevents open redirect vulnerabilities).
/// Allows only relative URLs or URLs with the same host.
/// </summary>
/// <param name="ctx">The HttpContext to get the request host from.</param>
/// <param name="url">The URL to validate.</param>
/// <returns>True if the URL is safe to redirect to, false otherwise.</returns>
let isValidRedirectUrl (ctx: HttpContext) (url: string) =
if String.IsNullOrWhiteSpace url then
false
elif url.StartsWith '/' then
true // Relative URL
elif url.StartsWith "~/" then
true // App-relative URL
else
match Uri.TryCreate(url, UriKind.Absolute) with
| true, uri ->
// Only allow redirects to the same host
let requestHost = ctx.Request.Host
uri.Host = requestHost.Host
| false, _ -> false

/// <summary>
/// Redirects to a different location with a `302` or `301` (when permanent) HTTP status code.
/// Validates the redirect URL to prevent open redirect vulnerabilities.
/// </summary>
/// <param name="permanent">If true the redirect is permanent (301), otherwise temporary (302).</param>
/// <param name="location">The URL to redirect the client to.</param>
/// <param name="invalidRedirectHandler">Optional custom handler for invalid redirects. If None, returns 400 Bad Request with logged warning.</param>
/// <param name="next"></param>
/// <param name="ctx"></param>
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
let safeRedirectToExt
(permanent: bool)
(location: string)
(invalidRedirectHandler: HttpHandler option)
: HttpHandler =
fun (_next: HttpFunc) (ctx: HttpContext) ->
if isValidRedirectUrl ctx location then
ctx.Response.Redirect(location, permanent)
Task.FromResult(Some ctx)
else
let defaultHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
let logger = ctx.GetLogger("Giraffe.Core")
logger.LogWarning("Blocked potential open redirect to: {Location}", location)
ctx.Response.StatusCode <- 400
Task.FromResult(Some ctx)

let handler = invalidRedirectHandler |> Option.defaultValue defaultHandler
handler earlyReturn ctx

/// <summary>
/// Redirects to a different location with a `302` or `301` (when permanent) HTTP status code.
/// Validates the redirect URL to prevent **open redirect** vulnerabilities.
/// Uses default error handling (400 Bad Request) for invalid redirects.
/// </summary>
/// <param name="permanent">If true the redirect is permanent (301), otherwise temporary (302).</param>
/// <param name="location">The URL to redirect the client to.</param>
/// <param name="next"></param>
/// <param name="ctx"></param>
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
let safeRedirectTo (permanent: bool) (location: string) : HttpHandler =
safeRedirectToExt permanent location None

/// <summary>
/// Redirects to a different location with a `302` or `301` (when permanent) HTTP status code.
/// Does not validate redirection. Consider alternative: safeRedirectTo
/// </summary>
/// <param name="permanent">If true the redirect is permanent (301), otherwise temporary (302).</param>
/// <param name="location">The URL to redirect the client to.</param>
/// <param name="next"></param>
/// <param name="ctx"></param>
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
[<Obsolete("Use safeRedirectTo to prevent open redirect vulnerabilities.")>]
let redirectTo (permanent: bool) (location: string) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
fun (_next: HttpFunc) (ctx: HttpContext) ->
ctx.Response.Redirect(location, permanent)
Task.FromResult(Some ctx)

Expand Down
155 changes: 155 additions & 0 deletions src/Giraffe/Csrf.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
namespace Giraffe

/// <summary>
/// CSRF (Cross-Site Request Forgery) protection helpers for Giraffe.
/// Provides anti-forgery token generation and validation.
/// </summary>
[<RequireQualifiedAccess>]
module Csrf =
Comment thread
64J0 marked this conversation as resolved.
Comment thread
64J0 marked this conversation as resolved.
open System
open System.Security.Cryptography
open System.Text
open System.Threading.Tasks
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging
open Microsoft.AspNetCore.Antiforgery

// Defaults are selected to what developers would expect from ASP.NET Core application.

/// <summary>
/// Default CSRF token header name
/// </summary>
[<Literal>]
let DefaultCsrfTokenHeaderName = "X-CSRF-TOKEN"

/// <summary>
/// Default CSRF token form field name
/// </summary>
[<Literal>]
let DefaultCsrfTokenFormFieldName = "__RequestVerificationToken"
Comment thread
Thorium marked this conversation as resolved.

/// <summary>
/// Validates the CSRF token from the request.
/// Checks for token in header (X-CSRF-TOKEN) or form field (__RequestVerificationToken).
/// </summary>
/// <param name="invalidTokenHandler">Optional custom handler for invalid tokens. If None, returns 403 Forbidden with logged warning.</param>
/// <param name="next">The next HttpFunc</param>
/// <param name="ctx">The HttpContext</param>
/// <returns>HttpFuncResult</returns>
let validateCsrfTokenExt (invalidTokenHandler: HttpHandler option) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
let antiforgery = ctx.GetService<IAntiforgery>()

try
let! isValid = antiforgery.IsRequestValidAsync ctx

if isValid then
return! next ctx
else
let defaultHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
let logger = ctx.GetLogger("Giraffe.Csrf")

logger.LogWarning(
"CSRF token validation failed for request to {Path}",
ctx.Request.Path
)

ctx.Response.StatusCode <- 403
Task.FromResult(Some ctx)

let handler = invalidTokenHandler |> Option.defaultValue defaultHandler
return! handler earlyReturn ctx
with ex ->
let defaultHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
let logger = ctx.GetLogger("Giraffe.Csrf")
logger.LogWarning(ex, "CSRF token validation error for request to {Path}", ctx.Request.Path)
ctx.Response.StatusCode <- 403
Task.FromResult(Some ctx)

let handler = invalidTokenHandler |> Option.defaultValue defaultHandler
return! handler earlyReturn ctx
}

/// <summary>
/// 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.
/// </summary>
/// <param name="next">The next HttpFunc</param>
/// <param name="ctx">The HttpContext</param>
/// <returns>HttpFuncResult</returns>
let validateCsrfToken: HttpHandler = validateCsrfTokenExt None

/// <summary>
/// Alias for validateCsrfToken - validates anti-forgery tokens from requests.
/// </summary>
let requireAntiforgeryToken = validateCsrfToken

/// <summary>
/// Alias for validateCsrfTokenExt - validates anti-forgery tokens from requests with custom error handler.
/// </summary>
let requireAntiforgeryTokenExt = validateCsrfTokenExt

/// <summary>
/// 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"].
/// </summary>
/// <param name="next">The next HttpFunc</param>
/// <param name="ctx">The HttpContext</param>
/// <returns>HttpFuncResult</returns>
let generateCsrfToken: HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
let antiforgery = ctx.GetService<IAntiforgery>()
let tokens = antiforgery.GetAndStoreTokens ctx

// Store token for view rendering
ctx.Items.["CsrfToken"] <- tokens.RequestToken
ctx.Items.["CsrfTokenHeaderName"] <- tokens.HeaderName

return! next ctx
}

/// <summary>
/// Returns the CSRF token as JSON for AJAX requests.
/// Response format: { "token": "...", "headerName": "X-CSRF-TOKEN" }
/// </summary>
/// <param name="next">The next HttpFunc</param>
/// <param name="ctx">The HttpContext</param>
/// <returns>HttpFuncResult</returns>
let csrfTokenJson: HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
let antiforgery = ctx.GetService<IAntiforgery>()
let tokens = antiforgery.GetAndStoreTokens ctx

let response =
{|
token = tokens.RequestToken
headerName = tokens.HeaderName
|}

return! Core.json response next ctx
}

/// <summary>
/// Returns the CSRF token as an HTML hidden input field.
/// Can be included directly in forms.
/// </summary>
/// <param name="next">The next HttpFunc</param>
/// <param name="ctx">The HttpContext</param>
/// <returns>HttpFuncResult</returns>
let csrfTokenHtml: HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
let antiforgery = ctx.GetService<IAntiforgery>()
let tokens = antiforgery.GetAndStoreTokens(ctx)

let html =
sprintf "<input type=\"hidden\" name=\"%s\" value=\"%s\" />" tokens.HeaderName tokens.RequestToken

return! Core.htmlString html next ctx
}
1 change: 1 addition & 0 deletions src/Giraffe/Giraffe.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
<Compile Include="ModelParser.fs" />
<Compile Include="HttpContextExtensions.fs" />
<Compile Include="Core.fs" />
<Compile Include="Csrf.fs" />
<Compile Include="ResponseCaching.fs" />
<Compile Include="ModelValidation.fs" />
<Compile Include="Auth.fs" />
Expand Down
13 changes: 11 additions & 2 deletions src/Giraffe/Xml.fs
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,14 @@ module SystemXml =

member __.Deserialize<'T>(xml: string) =
let serializer = XmlSerializer(typeof<'T>)
use reader = new StringReader(xml)
serializer.Deserialize reader :?> 'T
use stringReader = new StringReader(xml)
// Secure XML parsing: disable DTD processing and external entities to prevent XXE attacks
let xmlReaderSettings =
new XmlReaderSettings(
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersFromEntities = 1024L * 1024L
) // 1MB limit

use xmlReader = XmlReader.Create(stringReader, xmlReaderSettings)
serializer.Deserialize xmlReader :?> 'T
Comment thread
Thorium marked this conversation as resolved.
Comment thread
64J0 marked this conversation as resolved.
1 change: 1 addition & 0 deletions tests/Giraffe.Tests/Giraffe.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<Compile Include="PreconditionalTests.fs" />
<Compile Include="JsonTests.fs" />
<Compile Include="XmlTests.fs" />
<Compile Include="SecurityTests.fs" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading