Skip to content

Added cross-site request forgery prevention to the ASP.NET Core handler - #630

Open
xperiandri wants to merge 5 commits into
devfrom
csrf-prevention
Open

xperiandri wants to merge 5 commits into
devfrom
csrf-prevention

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Problem

A browser sends some requests to another site without a CORS preflight, and with the cookies of that site. These are requests with no custom headers whose Content-Type is missing or one of application/x-www-form-urlencoded, multipart/form-data and text/plain.

The HTTP handler executed such requests, since it reads the JSON whatever Content-Type a request declares. So a page of any other site could run a mutation on behalf of a user signed in to an API that authenticates through cookies.

Changes

  • CSRF prevention (breaking: on by default). It is modeled on Apollo Server's csrfPrevention. Such a request is rejected with 400 and a GraphQL error whose extensions.code is BAD_REQUEST, unless it carries a non-empty value in one of these headers:

    • GraphQL-Preflight;
    • Apollo-Require-Preflight or X-Apollo-Operation-Name, Apollo's names, so that clients written for Apollo Server work unchanged.

    A browser never sends a custom header to another site without a preflight, which the CORS policy then allows or refuses.

  • Any method. As in Apollo Server, the method is not checked. A middleware overriding the method from a form field, such as UseHttpMethodOverride, would otherwise turn a form posted from another site into a PUT that skips the check. Only a CORS preflight (OPTIONS with Access-Control-Request-Method, which a form cannot set) always passes.

  • Not affected:

    • requests with another Content-Type, such as application/json;
    • WebSocket connections. The README says a server authenticating them through cookies must check their Origin itself.
  • Configuration: GraphQLOptions.CsrfPrevention accepts other headers or, set to ValueNone, turns the check off.

  • Custom handlers: GraphQLRequestHandler.CheckCsrfPrevention runs the check for handlers that override HandleAsync.

  • Client: the client provider sends GraphQL-Preflight: 1 with its GET requests and multipart uploads, unless the caller sets it. JSON requests go without it, so that a browser-hosted client does not need every server's CORS policy to allow the header.

  • Sample: the star-wars-api sample allows the header in CORS.

Tests

The unit tests are xUnit, in AspNetCore/CsrfPreventionTests.fs:

  • every simple Content-Type spelling, blocked without the header and executed with it;
  • a missing Content-Type, GET, an empty header value, each default header and their case;
  • requests a browser preflights;
  • a CORS preflight passing;
  • PUT, DELETE, PATCH and OPTIONS forms being blocked;
  • the error message and code, configured headers, an empty header list, and the check turned off.

The integration tests check:

  • which client requests carry the header, and that a value the caller sets is kept;
  • the server blocking an upload without the header, and executing one with it or sent by the client.

An independent review compared the Content-Type reading with whatwg-mimetype, Chromium and Apollo Server on 207 811 generated values: none of the 410 a browser sends without a preflight got through, and none differed from Apollo. It found the method-override bypass, now fixed and tested.

Verification:

  • Unit tests: the whole project passes, 816 tests with 5 skipped.
  • FAKE: the full pipeline passes, including the integration tests.

🤖 Generated with Claude Code

xperiandri and others added 3 commits October 10, 2026 04:59
A browser sends a GET, HEAD or POST request to another site without a CORS
preflight when it has no custom headers and its Content-Type is missing or
application/x-www-form-urlencoded, multipart/form-data or text/plain. 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 a signed-in user.

- Modeled on Apollo Server's csrfPrevention and enabled by default
  (breaking change): such a request is rejected with 400 and a GraphQL error
  unless it carries a non-empty GraphQL-Preflight, Apollo-Require-Preflight
  or X-Apollo-Operation-Name header. Requests with another Content-Type, such
  as application/json, and WebSocket connections are not affected.
- GraphQLOptions.CsrfPrevention configures the accepted headers or, set to
  ValueNone, turns the check off; GraphQLRequestHandler.CheckCsrfPrevention
  lets handlers overriding HandleAsync run the same check.
- The GraphQL client provider sends GraphQL-Preflight: 1 with every HTTP
  request, multipart file uploads and introspection included, unless the
  caller sets that header itself.
- The star-wars-api sample allows the header in CORS, and the README of
  FSharp.Data.GraphQL.Server.AspNetCore documents the protection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The check no longer looks at the method of a request, as Apollo Server
  does not: a middleware overriding the method from a form field, such as
  UseHttpMethodOverride, turned a form posted from another site without a
  preflight into a PUT that skipped the check and executed a mutation.
  Only a CORS preflight, an OPTIONS request with
  Access-Control-Request-Method, which a form cannot set, always passes.
- The error of a blocked request carries extensions.code BAD_REQUEST, as
  Apollo Server's does.
- A blocked request is logged at the debug level: a page of another site
  decides how many such requests browsers send.
- The client provider sends GraphQL-Preflight only with the requests a
  server's CSRF prevention would otherwise reject, GET requests and
  multipart uploads: a browser-hosted client would otherwise need every
  server's CORS policy to allow the header on its JSON requests too.
- The README says that WebSocket connections are not checked, so a server
  authenticating them through cookies must check their Origin itself.
- Fixed a doc comment mixing plain text and a tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client builds a multipart upload as MultipartContent of the form-data
subtype, not as MultipartFormDataContent, so the header was only added for
the latter and every upload was rejected by the server's CSRF prevention.
The integration tests of uploads caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 07:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Introspection GET requests overwrite rather than preserve a caller-supplied preflight header value.

2 open findings
What changed in this PR

Adds default CSRF protection to the ASP.NET Core GraphQL handler and updates clients to satisfy it.

Changes:

  • Rejects potentially unsafe requests unless preflight evidence is present.
  • Adds client preflight headers and configuration options.
  • Adds comprehensive unit/integration coverage and documentation.
File Description
tests/​FSharp.Data.GraphQL.Tests/​FSharp.Data.GraphQL.Tests.fsproj Registers CSRF unit tests.
tests/​FSharp.Data.GraphQL.Tests/​AspNetCore/​RequestHandlerExtensibilityTests.fs Adapts dispatch tests for CSRF protection.
tests/​FSharp.Data.GraphQL.Tests/​AspNetCore/​CsrfPreventionTests.fs Tests handler CSRF behavior and configuration.
tests/​FSharp.Data.GraphQL.IntegrationTests/​IntrospectionUpdateTests.fs Adds preflight headers to introspection requests.
tests/​FSharp.Data.GraphQL.IntegrationTests/​FSharp.Data.GraphQL.IntegrationTests.fsproj Registers CSRF integration tests.
tests/​FSharp.Data.GraphQL.IntegrationTests/​CsrfPreventionTests.fs Tests client headers and multipart handling.
src/​FSharp.Data.GraphQL.Server.AspNetCore/​ServiceCollectionExtensions.fs Enables CSRF protection by default.
src/​FSharp.Data.GraphQL.Server.AspNetCore/​README.md Documents protection and configuration.
src/​FSharp.Data.GraphQL.Server.AspNetCore/​GraphQLRequestHandler.fs Implements request classification and rejection.
src/​FSharp.Data.GraphQL.Server.AspNetCore/​GraphQLOptions.fs Defines CSRF options and headers.
src/​FSharp.Data.GraphQL.Client/​GraphQLClient.fs Adds preflight headers to GETs and uploads.
samples/​star-wars-api/​Startup.fs Allows the preflight header through CORS.
RELEASE_NOTES.md Documents the breaking security change.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


let private getAsync ct (invoker : HttpMessageInvoker) (serverUrl : string) = task {
use requestMessage = new HttpRequestMessage (HttpMethod.Get, serverUrl)
addPreflightHeader requestMessage
let ctx = DefaultHttpContext (RequestServices = serviceProvider)
ctx.Request.Method <- method
ctx.Request.Path <- PathString "/graphql"
// These tests are about dispatch, so a GET must not be stopped by the CSRF prevention CsrfPreventionTests covers
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Test Results

    9 files      9 suites   14m 41s ⏱️
  932 tests   927 ✅  5 💤 0 ❌
2 796 runs  2 781 ✅ 15 💤 0 ❌

Results for commit 6aff919.

♻️ This comment has been updated with latest results.

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>
/// An HTTP client of the integration server whose GET requests pass its CSRF prevention
let createPreflightedHttpClient () =
let httpClient = TestHosts.createIntegrationHttpClient ()
httpClient.DefaultRequestHeaders.Add ("GraphQL-Preflight", "1")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not const from our public module?

- Media types, header names and methods come from `MediaTypeNames`,
  `HeaderNames` and `HttpMethods` instead of being spelled out.
- The header names of CSRF prevention moved to `CsrfPreventionHeaders` in
  Shared, so that the client that sends one, the server that checks for
  it and the tests share a single definition.
- The media types and header names in the error of a blocked request
  are sorted, as both come from hash sets, which enumerate in no
  particular order.
- The introspection GET of the client carries the headers of the caller,
  so that a preflight header among them is kept.
- The tests assert with `Assert.Equal` and `Assert.Empty` instead of
  `Assert.True` on a comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants