diff --git a/API.md b/API.md index 6ac4cc0..ff733bc 100644 --- a/API.md +++ b/API.md @@ -53,6 +53,9 @@ POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, vot Owner restrictions: owner role/admin status cannot be changed, and owner account cannot be deleted. ## Security Defaults +- Mutating authenticated API requests (`POST`/`PUT`/`DELETE`/`PATCH`) enforce same-origin CSRF checks via `Origin`/`Referer`; cross-origin or missing-origin authenticated writes are rejected with `400`. - Security headers are set on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`). +- CSP is tightened to disallow inline styles and insecure image origins (`img-src` excludes `http:`). - In production, HTTPS redirection and HSTS are enabled. - Screenshot URL validation rejects private/reserved address ranges and pins outbound connections to validated public IPs. +- Password hashing is versioned; legacy hashes are transparently upgraded on successful login/admin password confirmation. diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 0e7bda9..a43c4f2 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -21,6 +21,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op builder.HasIndex(p => p.NormalizedUsername).IsUnique(); builder.Property(p => p.PasswordHash).IsRequired(); builder.Property(p => p.PasswordSalt).IsRequired(); + builder.Property(p => p.PasswordHashVersion).HasDefaultValue(1); builder.Property(p => p.IsAdmin).HasDefaultValue(false); builder.Property(p => p.IsOwner).HasDefaultValue(false); builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique(); diff --git a/Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs b/Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs new file mode 100644 index 0000000..08ead0b --- /dev/null +++ b/Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs @@ -0,0 +1,260 @@ +// +using System; +using GameList.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GameList.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260218194640_AddPasswordHashVersion")] + partial class AddPasswordHashVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("GameList.Domain.AppState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ResultsOpen") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + ResultsOpen = false, + UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentPhase") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DisplayName") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("HasJoker") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsOwner") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordHashVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("VotesFinal") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("IsOwner") + .IsUnique() + .HasFilter("IsOwner = 1"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ParentSuggestionId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentSuggestionId"); + + b.HasIndex("PlayerId"); + + b.ToTable("Suggestions"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SuggestionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SuggestionId"); + + b.HasIndex("PlayerId", "SuggestionId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion") + .WithMany("LinkedSuggestions") + .HasForeignKey("ParentSuggestionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Suggestions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ParentSuggestion"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Votes") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GameList.Domain.Suggestion", "Suggestion") + .WithMany("Votes") + .HasForeignKey("SuggestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Suggestion"); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Navigation("Suggestions"); + + b.Navigation("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Navigation("LinkedSuggestions"); + + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260218194640_AddPasswordHashVersion.cs b/Data/Migrations/20260218194640_AddPasswordHashVersion.cs new file mode 100644 index 0000000..11dc148 --- /dev/null +++ b/Data/Migrations/20260218194640_AddPasswordHashVersion.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class AddPasswordHashVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordHashVersion", + table: "Players", + type: "INTEGER", + nullable: false, + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordHashVersion", + table: "Players"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index eb5115e..3b63974 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -87,6 +87,11 @@ namespace GameList.Data.Migrations .IsRequired() .HasColumnType("BLOB"); + b.Property("PasswordHashVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + b.Property("PasswordSalt") .IsRequired() .HasColumnType("BLOB"); diff --git a/Domain/Player.cs b/Domain/Player.cs index 8378c44..d476629 100644 --- a/Domain/Player.cs +++ b/Domain/Player.cs @@ -17,6 +17,7 @@ public class Player public byte[] PasswordHash { get; set; } = []; public byte[] PasswordSalt { get; set; } = []; + public int PasswordHashVersion { get; set; } = 1; public DateTimeOffset? LastLoginAt { get; set; } public bool IsAdmin { get; set; } diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index 18c6819..4af7673 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -262,18 +262,27 @@ internal sealed class AdminWorkflowService(AppDbContext db) if (string.IsNullOrWhiteSpace(password)) return ServiceError.BadRequest("Admin password is required."); - var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin); + var admin = await db.Players.FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin); if (admin is null) return ServiceError.Unauthorized(); var monitor = ctx.RequestServices.GetRequiredService(); - var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt); + var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt, admin.PasswordHashVersion, out var needsRehash); if (!verified) { monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password"); return ServiceError.BadRequest("Invalid admin password."); } + if (needsRehash) + { + var (upgradedHash, upgradedSalt) = PasswordHasher.HashPassword(password); + admin.PasswordHash = upgradedHash; + admin.PasswordSalt = upgradedSalt; + admin.PasswordHashVersion = PasswordHasher.CurrentVersion; + await db.SaveChangesAsync(); + } + monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername); return null; } diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index b4da1a5..83abf5a 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -60,6 +60,7 @@ public static class AuthEndpoints NormalizedUsername = validated.NormalizedUsername, PasswordHash = hash, PasswordSalt = salt, + PasswordHashVersion = PasswordHasher.CurrentVersion, DisplayName = validated.DisplayName, IsAdmin = isAdmin, IsOwner = isOwner, @@ -104,12 +105,21 @@ public static class AuthEndpoints } var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername); - if (player == null || !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt)) + if (player == null + || !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt, player.PasswordHashVersion, out var needsRehash)) { authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials"); return EndpointHelpers.UnauthorizedError("Invalid username or password."); } + if (needsRehash) + { + var (upgradedHash, upgradedSalt) = PasswordHasher.HashPassword(request.Password ?? string.Empty); + player.PasswordHash = upgradedHash; + player.PasswordSalt = upgradedSalt; + player.PasswordHashVersion = PasswordHasher.CurrentVersion; + } + if (string.IsNullOrWhiteSpace(player.DisplayName)) { player.DisplayName = EndpointHelpers.TrimTo(player.Username, AuthValidator.MaxDisplayNameLength); diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index 9709d8d..6107cc5 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -107,6 +107,37 @@ public class AuthTests }); } + [Fact] + public async Task Login_upgrades_legacy_password_hash_version() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("rehashme"); + + byte[] originalHash = []; + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.SingleAsync(); + var (legacyHash, legacySalt) = PasswordHasher.HashPassword("Pass123!", PasswordHasher.LegacyVersion); + + originalHash = legacyHash.ToArray(); + player.PasswordHash = legacyHash; + player.PasswordSalt = legacySalt; + player.PasswordHashVersion = PasswordHasher.LegacyVersion; + await db.SaveChangesAsync(); + }); + + var login = await client.LoginAsync("rehashme", "Pass123!"); + login.EnsureSuccessStatusCode(); + + await factory.WithDbContextAsync(async db => + { + var player = await db.Players.AsNoTracking().SingleAsync(); + Assert.Equal(PasswordHasher.CurrentVersion, player.PasswordHashVersion); + Assert.False(player.PasswordHash.SequenceEqual(originalHash)); + }); + } + [Fact] public async Task Register_with_admin_key_sets_admin_flag() { diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 5533a73..1f1a935 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -21,7 +21,13 @@ public class HelperTests public void PasswordHasher_roundtrip_and_empty_guard() { var (hash, salt) = PasswordHasher.HashPassword("secret"); - Assert.True(PasswordHasher.Verify("secret", hash, salt)); + Assert.True(PasswordHasher.Verify("secret", hash, salt, PasswordHasher.CurrentVersion, out var currentNeedsRehash)); + Assert.False(currentNeedsRehash); + + var (legacyHash, legacySalt) = PasswordHasher.HashPassword("secret", PasswordHasher.LegacyVersion); + Assert.True(PasswordHasher.Verify("secret", legacyHash, legacySalt, PasswordHasher.LegacyVersion, out var legacyNeedsRehash)); + Assert.True(legacyNeedsRehash); + Assert.False(PasswordHasher.Verify("other", hash, salt)); Assert.Throws(() => PasswordHasher.HashPassword("")); } @@ -264,7 +270,11 @@ public class HelperTests Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single()); Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single()); Assert.Equal("no-referrer", response.Headers.GetValues("Referrer-Policy").Single()); - Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single()); + + var csp = response.Headers.GetValues("Content-Security-Policy").Single(); + Assert.Contains("default-src 'self'", csp); + Assert.DoesNotContain("'unsafe-inline'", csp, StringComparison.Ordinal); + Assert.DoesNotContain("http:", csp, StringComparison.Ordinal); } [Fact] diff --git a/GameList.Tests/MiddlewareTests.cs b/GameList.Tests/MiddlewareTests.cs index 6a2d9b7..f7598fd 100644 --- a/GameList.Tests/MiddlewareTests.cs +++ b/GameList.Tests/MiddlewareTests.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Net.Http.Json; +using System.Text.Json; using GameList.Tests.Support; namespace GameList.Tests; @@ -36,4 +38,49 @@ public class MiddlewareTests var resp = await client.GetAsync("/api/state"); Assert.Equal(HttpStatusCode.OK, resp.StatusCode); } + + [Fact] + public async Task Mutating_authenticated_request_without_origin_is_rejected() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + var register = await client.RegisterAsync("csrfm"); + register.EnsureSuccessStatusCode(); + await client.CreateSuggestionAsync("Seed"); + await client.PostAsJsonAsync("/api/me/phase/next", new { }); + + client.DefaultRequestHeaders.Remove("Origin"); + + var response = await client.PostAsJsonAsync("/api/votes/finalize", new + { + Final = true + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString()); + } + + [Fact] + public async Task Mutating_authenticated_request_with_cross_origin_is_rejected() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + var register = await client.RegisterAsync("csrfx"); + register.EnsureSuccessStatusCode(); + await client.CreateSuggestionAsync("Seed"); + await client.PostAsJsonAsync("/api/me/phase/next", new { }); + + client.DefaultRequestHeaders.Remove("Origin"); + client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", "https://evil.example"); + + var response = await client.PostAsJsonAsync("/api/votes/finalize", new + { + Final = true + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString()); + } } diff --git a/GameList.Tests/Support/TestWebApplicationFactory.cs b/GameList.Tests/Support/TestWebApplicationFactory.cs index e0655af..baae8ae 100644 --- a/GameList.Tests/Support/TestWebApplicationFactory.cs +++ b/GameList.Tests/Support/TestWebApplicationFactory.cs @@ -76,10 +76,18 @@ internal class TestWebApplicationFactory : WebApplicationFactory public HttpClient CreateClientWithCookies() { - return CreateClient(new WebApplicationFactoryClientOptions + var client = CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = true, AllowAutoRedirect = false }); + + if (client.BaseAddress is { } baseAddress) + { + var origin = $"{baseAddress.Scheme}://{baseAddress.Authority}"; + client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", origin); + } + + return client; } } diff --git a/Infrastructure/CsrfProtectionMiddleware.cs b/Infrastructure/CsrfProtectionMiddleware.cs new file mode 100644 index 0000000..1af010c --- /dev/null +++ b/Infrastructure/CsrfProtectionMiddleware.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; + +namespace GameList.Infrastructure; + +public sealed class CsrfProtectionMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + if (!ShouldValidate(context)) + { + await next(context); + return; + } + + if (IsSameOriginRequest(context)) + { + await next(context); + return; + } + + await WriteCsrfFailureAsync(context); + } + + private static bool ShouldValidate(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)) + return false; + + if (!HttpMethods.IsPost(context.Request.Method) + && !HttpMethods.IsPut(context.Request.Method) + && !HttpMethods.IsDelete(context.Request.Method) + && !HttpMethods.IsPatch(context.Request.Method)) + return false; + + return context.User.Identity?.IsAuthenticated == true; + } + + private static bool IsSameOriginRequest(HttpContext context) + { + var originValues = context.Request.Headers.Origin; + if (!StringValues.IsNullOrEmpty(originValues)) + { + foreach (var origin in originValues) + { + if (string.IsNullOrWhiteSpace(origin)) + return false; + + if (!IsSameOrigin(origin, context)) + return false; + } + + return true; + } + + var referer = context.Request.Headers.Referer.ToString(); + if (string.IsNullOrWhiteSpace(referer)) + return false; + + return IsSameOrigin(referer, context); + } + + private static bool IsSameOrigin(string raw, HttpContext context) + { + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) + return false; + + var requestScheme = context.Request.Scheme; + if (!string.Equals(uri.Scheme, requestScheme, StringComparison.OrdinalIgnoreCase)) + return false; + + var requestHost = context.Request.Host.Host; + if (!string.Equals(uri.Host, requestHost, StringComparison.OrdinalIgnoreCase)) + return false; + + var uriPort = uri.IsDefaultPort ? GetDefaultPort(uri.Scheme) : uri.Port; + var requestPort = context.Request.Host.Port ?? GetDefaultPort(requestScheme); + + return uriPort == requestPort; + } + + private static int GetDefaultPort(string scheme) + { + return string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) ? 443 : 80; + } + + private static Task WriteCsrfFailureAsync(HttpContext context) + { + if (context.Response.HasStarted) + return Task.CompletedTask; + + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.ContentType = "application/problem+json"; + + var problem = new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Bad Request", + Detail = "CSRF validation failed.", + Extensions = { ["error"] = "CSRF validation failed." } + }; + + return context.Response.WriteAsJsonAsync(problem); + } +} diff --git a/Infrastructure/PasswordHasher.cs b/Infrastructure/PasswordHasher.cs index 90dc3ae..e760ad9 100644 --- a/Infrastructure/PasswordHasher.cs +++ b/Infrastructure/PasswordHasher.cs @@ -5,31 +5,81 @@ namespace GameList.Infrastructure; public static class PasswordHasher { + public const int LegacyVersion = 1; + public const int CurrentVersion = 2; + private const int SaltSize = 16; private const int KeySize = 32; - private const int Iterations = 210_000; + private const int IterationsV1 = 210_000; + private const int IterationsV2 = 350_000; public static (byte[] Hash, byte[] Salt) HashPassword(string password) + => HashPassword(password, CurrentVersion); + + public static (byte[] Hash, byte[] Salt) HashPassword(string password, int version) { if (string.IsNullOrEmpty(password)) throw new ArgumentException("Password required", nameof(password)); + var normalizedVersion = NormalizeHashVersion(version); var salt = RandomNumberGenerator.GetBytes(SaltSize); - var hash = PBKDF2(password, salt); + var hash = PBKDF2(password, salt, normalizedVersion); return (hash, salt); } public static bool Verify(string password, byte[] hash, byte[] salt) + => Verify(password, hash, salt, LegacyVersion, out _); + + public static bool Verify(string password, byte[] hash, byte[] salt, int version, out bool needsRehash) { + needsRehash = false; if (hash.Length == 0 || salt.Length == 0) return false; - var computed = PBKDF2(password, salt); - return CryptographicOperations.FixedTimeEquals(computed, hash); + var normalizedVersion = NormalizeVerifyVersion(version); + if (normalizedVersion == 0) + return false; + + var computed = PBKDF2(password, salt, normalizedVersion); + var verified = CryptographicOperations.FixedTimeEquals(computed, hash); + + needsRehash = verified && normalizedVersion < CurrentVersion; + return verified; } - private static byte[] PBKDF2(string password, byte[] salt) + private static int NormalizeHashVersion(int version) { - return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, KeySize); + return version switch + { + <= LegacyVersion => LegacyVersion, + CurrentVersion => CurrentVersion, + _ => throw new ArgumentOutOfRangeException(nameof(version), "Unsupported password hash version.") + }; + } + + private static int NormalizeVerifyVersion(int version) + { + return version switch + { + <= LegacyVersion => LegacyVersion, + CurrentVersion => CurrentVersion, + _ => 0 + }; + } + + private static int ResolveIterations(int version) + { + return version switch + { + LegacyVersion => IterationsV1, + CurrentVersion => IterationsV2, + _ => IterationsV1 + }; + } + + private static byte[] PBKDF2(string password, byte[] salt, int version) + { + var iterations = ResolveIterations(version); + return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, KeySize); } } diff --git a/Program.cs b/Program.cs index 51734fc..94d6bc9 100644 --- a/Program.cs +++ b/Program.cs @@ -136,7 +136,7 @@ app.Use(async (ctx, next) => headers["Referrer-Policy"] = "no-referrer"; headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=()"; headers["Content-Security-Policy"] = - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https: http:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; + "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; return Task.CompletedTask; }); @@ -152,6 +152,7 @@ if (!string.IsNullOrWhiteSpace(basePath)) app.UseGlobalExceptionLogging(); app.UseAuthentication(); app.UseMiddleware(); +app.UseMiddleware(); app.UseAuthorization(); app.UseMiddleware(); diff --git a/README.md b/README.md index 60796b3..80e5412 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,14 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS - Storage: SQLite database under `App_Data/gamelist.db`. - Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement. +- CSRF baseline: authenticated mutating API requests require same-origin `Origin`/`Referer` headers. +- Password hashes are versioned and upgraded on successful login/admin-password verification; current rollout upgrades legacy PBKDF2 parameters and prepares further migration hardening. + +## Password Hash Migration Plan + +1. Existing hashes remain valid under versioned verification (`LegacyVersion=1`). +2. Successful authentication transparently rehashes credentials to `CurrentVersion=2` and persists the upgraded hash metadata. +3. Future migration can introduce Argon2id as a new version without breaking existing users, then retire legacy versions after full rollout. ## Module Ownership diff --git a/SPEC.md b/SPEC.md index e764b66..8db8199 100644 --- a/SPEC.md +++ b/SPEC.md @@ -41,3 +41,4 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow: ## Non-functional - Desktop + mobile friendly - Runs on IIS; SQLite via EF Core +- Browser security baseline: strict CSP (no inline styles, no insecure image origins) and same-origin protection for authenticated mutating API requests diff --git a/TESTS.md b/TESTS.md index a217323..49a4c3b 100644 --- a/TESTS.md +++ b/TESTS.md @@ -38,6 +38,7 @@ stateDiagram-v2 - Database uniqueness guard enforces single owner row (`IsOwner=true`) even if writes bypass endpoint-level checks. - `/api/auth/options` reports owner presence for registration UI behavior. - Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits. +- Successful login upgrades legacy password-hash versions to current hash parameters. - Logout clears cookie. - EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth. - Cookie contains admin claim; non-admin cookie cannot access admin routes (401/403 via filter). @@ -94,6 +95,7 @@ stateDiagram-v2 - Global exception handler returns 500 with JSON body and logs error. - /health returns {status:"ok"}. - Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes. +- CSRF middleware tests validate that authenticated mutating requests reject missing/cross-origin `Origin`/`Referer` values. - Frontend regression guard tests assert modal/admin JS no longer interpolate untrusted values in vulnerable patterns. ## Coverage Policy diff --git a/wwwroot/css/components.css b/wwwroot/css/components.css index 8039640..1f55d40 100644 --- a/wwwroot/css/components.css +++ b/wwwroot/css/components.css @@ -47,6 +47,16 @@ display: block; padding: 0; } +.card-visual.has-image { + background: #f6b24f; + overflow: hidden; +} +.card-visual-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} .card-visual.hovering { cursor: zoom-in; } @@ -269,3 +279,10 @@ input[type="range"].full-slider:disabled::-moz-range-thumb { background: #f1f1f1; border-color: #c1c1c1; } + +.fx-canvas { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 120; +} diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index 84a958c..9a4a8aa 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -205,6 +205,11 @@ Registriere dich erneut mit dem korrekten Schlüssel vom Host ‒ oder lasse das Auth- und Admin-sensitive Routen sind gegen Brute-Force-Angriffe rate-limitiert. Warte kurz und versuche es dann erneut. +### „CSRF-Validierung fehlgeschlagen." + +Authentifizierte Schreibaktionen erfordern jetzt eine Same-Origin-Browseranfrage. +Lade die Seite neu und versuche es erneut. Bei eigener API-Nutzung müssen `Origin`/`Referer` zum App-Host passen. + ## Daten & Datenschutz - Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen Datenbank gespeichert. diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index 4e5f58c..8e289a7 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -209,6 +209,11 @@ Register again using the correct key from the host ‒ or leave it blank to crea Auth and admin-sensitive routes are rate-limited to reduce brute-force attempts. Wait briefly, then retry. +### "CSRF validation failed." + +Authenticated write actions now require a same-origin browser request. +Reload the page and retry. If you're calling the API from custom tooling, send matching `Origin`/`Referer` values for your app host. + ## Data & Privacy - Suggestions, votes, and phase states are stored in a shared database. diff --git a/wwwroot/js/effects.js b/wwwroot/js/effects.js index adc1dbb..ea58390 100644 --- a/wwwroot/js/effects.js +++ b/wwwroot/js/effects.js @@ -3,48 +3,15 @@ // Screenshot hover --------------------------------------------------- export function setupCardVisualHover(el, url) { if (!el || !url) return; - const img = new Image(); - let naturalW = 0; - let naturalH = 0; - let loaded = false; - img.src = url; - img.onload = () => { - naturalW = img.naturalWidth; - naturalH = img.naturalHeight; - loaded = true; - }; - - const reset = () => { - el.classList.remove("hovering"); - el.style.backgroundSize = ""; - el.style.backgroundPosition = ""; - el.style.backgroundRepeat = ""; - }; - el.addEventListener("mouseenter", () => { el.classList.add("hovering"); - el.style.backgroundSize = "auto"; - el.style.backgroundRepeat = "no-repeat"; - el.style.backgroundPosition = "center"; }); - el.addEventListener("mousemove", (e) => { - if (!loaded) return; - const rect = el.getBoundingClientRect(); - const overW = naturalW - rect.width; - const overH = naturalH - rect.height; - if (overW <= 0 && overH <= 0) { - el.style.backgroundPosition = "center"; - return; - } - const xRatio = (e.clientX - rect.left) / rect.width; - const yRatio = (e.clientY - rect.top) / rect.height; - const xPercent = overW > 0 ? xRatio * 100 : 50; - const yPercent = overH > 0 ? yRatio * 100 : 50; - el.style.backgroundPosition = `${xPercent}% ${yPercent}%`; - }); - - ["mouseleave", "blur"].forEach((evt) => el.addEventListener(evt, reset)); + ["mouseleave", "blur"].forEach((evt) => + el.addEventListener(evt, () => { + el.classList.remove("hovering"); + }), + ); } // Celebration FX ----------------------------------------------------- @@ -57,10 +24,6 @@ function ensureFxCanvas() { if (fxCanvas) return; fxCanvas = document.createElement("canvas"); fxCanvas.className = "fx-canvas"; - fxCanvas.style.position = "fixed"; - fxCanvas.style.inset = "0"; - fxCanvas.style.pointerEvents = "none"; - fxCanvas.style.zIndex = "120"; fxCanvas.width = window.innerWidth; fxCanvas.height = window.innerHeight; fxCtx = fxCanvas.getContext("2d"); diff --git a/wwwroot/js/suggestions-ui.js b/wwwroot/js/suggestions-ui.js index 2b9b640..5e2ac34 100644 --- a/wwwroot/js/suggestions-ui.js +++ b/wwwroot/js/suggestions-ui.js @@ -6,7 +6,6 @@ import { setupCardVisualHover, triggerCelebration } from "./effects.js"; import { renderAdminLinker } from "./admin-ui.js"; import { getUiRuntime } from "./ui-runtime.js"; import { - cssEscapeUrl, escapeHtml, isLinked, linkedPeerTitles, @@ -95,7 +94,7 @@ export function buildCard( : ""; const visual = hasImage && safeShot - ? `` + ? `` : `
`; const hasPlayers = s.minPlayers || s.maxPlayers; const players = hasPlayers