Harden CSRF/CSP and add hash version upgrades
This commit is contained in:
3
API.md
3
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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
260
Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs
generated
Normal file
260
Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Data/Migrations/20260218194640_AddPasswordHashVersion.cs
Normal file
29
Data/Migrations/20260218194640_AddPasswordHashVersion.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
105
Infrastructure/CsrfProtectionMiddleware.cs
Normal file
105
Infrastructure/CsrfProtectionMiddleware.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1
SPEC.md
1
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
|
||||
|
||||
2
TESTS.md
2
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
47
wwwroot/js/effects.js
vendored
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user