Skip to content

Commit 23b5829

Browse files
authored
Add CSRF protection (#1138)
1 parent c421048 commit 23b5829

21 files changed

+288
-29
lines changed

README.md

+27
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,31 @@ app.UseEndpoints(endpoints =>
569569
await app.RunAsync();
570570
```
571571

572+
In order to ensure that all requests trigger CORS preflight requests, by default the server
573+
will reject requests that do not meet one of the following criteria:
574+
575+
- The request is a POST request that includes a Content-Type header that is not
576+
`application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`.
577+
- The request includes a non-empty `GraphQL-Require-Preflight` header.
578+
579+
To disable this behavior, set the `CsrfProtectionEnabled` option to `false` in the `GraphQLServerOptions`.
580+
581+
```csharp
582+
app.UseGraphQL("/graphql", config =>
583+
{
584+
config.CsrfProtectionEnabled = false;
585+
});
586+
```
587+
588+
You may also change the allowed headers by modifying the `CsrfProtectionHeaders` option.
589+
590+
```csharp
591+
app.UseGraphQL("/graphql", config =>
592+
{
593+
config.CsrfProtectionHeaders = ["MyCustomHeader"];
594+
});
595+
```
596+
572597
### Response compression
573598

574599
ASP.NET Core supports response compression independently of GraphQL, with brotli and gzip
@@ -660,6 +685,8 @@ methods allowing for different options for each configured endpoint.
660685
| `AuthorizationRequired` | Requires `HttpContext.User` to represent an authenticated user. | False |
661686
| `AuthorizedPolicy` | If set, requires `HttpContext.User` to pass authorization of the specified policy. | |
662687
| `AuthorizedRoles` | If set, requires `HttpContext.User` to be a member of any one of a list of roles. | |
688+
| `CsrfProtectionEnabled` | Enables cross-site request forgery (CSRF) protection for both GET and POST requests. | True |
689+
| `CsrfProtectionHeaders` | Sets the headers used for CSRF protection when necessary. | `GraphQL-Require-Preflight` |
663690
| `DefaultResponseContentType` | Sets the default response content type used within responses. | `application/graphql-response+json; charset=utf-8` |
664691
| `EnableBatchedRequests` | Enables handling of batched GraphQL requests for POST requests when formatted as JSON. | True |
665692
| `ExecuteBatchedRequestsInParallel` | Enables parallel execution of batched GraphQL requests. | True |

docs/migration/migration8.md

+7
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44

55
- When using `FormFileGraphType` with type-first schemas, you may specify the allowed media
66
types for the file by using the new `[MediaType]` attribute on the argument or input object field.
7+
- Cross-site request forgery (CSRF) protection has been added for both GET and POST requests,
8+
enabled by default.
79

810
## Breaking changes
911

1012
- The validation rules' signatures have changed slightly due to the underlying changes to the
1113
GraphQL.NET library. Please see the GraphQL.NET v8 migration document for more information.
1214
- The obsolete (v6 and prior) authorization validation rule has been removed. See the v7 migration
1315
document for more information on how to migrate to the v7/v8 authorization validation rule.
16+
- Cross-site request forgery (CSRF) protection has been enabled for all requests by default.
17+
This will require that the `GraphQL-Require-Preflight` header be sent with all GET requests and
18+
all form-POST requests. To disable this feature, set the `CsrfProtectionEnabled` property on the
19+
`GraphQLMiddlewareOptions` class to `false`. You may also configure the headers list by modifying
20+
the `CsrfProtectionHeaders` property on the same class. See the readme for more details.
1421

1522
## Other changes
1623

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace GraphQL.Server.Transports.AspNetCore.Errors;
2+
3+
/// <summary>
4+
/// Represents an error indicating that the request may not have triggered a CORS preflight request.
5+
/// </summary>
6+
public class CsrfProtectionError : RequestError
7+
{
8+
/// <inheritdoc cref="CsrfProtectionError"/>
9+
public CsrfProtectionError(IEnumerable<string> headersRequired) : base($"This request requires a non-empty header from the following list: {FormatHeaders(headersRequired)}.") { }
10+
11+
/// <inheritdoc cref="CsrfProtectionError"/>
12+
public CsrfProtectionError(IEnumerable<string> headersRequired, Exception innerException) : base($"This request requires a non-empty header from the following list: {FormatHeaders(headersRequired)}. {innerException.Message}") { }
13+
14+
private static string FormatHeaders(IEnumerable<string> headersRequired)
15+
=> string.Join(", ", headersRequired.Select(x => $"'{x}'"));
16+
}

src/Transports.AspNetCore/GraphQLHttpMiddleware.cs

+51-4
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ protected virtual async Task InvokeAsync(HttpContext context, RequestDelegate ne
142142
return;
143143
}
144144

145+
// Perform CSRF protection if necessary
146+
if (await HandleCsrfProtectionAsync(context, next))
147+
return;
148+
145149
// Authenticate request if necessary
146150
if (await HandleAuthorizeAsync(context, next))
147151
return;
@@ -484,7 +488,36 @@ static void ApplyFileToRequest(IFormFile file, string target, GraphQLRequest? re
484488
}
485489

486490
/// <summary>
487-
/// Perform authentication, if required, and return <see langword="true"/> if the
491+
/// Performs CSRF protection, if required, and returns <see langword="true"/> if the
492+
/// request was handled (typically by returning an error message). If <see langword="false"/>
493+
/// is returned, the request is processed normally.
494+
/// </summary>
495+
protected virtual async ValueTask<bool> HandleCsrfProtectionAsync(HttpContext context, RequestDelegate next)
496+
{
497+
if (!_options.CsrfProtectionEnabled)
498+
return false;
499+
if (context.Request.Headers.TryGetValue("Content-Type", out var contentTypes) && contentTypes.Count > 0 && contentTypes[0] != null)
500+
{
501+
var contentType = contentTypes[0]!;
502+
if (contentType.IndexOf(';') > 0)
503+
{
504+
contentType = contentType.Substring(0, contentType.IndexOf(';'));
505+
}
506+
contentType = contentType.Trim().ToLowerInvariant();
507+
if (!(contentType == "text/plain" || contentType == "application/x-www-form-urlencoded" || contentType == "multipart/form-data"))
508+
return false;
509+
}
510+
foreach (var header in _options.CsrfProtectionHeaders)
511+
{
512+
if (context.Request.Headers.TryGetValue(header, out var values) && values.Count > 0 && values[0]?.Length > 0)
513+
return false;
514+
}
515+
await HandleCsrfProtectionErrorAsync(context, next);
516+
return true;
517+
}
518+
519+
/// <summary>
520+
/// Perform authentication, if required, and returns <see langword="true"/> if the
488521
/// request was handled (typically by returning an error message). If <see langword="false"/>
489522
/// is returned, the request is processed normally.
490523
/// </summary>
@@ -1034,21 +1067,29 @@ protected virtual Task HandleNotAuthorizedPolicyAsync(HttpContext context, Reque
10341067
/// </summary>
10351068
protected virtual async ValueTask<bool> HandleDeserializationErrorAsync(HttpContext context, RequestDelegate next, Exception exception)
10361069
{
1037-
await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, new JsonInvalidError(exception));
1070+
await WriteErrorResponseAsync(context, new JsonInvalidError(exception));
10381071
return true;
10391072
}
10401073

1074+
/// <summary>
1075+
/// Writes a '.' message to the output.
1076+
/// </summary>
1077+
protected virtual async Task HandleCsrfProtectionErrorAsync(HttpContext context, RequestDelegate next)
1078+
{
1079+
await WriteErrorResponseAsync(context, new CsrfProtectionError(_options.CsrfProtectionHeaders));
1080+
}
1081+
10411082
/// <summary>
10421083
/// Writes a '400 Batched requests are not supported.' message to the output.
10431084
/// </summary>
10441085
protected virtual Task HandleBatchedRequestsNotSupportedAsync(HttpContext context, RequestDelegate next)
1045-
=> WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, new BatchedRequestsNotSupportedError());
1086+
=> WriteErrorResponseAsync(context, new BatchedRequestsNotSupportedError());
10461087

10471088
/// <summary>
10481089
/// Writes a '400 Invalid requested WebSocket sub-protocol(s).' message to the output.
10491090
/// </summary>
10501091
protected virtual Task HandleWebSocketSubProtocolNotSupportedAsync(HttpContext context, RequestDelegate next)
1051-
=> WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, new WebSocketSubProtocolNotSupportedError(context.WebSockets.WebSocketRequestedProtocols));
1092+
=> WriteErrorResponseAsync(context, new WebSocketSubProtocolNotSupportedError(context.WebSockets.WebSocketRequestedProtocols));
10521093

10531094
/// <summary>
10541095
/// Writes a '415 Invalid Content-Type header: could not be parsed.' message to the output.
@@ -1079,6 +1120,12 @@ protected virtual Task HandleInvalidHttpMethodErrorAsync(HttpContext context, Re
10791120
return next(context);
10801121
}
10811122

1123+
/// <summary>
1124+
/// Writes the specified error as a JSON-formatted GraphQL response.
1125+
/// </summary>
1126+
protected virtual Task WriteErrorResponseAsync(HttpContext context, ExecutionError executionError)
1127+
=> WriteErrorResponseAsync(context, executionError is IHasPreferredStatusCode withCode ? withCode.PreferredStatusCode : HttpStatusCode.BadRequest, executionError);
1128+
10821129
/// <summary>
10831130
/// Writes the specified error message as a JSON-formatted GraphQL response, with the specified HTTP status code.
10841131
/// </summary>

src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs

+16
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions
7070
/// </remarks>
7171
public bool ReadFormOnPost { get; set; } = true; // TODO: change to false for v9
7272

73+
/// <summary>
74+
/// Enables cross-site request forgery (CSRF) protection for both GET and POST requests.
75+
/// Requires a non-empty header from the <see cref="CsrfProtectionHeaders"/> list to be
76+
/// present, or a POST request with a Content-Type header that is not <c>text/plain</c>,
77+
/// <c>application/x-www-form-urlencoded</c>, or <c>multipart/form-data</c>.
78+
/// </summary>
79+
public bool CsrfProtectionEnabled { get; set; } = true;
80+
81+
/// <summary>
82+
/// When <see cref="CsrfProtectionEnabled"/> is enabled, requests require a non-empty
83+
/// header from this list or a POST request with a Content-Type header that is not
84+
/// <c>text/plain</c>, <c>application/x-www-form-urlencoded</c>, or <c>multipart/form-data</c>.
85+
/// Defaults to <c>GraphQL-Require-Preflight</c>.
86+
/// </summary>
87+
public List<string> CsrfProtectionHeaders { get; set; } = ["GraphQL-Require-Preflight"]; // see https://github.com/graphql/graphql-over-http/pull/303
88+
7389
/// <summary>
7490
/// Enables reading variables from the query string.
7591
/// Variables are interpreted as JSON and deserialized before being

tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt

+10
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ namespace GraphQL.Server.Transports.AspNetCore
9999
protected virtual System.Threading.Tasks.Task HandleBatchRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, System.Collections.Generic.IList<GraphQL.Transport.GraphQLRequest?> gqlRequests) { }
100100
protected virtual System.Threading.Tasks.Task HandleBatchedRequestsNotSupportedAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
101101
protected virtual System.Threading.Tasks.Task HandleContentTypeCouldNotBeParsedErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
102+
protected virtual System.Threading.Tasks.ValueTask<bool> HandleCsrfProtectionAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
103+
protected virtual System.Threading.Tasks.Task HandleCsrfProtectionErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
102104
protected virtual System.Threading.Tasks.ValueTask<bool> HandleDeserializationErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, System.Exception exception) { }
103105
protected virtual System.Threading.Tasks.Task HandleInvalidContentTypeErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
104106
protected virtual System.Threading.Tasks.Task HandleInvalidHttpMethodErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
@@ -115,6 +117,7 @@ namespace GraphQL.Server.Transports.AspNetCore
115117
"BatchRequest"})]
116118
protected virtual System.Threading.Tasks.Task<System.ValueTuple<GraphQL.Transport.GraphQLRequest?, System.Collections.Generic.IList<GraphQL.Transport.GraphQLRequest?>?>?> ReadPostContentAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, string? mediaType, System.Text.Encoding? sourceEncoding) { }
117119
protected virtual string SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { }
120+
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionError executionError) { }
118121
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, GraphQL.ExecutionError executionError) { }
119122
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, string errorMessage) { }
120123
protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync<TResult>(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, TResult result) { }
@@ -126,6 +129,8 @@ namespace GraphQL.Server.Transports.AspNetCore
126129
public bool AuthorizationRequired { get; set; }
127130
public string? AuthorizedPolicy { get; set; }
128131
public System.Collections.Generic.List<string> AuthorizedRoles { get; set; }
132+
public bool CsrfProtectionEnabled { get; set; }
133+
public System.Collections.Generic.List<string> CsrfProtectionHeaders { get; set; }
129134
public Microsoft.Net.Http.Headers.MediaTypeHeaderValue DefaultResponseContentType { get; set; }
130135
public bool EnableBatchedRequests { get; set; }
131136
public bool ExecuteBatchedRequestsInParallel { get; set; }
@@ -199,6 +204,11 @@ namespace GraphQL.Server.Transports.AspNetCore.Errors
199204
{
200205
public BatchedRequestsNotSupportedError() { }
201206
}
207+
public class CsrfProtectionError : GraphQL.Execution.RequestError
208+
{
209+
public CsrfProtectionError(System.Collections.Generic.IEnumerable<string> headersRequired) { }
210+
public CsrfProtectionError(System.Collections.Generic.IEnumerable<string> headersRequired, System.Exception innerException) { }
211+
}
202212
public class FileCountExceededError : GraphQL.Execution.RequestError, GraphQL.Server.Transports.AspNetCore.Errors.IHasPreferredStatusCode
203213
{
204214
public FileCountExceededError() { }

tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt

+10
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ namespace GraphQL.Server.Transports.AspNetCore
106106
protected virtual System.Threading.Tasks.Task HandleBatchRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, System.Collections.Generic.IList<GraphQL.Transport.GraphQLRequest?> gqlRequests) { }
107107
protected virtual System.Threading.Tasks.Task HandleBatchedRequestsNotSupportedAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
108108
protected virtual System.Threading.Tasks.Task HandleContentTypeCouldNotBeParsedErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
109+
protected virtual System.Threading.Tasks.ValueTask<bool> HandleCsrfProtectionAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
110+
protected virtual System.Threading.Tasks.Task HandleCsrfProtectionErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
109111
protected virtual System.Threading.Tasks.ValueTask<bool> HandleDeserializationErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, System.Exception exception) { }
110112
protected virtual System.Threading.Tasks.Task HandleInvalidContentTypeErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
111113
protected virtual System.Threading.Tasks.Task HandleInvalidHttpMethodErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
@@ -122,6 +124,7 @@ namespace GraphQL.Server.Transports.AspNetCore
122124
"BatchRequest"})]
123125
protected virtual System.Threading.Tasks.Task<System.ValueTuple<GraphQL.Transport.GraphQLRequest?, System.Collections.Generic.IList<GraphQL.Transport.GraphQLRequest?>?>?> ReadPostContentAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, string? mediaType, System.Text.Encoding? sourceEncoding) { }
124126
protected virtual string SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { }
127+
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionError executionError) { }
125128
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, GraphQL.ExecutionError executionError) { }
126129
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, string errorMessage) { }
127130
protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync<TResult>(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, TResult result) { }
@@ -133,6 +136,8 @@ namespace GraphQL.Server.Transports.AspNetCore
133136
public bool AuthorizationRequired { get; set; }
134137
public string? AuthorizedPolicy { get; set; }
135138
public System.Collections.Generic.List<string> AuthorizedRoles { get; set; }
139+
public bool CsrfProtectionEnabled { get; set; }
140+
public System.Collections.Generic.List<string> CsrfProtectionHeaders { get; set; }
136141
public Microsoft.Net.Http.Headers.MediaTypeHeaderValue DefaultResponseContentType { get; set; }
137142
public bool EnableBatchedRequests { get; set; }
138143
public bool ExecuteBatchedRequestsInParallel { get; set; }
@@ -217,6 +222,11 @@ namespace GraphQL.Server.Transports.AspNetCore.Errors
217222
{
218223
public BatchedRequestsNotSupportedError() { }
219224
}
225+
public class CsrfProtectionError : GraphQL.Execution.RequestError
226+
{
227+
public CsrfProtectionError(System.Collections.Generic.IEnumerable<string> headersRequired) { }
228+
public CsrfProtectionError(System.Collections.Generic.IEnumerable<string> headersRequired, System.Exception innerException) { }
229+
}
220230
public class FileCountExceededError : GraphQL.Execution.RequestError, GraphQL.Server.Transports.AspNetCore.Errors.IHasPreferredStatusCode
221231
{
222232
public FileCountExceededError() { }

0 commit comments

Comments
 (0)