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.
|
Owner restrictions: owner role/admin status cannot be changed, and owner account cannot be deleted.
|
||||||
|
|
||||||
## Security Defaults
|
## 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`).
|
- 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.
|
- In production, HTTPS redirection and HSTS are enabled.
|
||||||
- Screenshot URL validation rejects private/reserved address ranges and pins outbound connections to validated public IPs.
|
- 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.HasIndex(p => p.NormalizedUsername).IsUnique();
|
||||||
builder.Property(p => p.PasswordHash).IsRequired();
|
builder.Property(p => p.PasswordHash).IsRequired();
|
||||||
builder.Property(p => p.PasswordSalt).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.IsAdmin).HasDefaultValue(false);
|
||||||
builder.Property(p => p.IsOwner).HasDefaultValue(false);
|
builder.Property(p => p.IsOwner).HasDefaultValue(false);
|
||||||
builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique();
|
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()
|
.IsRequired()
|
||||||
.HasColumnType("BLOB");
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<int>("PasswordHashVersion")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
b.Property<byte[]>("PasswordSalt")
|
b.Property<byte[]>("PasswordSalt")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("BLOB");
|
.HasColumnType("BLOB");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class Player
|
|||||||
|
|
||||||
public byte[] PasswordHash { get; set; } = [];
|
public byte[] PasswordHash { get; set; } = [];
|
||||||
public byte[] PasswordSalt { get; set; } = [];
|
public byte[] PasswordSalt { get; set; } = [];
|
||||||
|
public int PasswordHashVersion { get; set; } = 1;
|
||||||
|
|
||||||
public DateTimeOffset? LastLoginAt { get; set; }
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
public bool IsAdmin { get; set; }
|
public bool IsAdmin { get; set; }
|
||||||
|
|||||||
@@ -262,18 +262,27 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
return ServiceError.BadRequest("Admin password is required.");
|
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)
|
if (admin is null)
|
||||||
return ServiceError.Unauthorized();
|
return ServiceError.Unauthorized();
|
||||||
|
|
||||||
var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
|
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)
|
if (!verified)
|
||||||
{
|
{
|
||||||
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
|
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
|
||||||
return ServiceError.BadRequest("Invalid admin 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);
|
monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public static class AuthEndpoints
|
|||||||
NormalizedUsername = validated.NormalizedUsername,
|
NormalizedUsername = validated.NormalizedUsername,
|
||||||
PasswordHash = hash,
|
PasswordHash = hash,
|
||||||
PasswordSalt = salt,
|
PasswordSalt = salt,
|
||||||
|
PasswordHashVersion = PasswordHasher.CurrentVersion,
|
||||||
DisplayName = validated.DisplayName,
|
DisplayName = validated.DisplayName,
|
||||||
IsAdmin = isAdmin,
|
IsAdmin = isAdmin,
|
||||||
IsOwner = isOwner,
|
IsOwner = isOwner,
|
||||||
@@ -104,12 +105,21 @@ public static class AuthEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
|
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");
|
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
|
||||||
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
|
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))
|
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||||
{
|
{
|
||||||
player.DisplayName = EndpointHelpers.TrimTo(player.Username, AuthValidator.MaxDisplayNameLength);
|
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]
|
[Fact]
|
||||||
public async Task Register_with_admin_key_sets_admin_flag()
|
public async Task Register_with_admin_key_sets_admin_flag()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ public class HelperTests
|
|||||||
public void PasswordHasher_roundtrip_and_empty_guard()
|
public void PasswordHasher_roundtrip_and_empty_guard()
|
||||||
{
|
{
|
||||||
var (hash, salt) = PasswordHasher.HashPassword("secret");
|
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.False(PasswordHasher.Verify("other", hash, salt));
|
||||||
Assert.Throws<ArgumentException>(() => PasswordHasher.HashPassword(""));
|
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("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
|
||||||
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
|
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
|
||||||
Assert.Equal("no-referrer", response.Headers.GetValues("Referrer-Policy").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]
|
[Fact]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
|
|
||||||
namespace GameList.Tests;
|
namespace GameList.Tests;
|
||||||
@@ -36,4 +38,49 @@ public class MiddlewareTests
|
|||||||
var resp = await client.GetAsync("/api/state");
|
var resp = await client.GetAsync("/api/state");
|
||||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
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()
|
public HttpClient CreateClientWithCookies()
|
||||||
{
|
{
|
||||||
return CreateClient(new WebApplicationFactoryClientOptions
|
var client = CreateClient(new WebApplicationFactoryClientOptions
|
||||||
{
|
{
|
||||||
HandleCookies = true,
|
HandleCookies = true,
|
||||||
AllowAutoRedirect = false
|
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 static class PasswordHasher
|
||||||
{
|
{
|
||||||
|
public const int LegacyVersion = 1;
|
||||||
|
public const int CurrentVersion = 2;
|
||||||
|
|
||||||
private const int SaltSize = 16;
|
private const int SaltSize = 16;
|
||||||
private const int KeySize = 32;
|
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)
|
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))
|
if (string.IsNullOrEmpty(password))
|
||||||
throw new ArgumentException("Password required", nameof(password));
|
throw new ArgumentException("Password required", nameof(password));
|
||||||
|
|
||||||
|
var normalizedVersion = NormalizeHashVersion(version);
|
||||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||||
var hash = PBKDF2(password, salt);
|
var hash = PBKDF2(password, salt, normalizedVersion);
|
||||||
return (hash, salt);
|
return (hash, salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool Verify(string password, byte[] hash, byte[] 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)
|
if (hash.Length == 0 || salt.Length == 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var computed = PBKDF2(password, salt);
|
var normalizedVersion = NormalizeVerifyVersion(version);
|
||||||
return CryptographicOperations.FixedTimeEquals(computed, hash);
|
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["Referrer-Policy"] = "no-referrer";
|
||||||
headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=()";
|
headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=()";
|
||||||
headers["Content-Security-Policy"] =
|
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;
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,6 +152,7 @@ if (!string.IsNullOrWhiteSpace(basePath))
|
|||||||
app.UseGlobalExceptionLogging();
|
app.UseGlobalExceptionLogging();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
||||||
|
app.UseMiddleware<CsrfProtectionMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<StateChangeNotificationMiddleware>();
|
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`.
|
- Storage: SQLite database under `App_Data/gamelist.db`.
|
||||||
- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate.
|
- 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.
|
- 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
|
## 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
|
## Non-functional
|
||||||
- Desktop + mobile friendly
|
- Desktop + mobile friendly
|
||||||
- Runs on IIS; SQLite via EF Core
|
- 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.
|
- 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.
|
- `/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.
|
- 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.
|
- Logout clears cookie.
|
||||||
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
|
- 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).
|
- 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.
|
- Global exception handler returns 500 with JSON body and logs error.
|
||||||
- /health returns {status:"ok"}.
|
- /health returns {status:"ok"}.
|
||||||
- Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes.
|
- 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.
|
- Frontend regression guard tests assert modal/admin JS no longer interpolate untrusted values in vulnerable patterns.
|
||||||
|
|
||||||
## Coverage Policy
|
## Coverage Policy
|
||||||
|
|||||||
@@ -47,6 +47,16 @@
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 0;
|
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 {
|
.card-visual.hovering {
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
@@ -269,3 +279,10 @@ input[type="range"].full-slider:disabled::-moz-range-thumb {
|
|||||||
background: #f1f1f1;
|
background: #f1f1f1;
|
||||||
border-color: #c1c1c1;
|
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.
|
Auth- und Admin-sensitive Routen sind gegen Brute-Force-Angriffe rate-limitiert.
|
||||||
Warte kurz und versuche es dann erneut.
|
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
|
## Daten & Datenschutz
|
||||||
|
|
||||||
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen Datenbank gespeichert.
|
- 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.
|
Auth and admin-sensitive routes are rate-limited to reduce brute-force attempts.
|
||||||
Wait briefly, then retry.
|
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
|
## Data & Privacy
|
||||||
|
|
||||||
- Suggestions, votes, and phase states are stored in a shared database.
|
- 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 ---------------------------------------------------
|
// Screenshot hover ---------------------------------------------------
|
||||||
export function setupCardVisualHover(el, url) {
|
export function setupCardVisualHover(el, url) {
|
||||||
if (!el || !url) return;
|
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.addEventListener("mouseenter", () => {
|
||||||
el.classList.add("hovering");
|
el.classList.add("hovering");
|
||||||
el.style.backgroundSize = "auto";
|
|
||||||
el.style.backgroundRepeat = "no-repeat";
|
|
||||||
el.style.backgroundPosition = "center";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
el.addEventListener("mousemove", (e) => {
|
["mouseleave", "blur"].forEach((evt) =>
|
||||||
if (!loaded) return;
|
el.addEventListener(evt, () => {
|
||||||
const rect = el.getBoundingClientRect();
|
el.classList.remove("hovering");
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Celebration FX -----------------------------------------------------
|
// Celebration FX -----------------------------------------------------
|
||||||
@@ -57,10 +24,6 @@ function ensureFxCanvas() {
|
|||||||
if (fxCanvas) return;
|
if (fxCanvas) return;
|
||||||
fxCanvas = document.createElement("canvas");
|
fxCanvas = document.createElement("canvas");
|
||||||
fxCanvas.className = "fx-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.width = window.innerWidth;
|
||||||
fxCanvas.height = window.innerHeight;
|
fxCanvas.height = window.innerHeight;
|
||||||
fxCtx = fxCanvas.getContext("2d");
|
fxCtx = fxCanvas.getContext("2d");
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { setupCardVisualHover, triggerCelebration } from "./effects.js";
|
|||||||
import { renderAdminLinker } from "./admin-ui.js";
|
import { renderAdminLinker } from "./admin-ui.js";
|
||||||
import { getUiRuntime } from "./ui-runtime.js";
|
import { getUiRuntime } from "./ui-runtime.js";
|
||||||
import {
|
import {
|
||||||
cssEscapeUrl,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
isLinked,
|
isLinked,
|
||||||
linkedPeerTitles,
|
linkedPeerTitles,
|
||||||
@@ -95,7 +94,7 @@ export function buildCard(
|
|||||||
: "";
|
: "";
|
||||||
const visual =
|
const visual =
|
||||||
hasImage && safeShot
|
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>`;
|
: `<div class="card-visual"></div>`;
|
||||||
const hasPlayers = s.minPlayers || s.maxPlayers;
|
const hasPlayers = s.minPlayers || s.maxPlayers;
|
||||||
const players = hasPlayers
|
const players = hasPlayers
|
||||||
|
|||||||
Reference in New Issue
Block a user