From 7a2ed808c4ce881362ee3cf5aad52199fdfba182 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 25 May 2026 13:18:23 +0300 Subject: [PATCH] fix: replace cookie-based Discord OAuth CSRF with server-side state store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace __DiscordOAuthState cookie (blocked by third-party cookie policies) with in-memory DiscordOAuthStateStore singleton - State is created server-side and validated on callback, eliminating cross-site cookie transmission issues entirely - Removed CryptographicOperations dependency from Program.cs Bump version → 2.8.1 Co-Authored-By: Claude Opus 4.7 --- src/GmRelay.Web/Program.cs | 25 ++++----------- .../Services/DiscordOAuthStateStore.cs | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 19 deletions(-) create mode 100644 src/GmRelay.Web/Services/DiscordOAuthStateStore.cs diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 139527f..6619de8 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -39,6 +39,7 @@ builder.AddNpgsqlDataSource("gmrelaydb"); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("Discord")); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -185,16 +186,9 @@ app.MapPost("/auth/logout", async (HttpContext context) => }); // Discord OAuth endpoints -app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth) => +app.MapGet("/auth/discord", (DiscordAuthService discordAuth, DiscordOAuthStateStore stateStore) => { - var state = Guid.NewGuid().ToString("N"); - context.Response.Cookies.Append("__DiscordOAuthState", state, new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.None, - MaxAge = TimeSpan.FromMinutes(5) - }); + var state = stateStore.CreateState(); var url = discordAuth.BuildAuthorizeUrl(state); return Results.Redirect(url); }); @@ -202,23 +196,16 @@ app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth app.MapGet("/auth/discord/callback", async ( HttpContext context, DiscordAuthService discordAuth, - ISessionStore sessionStore, - ILogger logger) => + DiscordOAuthStateStore stateStore, + ISessionStore sessionStore) => { var code = context.Request.Query["code"].ToString(); var state = context.Request.Query["state"].ToString(); - var storedState = context.Request.Cookies["__DiscordOAuthState"]; - - context.Response.Cookies.Delete("__DiscordOAuthState"); if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state) || - !CryptographicOperations.FixedTimeEquals( - System.Text.Encoding.UTF8.GetBytes(state), - System.Text.Encoding.UTF8.GetBytes(storedState ?? string.Empty))) + !stateStore.ValidateAndRemove(state)) { - logger.LogWarning("Discord OAuth CSRF validation failed. code_present={CodePresent}, state_present={StatePresent}, stored_state_present={StoredStatePresent}", - !string.IsNullOrWhiteSpace(code), !string.IsNullOrWhiteSpace(state), !string.IsNullOrWhiteSpace(storedState)); return Results.Redirect("/login?error=auth_failed"); } diff --git a/src/GmRelay.Web/Services/DiscordOAuthStateStore.cs b/src/GmRelay.Web/Services/DiscordOAuthStateStore.cs new file mode 100644 index 0000000..1c9c65c --- /dev/null +++ b/src/GmRelay.Web/Services/DiscordOAuthStateStore.cs @@ -0,0 +1,32 @@ +namespace GmRelay.Web.Services; + +public sealed class DiscordOAuthStateStore(ILogger logger) +{ + private readonly System.Collections.Concurrent.ConcurrentDictionary _states = new(); + + public string CreateState() + { + var state = Guid.NewGuid().ToString("N"); + _states[state] = DateTime.UtcNow.AddMinutes(5); + logger.LogDebug("Discord OAuth state created: {State}", state); + return state; + } + + public bool ValidateAndRemove(string state) + { + if (!_states.TryRemove(state, out var expiresAt)) + { + logger.LogWarning("Discord OAuth state not found or already used: {State}", state); + return false; + } + + if (DateTime.UtcNow > expiresAt) + { + logger.LogWarning("Discord OAuth state expired: {State}", state); + return false; + } + + logger.LogDebug("Discord OAuth state validated: {State}", state); + return true; + } +}