Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,6 @@
* Fixed `graphql-transport-ws` throwing while serializing an `error` message or a `pong` carrying a payload, since neither was written under the `payload` property name `Utf8JsonWriter` requires
* Fixed validation of an inline fragment without a type condition (`... { … }`), which used to fail with an exception instead of applying to the parent type
* Fixed the GraphQL client provider building `Operation<...>` again for every file that uses the same operation, which added another operation type of the same name to `Operations` each time
* **Breaking Change** Added cross-site request forgery (CSRF) prevention to the `FSharp.Data.GraphQL.Server.AspNetCore` HTTP handler, modeled on Apollo Server's `csrfPrevention` and enabled by default: a request of the kind a browser sends to another site without a CORS preflight, one without a `Content-Type` or with `application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain` (parameters ignored, case-insensitive), whatever its method, since a method override middleware could change it, is rejected with `400 Bad Request` and a GraphQL error whose `extensions.code` is `BAD_REQUEST`, unless it carries a non-empty `GraphQL-Preflight`, `Apollo-Require-Preflight` or `X-Apollo-Operation-Name` header. A CORS preflight, requests with any other `Content-Type`, such as `application/json`, and WebSocket connections are not affected. `GraphQLOptions` gained the `CsrfPrevention` field to accept other headers or, set to `ValueNone`, to turn the check off, and `GraphQLRequestHandler` gained `CheckCsrfPrevention` for handlers that override `HandleAsync`
* Changed the GraphQL client provider to send `GraphQL-Preflight: 1` with the HTTP requests a server's CSRF prevention would otherwise reject, introspection `GET` requests and multipart file uploads, unless the caller sets that header itself; JSON requests, which a browser preflights anyway, are sent without it
* Removed the internal `Observable.withCompletionMarker`
3 changes: 2 additions & 1 deletion samples/star-wars-api/Startup.fs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ type Startup private () =
//endpoints.MapOxpeckerEndpoint (HttpEndpoints.graphQL<Root>("/", id))
let handler =
setHttpHeader "Access-Control-Allow-Origin" "*"
>=> setHttpHeader "Access-Control-Allow-Headers" "content-type"
// A browser client sending a file upload or a GET needs the GraphQL-Preflight header to pass CSRF prevention
>=> setHttpHeader "Access-Control-Allow-Headers" "content-type, graphql-preflight"
>=> (setHttpHeader "Request-Type" "Classic") // For integration testing purposes
>=> HttpEndpointHandlers.graphQL<Root>
endpoints.MapOxpeckerEndpoint (route "/" handler))
Expand Down
25 changes: 25 additions & 0 deletions src/FSharp.Data.GraphQL.Client/GraphQLClient.fs
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,40 @@ module GraphQLClient =
httpHeaders
|> Seq.iter (fun (name, value) -> requestMessage.Headers.Add (name, value))

/// <summary>
/// The header that tells the CSRF prevention of a GraphQL server a browser would have preflighted the request.
/// Such a server, FSharp.Data.GraphQL.Server.AspNetCore by default, rejects a <c>GET</c> request or a multipart
/// file upload without it, since a browser sends those to another site without a CORS preflight.
/// </summary>
[<Literal>]
let private PreflightHeaderName = "GraphQL-Preflight"

/// <summary>
/// Adds the preflight header, unless the caller already set one through its own headers.
/// </summary>
/// <remarks>
/// Only the requests a browser would send without a preflight need it: a <c>GET</c> request and a multipart upload. A
/// JSON request, which a browser preflights anyway, is sent without it, so that a client hosted in a browser does not need
/// the CORS policy of every server it calls to allow the header.
/// </remarks>
let private addPreflightHeader (requestMessage : HttpRequestMessage) =
if not (requestMessage.Headers.Contains PreflightHeaderName) then
requestMessage.Headers.Add (PreflightHeaderName, "1")

let private postAsync ct (invoker : HttpMessageInvoker) (serverUrl : string) (httpHeaders : seq<string * string>) (content : HttpContent) = task {
use requestMessage = new HttpRequestMessage (HttpMethod.Post, serverUrl)
requestMessage.Content <- content
addHeaders httpHeaders requestMessage
// A multipart upload, built as MultipartContent of the form-data subtype, rather than MultipartFormDataContent
match content with
| :? MultipartContent -> addPreflightHeader requestMessage
| _ -> ()
return! invoker.SendAsync (requestMessage, ct) |> ensureSuccessCode
}

let private getAsync ct (invoker : HttpMessageInvoker) (serverUrl : string) = task {
use requestMessage = new HttpRequestMessage (HttpMethod.Get, serverUrl)
addPreflightHeader requestMessage
return! invoker.SendAsync (requestMessage, ct) |> ensureSuccessCode
}

Expand Down
69 changes: 69 additions & 0 deletions src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLOptions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,69 @@ type GraphQLTransportWSOptions = {
CustomPingHandler : PingHandler voption
}

/// The names of the request headers that show a GraphQL server a browser would have sent the request only after a CORS preflight.
[<RequireQualifiedAccess>]
module CsrfPreventionHeaders =

/// <summary>
/// The header the GraphQL client provider of this library sends, with the value <c>1</c>, with every request a browser
/// would send without a preflight: <c>GET</c> requests and multipart file uploads.
/// </summary>
[<Literal>]
let GraphQLPreflight = "GraphQL-Preflight"

/// The header Apollo Server's CSRF prevention accepts from clients that send otherwise simple requests, such as file uploads.
[<Literal>]
let ApolloRequirePreflight = "Apollo-Require-Preflight"

/// The header with the operation name that Apollo Server's CSRF prevention accepts, and some Apollo clients send.
[<Literal>]
let ApolloOperationName = "X-Apollo-Operation-Name"

/// <summary>
/// The protection of the GraphQL HTTP endpoint against cross-site request forgery (CSRF), modeled on Apollo Server's
/// <c>csrfPrevention</c>.
/// <para>
/// A browser sends a <c>GET</c>, <c>HEAD</c> or <c>POST</c> request to another site without asking it first through a CORS
/// preflight when the request has no custom headers and its <c>Content-Type</c> is either missing or one of
/// <c>application/x-www-form-urlencoded</c>, <c>multipart/form-data</c> and <c>text/plain</c>. Such a request carries the
/// cookies of the site it goes to, so a page of any other site could run an operation on behalf of the user signed in there,
/// even though it cannot read the response.
/// </para>
/// <para>
/// The request handler therefore rejects every request with such a <c>Content-Type</c> with <c>400 Bad Request</c>, unless
/// it carries one of the <see cref="RequestHeaders"/> with a non-empty value. A browser never sends a custom header to
/// another site without a preflight, which the CORS policy of the server then allows or refuses. The method of a request is
/// not checked, since a middleware overriding the method from a form field would turn a form posted from another site into
/// a request with any method; only a CORS preflight itself always passes.
/// </para>
/// </summary>
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.
/// </summary>
RequestHeaders : string list
} with

/// <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
/// <see cref="CsrfPreventionHeaders.ApolloOperationName"/> header.
/// </summary>
/// <remarks>
/// 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
]
}

type IGraphQLOptions =
abstract member SerializerOptions : JsonSerializerOptions
abstract member WebsocketOptions : GraphQLTransportWSOptions
Expand All @@ -36,6 +99,12 @@ type GraphQLOptions<'Root> = {
ReadBufferSize : int
SerializerOptions : JsonSerializerOptions
WebsocketOptions : GraphQLTransportWSOptions
/// <summary>
/// The protection of the HTTP endpoint against cross-site request forgery, <see cref="CsrfPreventionOptions.Default"/>
/// unless configured otherwise. <c>ValueNone</c> turns it off, for example for a server that only serves clients
/// other than browsers or that authenticates requests without cookies.
/// </summary>
CsrfPrevention : CsrfPreventionOptions voption
} with

interface IGraphQLOptions with
Expand Down
117 changes: 117 additions & 0 deletions src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
namespace FSharp.Data.GraphQL.Server.AspNetCore

open System
open System.Collections.Generic
open System.Collections.Immutable
open System.IO
open System.Text.Json
open System.Text.Json.Serialization
Expand All @@ -9,6 +11,7 @@ open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Microsoft.Extensions.Options
open Microsoft.Extensions.Primitives

open FSharp.Data.GraphQL
open FsToolkit.ErrorHandling
Expand All @@ -22,6 +25,76 @@ module private DeferredEventLogging =
/// The path of a deferred event as one string for the log, its segments joined as GraphQL error paths print them
let formatPath (path : obj list) = String.Join<obj> ("/", path)

/// <summary>
/// Recognizes the requests a browser sends to another site without a CORS preflight, following the
/// <see href="https://fetch.spec.whatwg.org/#cors-safelisted-request-header">CORS-safelisted request headers</see>
/// of the Fetch standard.
/// </summary>
/// <remarks>
/// The method of a request is not checked, as Apollo Server does not check it either: a middleware overriding the method
/// from a form field, such as the one <c>UseHttpMethodOverride</c> adds, turns a form a browser posts to another site
/// without a preflight into a request with any method.
/// </remarks>
module internal CsrfPrevention =

/// The media types of a request body that a browser sends to another site without a CORS preflight
let private preflightFreeMediaTypes = [| "application/x-www-form-urlencoded"; "multipart/form-data"; "text/plain" |]

/// The extension code of the error of a blocked request, the one Apollo Server reports it with
[<Literal>]
let BlockedRequestCode = "BAD_REQUEST"

/// <summary>
/// Whether the request is the CORS preflight a browser sends before a request it does not send without one: an
/// <c>OPTIONS</c> request with an <c>Access-Control-Request-Method</c> header, which a form cannot set. It carries
/// neither an operation nor the headers that let a request through, so it is never blocked.
/// </summary>
let isCorsPreflight (request : HttpRequest) =
HttpMethods.IsOptions request.Method
&& request.Headers.ContainsKey Microsoft.Net.Http.Headers.HeaderNames.AccessControlRequestMethod

/// <summary>
/// Whether a browser sends a request with this <c>Content-Type</c> to another site without a CORS preflight: when there
/// is none, or when its media type without the parameters is one of the simple ones, compared case-insensitively.
/// </summary>
let isPreflightFreeContentType (contentType : string | null) =
match contentType with
| null -> true
| contentType ->
// The Fetch standard reads the media type as everything before the parameters with the HTTP whitespace around it
// trimmed. A value it cannot parse is not safelisted, so the browser preflights it, and this reading, which never
// fails, at worst rejects a malformed value a browser would not have sent without a preflight anyway.
let span = contentType.AsSpan ()
let mediaType =
match MemoryExtensions.IndexOf (span, ';') with
| -1 -> span
| parametersStart -> span.Slice (0, parametersStart)
let mediaType = MemoryExtensions.Trim (mediaType, " \t\r\n".AsSpan ())
let mutable isSimple = false
for simpleMediaType in preflightFreeMediaTypes do
if MemoryExtensions.Equals (mediaType, simpleMediaType.AsSpan (), StringComparison.OrdinalIgnoreCase) then
isSimple <- true
isSimple

/// Whether the request carries any of the headers with a non-empty value
let hasAnyHeader (headers : IHeaderDictionary) (headerNames : string list) =
headerNames
|> List.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 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)}."""
$"This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). {advice}"

/// Handles GraphQL requests using a provided root schema.
type DefaultGraphQLRequestHandler<'Root>
/// <summary>
Expand Down Expand Up @@ -269,13 +342,57 @@ and [<AbstractClass>] GraphQLRequestHandler<'Root>
return (TypedResults.Ok response) :> IResult
}

/// <summary>
/// Rejects a request a browser could have sent from a page of another site without a CORS preflight: a request with no
/// <c>Content-Type</c>, or with <c>application/x-www-form-urlencoded</c>, <c>multipart/form-data</c> or
/// <c>text/plain</c>, whatever its method, that carries none of the <see cref="CsrfPreventionOptions.RequestHeaders"/>
/// with a non-empty value. A CORS preflight itself always passes, and so does every request when
/// <see cref="GraphQLOptions{Root}.CsrfPrevention"/> is <c>ValueNone</c>.
/// </summary>
/// <remarks>
/// <see cref="HandleAsync"/> calls this before it reads the request, so an override of <see cref="HandleAsync"/> that
/// does not call the base implementation should call this first too.
/// </remarks>
/// <returns>
/// <c>Ok</c> when the request may be executed, or <c>Error</c> with a <c>400 Bad Request</c> GraphQL response whose
/// error explains how to get through.
/// </returns>
member _.CheckCsrfPrevention () : Result<unit, IResult> =
match options.CurrentValue.CsrfPrevention with
| ValueNone -> Ok ()
| ValueSome csrfPrevention ->
let request = ctx.Request
if
not (CsrfPrevention.isCorsPreflight request)
&& CsrfPrevention.isPreflightFreeContentType request.ContentType
&& not (CsrfPrevention.hasAnyHeader request.Headers csrfPrevention.RequestHeaders)
then
// At the debug level: a page of another site decides how many such requests the browsers visiting it send
logger.LogDebug (
"Blocked a {method} request with Content-Type '{contentType}' and no CSRF prevention header as a potential cross-site request forgery",
request.Method,
request.ContentType
)
let extensions =
ImmutableDictionary.CreateRange (StringComparer.Ordinal, [ KeyValuePair ("code", box CsrfPrevention.BlockedRequestCode) ])
let error =
GQLProblemDetails.Create (
CsrfPrevention.blockedRequestMessage csrfPrevention.RequestHeaders,
extensions :> IReadOnlyDictionary<string, obj>
)
// No document has been read, so there is no document id to report
Error (TypedResults.BadRequest (GQLResponse.RequestError (0, [ error ])) :> IResult)
else
Ok ()

/// Handle the request and return the result
abstract HandleAsync : unit -> Task<Result<IResult, IResult>>

default handler.HandleAsync () : Task<Result<IResult, IResult>> = taskResult {
if ctx.RequestAborted.IsCancellationRequested then
return TypedResults.Empty
else
do! handler.CheckCsrfPrevention ()
match! handler.CheckOperationType () with
| IntrospectionQuery optionalAstDocument -> return! handler.ExecuteIntrospectionQuery optionalAstDocument
| OperationQuery content -> return! handler.ExecuteOperation (content)
Expand Down
Loading
Loading