fix(admin): resolve Medium code-review finding (Admin-006)

Emit <AntiforgeryToken /> in the MainLayout sign-out form and remove
.DisableAntiforgery() from the /auth/logout endpoint so UseAntiforgery()
validates the token. A tokenless POST now returns 400, preventing CSRF-logout.
Regression-guarded by AuthEndpointsTests.Logout_without_antiforgery_token_is_rejected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 07:26:34 -04:00
parent 55c2a5a209
commit af454c6af6
4 changed files with 21 additions and 11 deletions

View File

@@ -45,6 +45,7 @@
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
</div>
<form method="post" action="/auth/logout">
<AntiforgeryToken />
<button class="rail-btn" type="submit">Sign out</button>
</form>
</Authorized>

View File

@@ -21,15 +21,21 @@ public static class AuthEndpoints
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
{
// Anonymous: the login POST is the only way in, so the fallback authorization policy
// (Admin-001) must not gate it. DisableAntiforgery — the static form posts with
// data-enhance="false" and renders no token; the cookie scheme + LDAP bind are the
// gate here. (Admin-006 covers emitting a token for a hardened build.)
// (Admin-001) must not gate it. DisableAntiforgery — the static Login.razor form posts
// with data-enhance="false" and renders no antiforgery token; the cookie scheme + LDAP
// bind are the authentication gate here. Login is not a state-changing operation that
// CSRF can abuse (the attacker cannot know the resulting cookie), so tokenless-login is
// the standard Web pattern.
endpoints.MapPost("/auth/login", (Delegate)LoginAsync)
.AllowAnonymous()
.DisableAntiforgery();
endpoints.MapPost("/auth/logout", (Delegate)LogoutAsync)
.DisableAntiforgery();
// Admin-006: the logout form in MainLayout.razor emits <AntiforgeryToken /> so the
// middleware validates the token. This prevents a cross-site logout (CSRF-logout) where
// an attacker tricks the operator's browser into posting to /auth/logout. The endpoint
// intentionally does NOT call .DisableAntiforgery() — the token must be present and
// valid; the middleware rejects forged or missing tokens with 400.
endpoints.MapPost("/auth/logout", (Delegate)LogoutAsync);
return endpoints;
}