Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Changed the CSRF prevention request headers to an immutable hash set
The default set is created once and compares header names case-insensitively.
The names in the error of a blocked request are sorted, as a hash set
enumerates in no particular order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
xperiandri and claude committed Oct 10, 2026
commit 361410a68d039f3b139083633d6616347ca6c1c8
27 changes: 17 additions & 10 deletions src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLOptions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace FSharp.Data.GraphQL.Server.AspNetCore

open FSharp.Data.GraphQL
open System
open System.Collections.Immutable
open System.Text.Json
open System.Threading.Tasks
open Microsoft.AspNetCore.Http
Expand Down Expand Up @@ -65,12 +66,24 @@ module CsrfPreventionHeaders =
type CsrfPreventionOptions = {
/// <summary>
/// The names of the request headers that let a request a browser would not preflight through, when any of them has a
/// non-empty value. Header names are case-insensitive. An empty list rejects every such request, so that only requests
/// with another <c>Content-Type</c>, such as <c>application/json</c>, are executed.
/// non-empty value. Header names are matched case-insensitively, whatever the comparer of the set. An empty set rejects
/// every such request, so that only requests with another <c>Content-Type</c>, such as <c>application/json</c>, are
/// executed.
/// </summary>
RequestHeaders : string list
RequestHeaders : ImmutableHashSet<string>
} with

// Created once, as the options of every endpoint start from it
static let defaultOptions = {
RequestHeaders =
ImmutableHashSet.Create (
StringComparer.OrdinalIgnoreCase,
CsrfPreventionHeaders.GraphQLPreflight,
CsrfPreventionHeaders.ApolloRequirePreflight,
CsrfPreventionHeaders.ApolloOperationName
)
}

/// <summary>
/// The default protection: a request a browser would not preflight passes with a non-empty
/// <see cref="CsrfPreventionHeaders.GraphQLPreflight"/>, <see cref="CsrfPreventionHeaders.ApolloRequirePreflight"/> or
Expand All @@ -80,13 +93,7 @@ type CsrfPreventionOptions = {
/// Apollo's header names are accepted too, so that a client written for Apollo Server's CSRF prevention works unchanged:
/// a custom header of any name makes a browser preflight the request, so each of them protects equally well.
/// </remarks>
static member Default = {
RequestHeaders = [
CsrfPreventionHeaders.GraphQLPreflight
CsrfPreventionHeaders.ApolloRequirePreflight
CsrfPreventionHeaders.ApolloOperationName
]
}
static member Default = defaultOptions

type IGraphQLOptions =
abstract member SerializerOptions : JsonSerializerOptions
Expand Down
18 changes: 11 additions & 7 deletions src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -77,22 +77,26 @@ module internal CsrfPrevention =
isSimple

/// Whether the request carries any of the headers with a non-empty value
let hasAnyHeader (headers : IHeaderDictionary) (headerNames : string list) =
let hasAnyHeader (headers : IHeaderDictionary) (headerNames : ImmutableHashSet<string>) =
headerNames
|> List.exists (fun headerName ->
|> Seq.exists (fun headerName ->
match headers.TryGetValue headerName with
| true, values -> not (StringValues.IsNullOrEmpty values)
| false, _ -> false)

/// The error message of a blocked request, modeled on Apollo Server's, telling the client how to get through
let blockedRequestMessage (headerNames : string list) =
let blockedRequestMessage (headerNames : ImmutableHashSet<string>) =
let contentTypeAdvice =
$"""specify a 'Content-Type' header with a media type that is not one of {String.Join (", ", preflightFreeMediaTypes)}"""
let advice =
match headerNames with
| [] -> $"Please {contentTypeAdvice}."
| headerNames ->
$"""Please either {contentTypeAdvice}, or provide a non-empty value for one of the following headers: {String.Join (", ", headerNames)}."""
if headerNames.IsEmpty then
$"Please {contentTypeAdvice}."
else
// A hash set enumerates in no particular order, so the names are sorted to keep the message stable
let sortedHeaderNames =
headerNames
|> Seq.sortWith (fun first second -> String.CompareOrdinal (first, second))
$"""Please either {contentTypeAdvice}, or provide a non-empty value for one of the following headers: {String.Join (", ", sortedHeaderNames)}."""
$"This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). {advice}"

/// Handles GraphQL requests using a provided root schema.
Expand Down
2 changes: 1 addition & 1 deletion src/FSharp.Data.GraphQL.Server.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ services.AddGraphQL<Root> (
Schema.executor,
rootFactory,
// Accept only GraphQL-Preflight and X-Requested-With
configure = fun options -> { options with CsrfPrevention = ValueSome { RequestHeaders = [ CsrfPreventionHeaders.GraphQLPreflight; "X-Requested-With" ] } }
configure = fun options -> { options with CsrfPrevention = ValueSome { RequestHeaders = ImmutableHashSet.Create (CsrfPreventionHeaders.GraphQLPreflight, "X-Requested-With") } }
// Or turn the protection off
// configure = fun options -> { options with CsrfPrevention = ValueNone }
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module FSharp.Data.GraphQL.Tests.AspNetCore.CsrfPreventionTests

open System
open System.Collections.Generic
open System.Collections.Immutable
open System.IO
open System.Net.Http
open System.Text
Expand Down Expand Up @@ -250,7 +251,7 @@ let ``The error of a blocked request explains how to get through`` () : Task = t
"This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). "
+ "Please either specify a 'Content-Type' header with a media type that is not one of "
+ "application/x-www-form-urlencoded, multipart/form-data, text/plain, or provide a non-empty value "
+ "for one of the following headers: GraphQL-Preflight, Apollo-Require-Preflight, X-Apollo-Operation-Name.",
+ "for one of the following headers: Apollo-Require-Preflight, GraphQL-Preflight, X-Apollo-Operation-Name.",
message
)
}
Expand Down Expand Up @@ -282,7 +283,7 @@ let ``GET is executed without a preflight header when CSRF prevention is off`` (
let ``Configured request headers replace the default ones`` () : Task = task {
let withCustomHeader (options : GraphQLOptions<Root>) = {
options with
CsrfPrevention = ValueSome { RequestHeaders = [ "X-Requested-With" ] }
CsrfPrevention = ValueSome { RequestHeaders = ImmutableHashSet.Create "X-Requested-With" }
}
let! body = bodyOf "json"

Expand All @@ -298,7 +299,10 @@ let ``Configured request headers replace the default ones`` () : Task = task {

[<Fact>]
let ``No configured request header lets only requests a browser preflights through`` () : Task = task {
let withoutHeaders (options : GraphQLOptions<Root>) = { options with CsrfPrevention = ValueSome { RequestHeaders = [] } }
let withoutHeaders (options : GraphQLOptions<Root>) = {
options with
CsrfPrevention = ValueSome { RequestHeaders = ImmutableHashSet<string>.Empty }
}
let! body = bodyOf "json"

let! simpleResponse =
Expand Down
Loading