Harden CSRF/CSP and add hash version upgrades

This commit is contained in:
2026-02-18 20:51:18 +01:00
parent 3c7f3d2114
commit a130cba41a
23 changed files with 627 additions and 57 deletions

3
API.md
View File

@@ -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.

View File

@@ -21,6 +21,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> 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();

View File

@@ -0,0 +1,260 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<bool>("HasJoker")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsOwner")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<int>("PasswordHashVersion")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<byte[]>("PasswordSalt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("GameUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<int?>("MinPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("ParentSuggestionId")
.HasColumnType("INTEGER");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<string>("ScreenshotUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<int>("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
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GameList.Data.Migrations
{
/// <inheritdoc />
public partial class AddPasswordHashVersion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PasswordHashVersion",
table: "Players",
type: "INTEGER",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordHashVersion",
table: "Players");
}
}
}

View File

@@ -87,6 +87,11 @@ namespace GameList.Data.Migrations
.IsRequired()
.HasColumnType("BLOB");
b.Property<int>("PasswordHashVersion")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<byte[]>("PasswordSalt")
.IsRequired()
.HasColumnType("BLOB");

View File

@@ -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; }

View File

@@ -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<AuthAttemptMonitor>();
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;
}

View File

@@ -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);

View File

@@ -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()
{

View File

@@ -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<ArgumentException>(() => 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]

View File

@@ -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<JsonElement>();
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<JsonElement>();
Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString());
}
}

View File

@@ -76,10 +76,18 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<EnsurePlayerExistsMiddleware>();
app.UseMiddleware<CsrfProtectionMiddleware>();
app.UseAuthorization();
app.UseMiddleware<StateChangeNotificationMiddleware>();

View File

@@ -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

View File

@@ -41,3 +41,4 @@ Help a small Discord group (48 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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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.

47
wwwroot/js/effects.js vendored
View File

@@ -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");

View File

@@ -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
? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></button>`
? `<button class="card-visual has-image" data-img="${escapeHtml(safeShot)}" aria-label="${t("card.openScreenshot")}"><img class="card-visual-image" src="${escapeHtml(safeShot)}" alt="" loading="lazy" decoding="async" /></button>`
: `<div class="card-visual"></div>`;
const hasPlayers = s.minPlayers || s.maxPlayers;
const players = hasPlayers