Skip to content

Commit 94c95be

Browse files
authored
Implement ASP.NET Core antiforgery protection with cryptographic double-submit pattern (#709)
Implement antiforgery protection using ASP.NET Core’s built-in cryptographic double-submit pattern to prevent CSRF (Cross-Site Request Forgery) attacks. This mechanism generates a secure, `HttpOnly` cookie containing a server-side token and requires an `x-xsrf-token` Header in non-GET requests. The token includes the user's `ClaimUid` and is validated on every request that mutates data (`POST`, `PUT`, `DELETE`, etc.) A new antiforgery middleware has been introduced to enforce this validation across the application, except for explicitly excluded endpoints like the Application Insights tracking API. The `upload-avatar` endpoint has been updated to remove the previous antiforgery bypass. Additional security improvements: - Antiforgery cookies now use the `__Host_` prefix. This special prefix ensures that modern browsers enforce the cookie to be sent only over HTTPS and only to the same origin, providing an additional layer of security. - Requests failing antiforgery validation return a `400 Bad Request` response with a traceable error message. To simplify automated testing, antiforgery validation can be disabled using an environment variable, allowing tests to run without requiring a valid token. Setting up the cryptographic double-submit pattern in tests would add unnecessary complexity. ### Downstream projects The `refresh_token` and `access_token` cookie names have been changed to `__Host_refresh_token` and `__Host_access_token`. As a result: - All logged-in users will be required to reauthenticate after this release. - The previous `refresh_token` cookie will remain orphaned in the browser but will no longer be used. Add the following to `EndpointBaseTest.cs` to bypass antiforgery validation in test environments: ```csharp // Set the environment variable to bypass antiforgery validation on the server. ASP.NET uses a cryptographic // double-submit pattern that encrypts the user's ClaimUid in the token, which is complex to replicate in tests Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); ``` ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents f21760a + 6b986de commit 94c95be

File tree

10 files changed

+139
-11
lines changed

10 files changed

+139
-11
lines changed

application/account-management/Api/Endpoints/UserEndpoints.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
4545

4646
group.MapPost("/me/update-avatar", async Task<ApiResult> (IFormFile file, IMediator mediator)
4747
=> await mediator.Send(new UpdateAvatarCommand(file.OpenReadStream(), file.ContentType))
48-
).DisableAntiforgery(); // Disable anti-forgery until we implement it
48+
);
4949

5050
group.MapDelete("/me/remove-avatar", async Task<ApiResult> (IMediator mediator)
5151
=> await mediator.Send(new RemoveAvatarCommand())

application/account-management/Tests/EndpointBaseTest.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ protected EndpointBaseTest()
105105
var memberAccessToken = AccessTokenGenerator.Generate(DatabaseSeeder.Tenant1Member.Adapt<UserInfo>());
106106
AuthenticatedMemberHttpClient = _webApplicationFactory.CreateClient();
107107
AuthenticatedMemberHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", memberAccessToken);
108+
109+
// Set the environment variable to bypass antiforgery validation on the server. ASP.NET uses a cryptographic
110+
// double-submit pattern that encrypts the user's ClaimUid in the token, which is complex to replicate in tests
111+
Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true");
108112
}
109113

110114
protected SqliteConnection Connection { get; }

application/account-management/WebApp/shared/lib/api/client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ const apiClient = createFetchClient<paths>({
1111
});
1212
apiClient.use(createAuthenticationMiddleware());
1313

14+
// Add middleware to include antiforgery token only for non-GET requests
15+
apiClient.use({
16+
onRequest: (params) => {
17+
const request = params.request;
18+
if (request instanceof Request && request.method !== "GET") {
19+
request.headers.set("x-xsrf-token", getAntiforgeryToken());
20+
}
21+
return request;
22+
}
23+
});
24+
25+
// Get the antiforgery token from the meta tag
26+
const getAntiforgeryToken = () => {
27+
const metaTag = document.querySelector('meta[name="antiforgeryToken"]');
28+
return metaTag?.getAttribute("content") ?? "";
29+
};
30+
1431
export const api = createClient(apiClient);
1532

1633
export type Schemas = components["schemas"];

application/back-office/Tests/EndpointBaseTest.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ protected EndpointBaseTest()
105105
var memberAccessToken = AccessTokenGenerator.Generate(DatabaseSeeder.Tenant1Member.Adapt<UserInfo>());
106106
AuthenticatedMemberHttpClient = _webApplicationFactory.CreateClient();
107107
AuthenticatedMemberHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", memberAccessToken);
108+
109+
// Set the environment variable to bypass antiforgery validation on the server. ASP.NET uses a cryptographic
110+
// double-submit pattern that encrypts the user's ClaimUid in the token, which is complex to replicate in tests
111+
Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true");
108112
}
109113

110114
protected SqliteConnection Connection { get; }
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Microsoft.AspNetCore.Antiforgery;
2+
using Microsoft.AspNetCore.Http;
3+
4+
namespace PlatformPlatform.SharedKernel.Antiforgery;
5+
6+
public sealed class AntiforgeryMiddleware(IAntiforgery antiforgery, ILogger<AntiforgeryMiddleware> logger) : IMiddleware
7+
{
8+
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
9+
{
10+
if (context.GetEndpoint()?.Metadata.GetMetadata<IAntiforgeryMetadata>()?.RequiresValidation == false)
11+
{
12+
// Skip validation for endpoints with disabled antiforgery
13+
await next(context);
14+
return;
15+
}
16+
17+
if (bool.TryParse(Environment.GetEnvironmentVariable("BypassAntiforgeryValidation"), out _))
18+
{
19+
logger.LogDebug("Bypassing antiforgery validation due to environment variable setting");
20+
await next(context);
21+
return;
22+
}
23+
24+
if (!await antiforgery.IsRequestValidAsync(context))
25+
{
26+
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
27+
28+
logger.LogWarning(
29+
"Antiforgery validation failed for {Method} {Path}. TraceId: {TraceId}",
30+
context.Request.Method,
31+
context.Request.Path,
32+
traceId
33+
);
34+
35+
await Results.Problem(
36+
title: "Invalid Antiforgery Token",
37+
detail: "Antiforgery validation failed for request.",
38+
statusCode: StatusCodes.Status400BadRequest,
39+
extensions: new Dictionary<string, object?> { { "traceId", traceId } }
40+
).ExecuteAsync(context);
41+
42+
return;
43+
}
44+
45+
await next(context);
46+
}
47+
}

application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ namespace PlatformPlatform.SharedKernel.Authentication;
22

33
public static class AuthenticationTokenHttpKeys
44
{
5-
public const string RefreshTokenCookieName = "refresh_token";
6-
7-
public const string AccessTokenCookieName = "access_token";
8-
95
public const string RefreshTokenHttpHeaderKey = "x-refresh-token";
106

117
public const string AccessTokenHttpHeaderKey = "x-access-token";
128

9+
public const string AntiforgeryTokenHttpHeaderKey = "x-xsrf-token";
10+
1311
public const string RefreshAuthenticationTokensHeaderKey = "x-refresh-authentication-tokens-required";
12+
13+
// __Host prefix ensures the cookie is sent only to the host, requires Secure, HTTPS, Path=/ and no Domain specified
14+
public const string RefreshTokenCookieName = "__Host_Refresh_Token";
15+
16+
public const string AccessTokenCookieName = "__Host_Access_Token";
17+
18+
public const string AntiforgeryTokenCookieName = "__Host_Xsrf_Token";
1419
}

application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using Microsoft.Extensions.DependencyInjection;
66
using Microsoft.Extensions.Hosting;
77
using NJsonSchema.Generation;
8+
using PlatformPlatform.SharedKernel.Antiforgery;
9+
using PlatformPlatform.SharedKernel.Authentication;
810
using PlatformPlatform.SharedKernel.Endpoints;
911
using PlatformPlatform.SharedKernel.ExecutionContext;
1012
using PlatformPlatform.SharedKernel.Middleware;
@@ -54,11 +56,18 @@ public static IServiceCollection AddApiServices(this IServiceCollection services
5456
.AddExceptionHandler<GlobalExceptionHandler>()
5557
.AddTransient<TelemetryContextMiddleware>()
5658
.AddTransient<ModelBindingExceptionHandlerMiddleware>()
59+
.AddTransient<AntiforgeryMiddleware>()
5760
.AddProblemDetails()
5861
.AddEndpointsApiExplorer()
5962
.AddApiEndpoints(assemblies)
6063
.AddOpenApiConfiguration(assemblies)
6164
.AddAuthConfiguration()
65+
.AddAntiforgery(options =>
66+
{
67+
options.Cookie.Name = AuthenticationTokenHttpKeys.AntiforgeryTokenCookieName;
68+
options.HeaderName = AuthenticationTokenHttpKeys.AntiforgeryTokenHttpHeaderKey;
69+
}
70+
)
6271
.AddHttpForwardHeaders();
6372
}
6473

@@ -86,6 +95,8 @@ public static WebApplication UseApiServices(this WebApplication app)
8695
.UseForwardedHeaders()
8796
.UseAuthentication() // Must be above TelemetryContextMiddleware to ensure authentication happens first
8897
.UseAuthorization()
98+
.UseAntiforgery()
99+
.UseMiddleware<AntiforgeryMiddleware>()
89100
.UseMiddleware<TelemetryContextMiddleware>() // It must be above ModelBindingExceptionHandlerMiddleware to ensure that model binding problems are annotated correctly
90101
.UseMiddleware<ModelBindingExceptionHandlerMiddleware>() // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto. Should run before other middleware
91102
.UseOpenApi(options => options.Path = "/openapi/v1.json"); // Adds the OpenAPI generator that uses the ASP. NET Core API Explorer

application/shared-kernel/SharedKernel/Endpoints/TrackEndpoints.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class TrackEndpoints : IEndpoints
2020
// </summary>
2121
public void MapEndpoints(IEndpointRouteBuilder routes)
2222
{
23-
routes.MapPost("/api/track", Track).AllowAnonymous();
23+
routes.MapPost("/api/track", Track).AllowAnonymous().DisableAntiforgery();
2424
}
2525

2626
[OpenApiIgnore]

application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text.Encodings.Web;
22
using System.Text.Json;
3+
using Microsoft.AspNetCore.Antiforgery;
34
using Microsoft.AspNetCore.Builder;
45
using Microsoft.AspNetCore.Hosting;
56
using Microsoft.AspNetCore.Http;
@@ -38,7 +39,12 @@ public static IApplicationBuilder UseSinglePageAppFallback(this WebApplication a
3839
}
3940
);
4041

41-
app.MapFallback((HttpContext context, IExecutionContext executionContext, SinglePageAppConfiguration singlePageAppConfiguration) =>
42+
app.MapFallback((
43+
HttpContext context,
44+
IExecutionContext executionContext,
45+
IAntiforgery antiforgery,
46+
SinglePageAppConfiguration singlePageAppConfiguration
47+
) =>
4248
{
4349
if (context.Request.Path.Value?.Contains("/api/", StringComparison.OrdinalIgnoreCase) == true ||
4450
context.Request.Path.Value?.Contains("/internal-api/", StringComparison.OrdinalIgnoreCase) == true)
@@ -50,7 +56,10 @@ public static IApplicationBuilder UseSinglePageAppFallback(this WebApplication a
5056

5157
SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "text/html; charset=utf-8");
5258

53-
var html = GetHtmlWithEnvironment(singlePageAppConfiguration, executionContext.UserInfo);
59+
var antiforgeryHttpHeaderToken = GenerateAntiforgeryTokens(antiforgery, context);
60+
61+
var html = GetHtmlWithEnvironment(singlePageAppConfiguration, executionContext.UserInfo, antiforgeryHttpHeaderToken);
62+
5463
return context.Response.WriteAsync(html);
5564
}
5665
);
@@ -62,7 +71,11 @@ public static IApplicationBuilder UseSinglePageAppFallback(this WebApplication a
6271
.UseRequestLocalization(SinglePageAppConfiguration.SupportedLocalizations);
6372
}
6473

65-
private static void SetResponseHttpHeaders(SinglePageAppConfiguration singlePageAppConfiguration, IHeaderDictionary responseHeaders, StringValues contentType)
74+
private static void SetResponseHttpHeaders(
75+
SinglePageAppConfiguration singlePageAppConfiguration,
76+
IHeaderDictionary responseHeaders,
77+
StringValues contentType
78+
)
6679
{
6780
// No cache headers
6881
responseHeaders.Append("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -82,7 +95,32 @@ private static void SetResponseHttpHeaders(SinglePageAppConfiguration singlePage
8295
responseHeaders.Append("Content-Type", contentType);
8396
}
8497

85-
private static string GetHtmlWithEnvironment(SinglePageAppConfiguration singlePageAppConfiguration, UserInfo userInfo)
98+
private static string GenerateAntiforgeryTokens(IAntiforgery antiforgery, HttpContext context)
99+
{
100+
// ASP.NET Core antiforgery system uses a cryptographic double-submit pattern with two tokens:
101+
// - A secret cookie token that only the server can read (session-based)
102+
// - A public request token that the SPA sends as a header for state-changing requests like POST/PUT/DELETE
103+
104+
var antiforgeryTokenSet = antiforgery.GetAndStoreTokens(context);
105+
106+
if (antiforgeryTokenSet.CookieToken is not null)
107+
{
108+
// A new antiforgery cookie is only generated once, as it must remain constant across browser tabs to avoid validation failures
109+
context.Response.Cookies.Append(
110+
AuthenticationTokenHttpKeys.AntiforgeryTokenCookieName,
111+
antiforgeryTokenSet.CookieToken!,
112+
new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Path = "/" }
113+
);
114+
}
115+
116+
return antiforgeryTokenSet.RequestToken!;
117+
}
118+
119+
private static string GetHtmlWithEnvironment(
120+
SinglePageAppConfiguration singlePageAppConfiguration,
121+
UserInfo userInfo,
122+
string antiforgeryHttpHeaderToken
123+
)
86124
{
87125
var userInfoEncoded = JsonSerializer.Serialize(userInfo, SinglePageAppConfiguration.JsonHtmlEncodingOptions);
88126

@@ -92,6 +130,7 @@ private static string GetHtmlWithEnvironment(SinglePageAppConfiguration singlePa
92130
html = html.Replace("%ENCODED_RUNTIME_ENV%", singlePageAppConfiguration.StaticRuntimeEnvironmentEscaped);
93131
html = html.Replace("%ENCODED_USER_INFO_ENV%", userInfoEscaped);
94132
html = html.Replace("%LOCALE%", userInfo.Locale);
133+
html = html.Replace("%ANTIFORGERY_TOKEN%", antiforgeryHttpHeaderToken);
95134

96135
foreach (var variable in singlePageAppConfiguration.StaticRuntimeEnvironment)
97136
{

application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export function RunTimeEnvironmentPlugin<E extends {} = Record<string, unknown>>
4848
// Define the runtime environment variables as part of the template
4949
meta: {
5050
runtimeEnv: "%ENCODED_RUNTIME_ENV%",
51-
userInfoEnv: "%ENCODED_USER_INFO_ENV%"
51+
userInfoEnv: "%ENCODED_USER_INFO_ENV%",
52+
antiforgeryToken: "%ANTIFORGERY_TOKEN%"
5253
},
5354
// Add the CDN URL placeholder to the script and link tags in the template file
5455
tags(tags) {

0 commit comments

Comments
 (0)