Harden app security controls from audit

This commit is contained in:
2026-02-08 18:40:13 +01:00
parent a6364b0802
commit 42e60d2a5a
20 changed files with 689 additions and 109 deletions

9
API.md
View File

@@ -1,12 +1,14 @@
# API Contract (auth-enabled)
All endpoints are JSON. Most routes require the HttpOnly `player` cookie issued after register/login. Admin access is granted only via an authenticated admin user session (`IsAdmin=true` on the account).
Auth and admin-sensitive routes are rate-limited and return HTTP `429` on excessive requests.
## Auth
POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true`
POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true` only for bootstrap of the first admin account
POST /api/auth/login
POST /api/auth/logout
Display names are set during registration and are immutable afterward.
Passwords must be 8-128 chars and contain uppercase, lowercase, number, and symbol.
## State (requires auth)
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
@@ -41,3 +43,8 @@ POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags
POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state
## Security Defaults
- Security headers are set on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
- In production, HTTPS redirection and HSTS are enabled.
- Screenshot URL validation rejects private/reserved address ranges and pins outbound connections to validated public IPs.

View File

@@ -2,6 +2,7 @@ using GameList.Data;
using GameList.Contracts;
using Microsoft.AspNetCore.Mvc;
using GameList.Infrastructure;
using Microsoft.AspNetCore.RateLimiting;
namespace GameList.Endpoints;
@@ -9,7 +10,7 @@ public static class AdminEndpoints
{
public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter<AdminOnlyFilter>();
var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter<AdminOnlyFilter>();
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => await service.SetResultsOpenAsync(request.ResultsOpen));
@@ -25,7 +26,7 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.DeletePlayerAsync(playerId, player.Id, request.Password);
return await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx);
});
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -52,7 +53,7 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.ResetAsync(player.Id, request.Password);
return await service.ResetAsync(player.Id, request.Password, ctx);
});
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -61,7 +62,7 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.FactoryResetAsync(player.Id, request.Password);
return await service.FactoryResetAsync(player.Id, request.Password, ctx);
});
}
}

View File

@@ -87,9 +87,9 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
}
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password)
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
return passwordError;
@@ -208,9 +208,9 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
}
public async Task<IResult> ResetAsync(Guid adminPlayerId, string password)
public async Task<IResult> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
return passwordError;
@@ -229,9 +229,9 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
}
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password)
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
return passwordError;
@@ -251,7 +251,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
}
private async Task<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password)
private async Task<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
if (string.IsNullOrWhiteSpace(password))
return EndpointHelpers.BadRequestError("Admin password is required.");
@@ -260,8 +260,15 @@ internal sealed class AdminWorkflowService(AppDbContext db)
if (admin is null)
return EndpointHelpers.UnauthorizedError();
return PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt)
? null
: EndpointHelpers.BadRequestError("Invalid admin password.");
var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt);
if (!verified)
{
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
return EndpointHelpers.BadRequestError("Invalid admin password.");
}
monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername);
return null;
}
}

View File

@@ -3,6 +3,7 @@ using GameList.Data;
using GameList.Domain;
using GameList.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
@@ -11,12 +12,15 @@ public static class AuthEndpoints
{
public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth");
var group = app.MapGroup("/api/auth").RequireRateLimiting("auth-sensitive");
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, AuthAttemptMonitor authAttemptMonitor) =>
{
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
{
authAttemptMonitor.RecordFailure(ctx, "auth-register", request.Username?.Trim() ?? "unknown", "validation-failed");
return EndpointHelpers.BadRequestError(registrationError);
}
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername);
if (exists)
@@ -28,7 +32,17 @@ public static class AuthEndpoints
if (wantsAdmin)
{
if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey)
{
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "invalid-admin-key");
return EndpointHelpers.BadRequestError("Invalid admin key.");
}
var adminExists = await db.Players.AnyAsync(p => p.IsAdmin);
if (adminExists)
{
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-disabled");
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled after the first admin account.");
}
}
var isAdmin = wantsAdmin;
@@ -49,6 +63,9 @@ public static class AuthEndpoints
db.Players.Add(player);
await db.SaveChangesAsync();
if (isAdmin)
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new AuthSessionResponse(
@@ -59,14 +76,20 @@ public static class AuthEndpoints
));
});
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) =>
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db, AuthAttemptMonitor authAttemptMonitor) =>
{
if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError))
{
authAttemptMonitor.RecordFailure(ctx, "auth-login", request.Username?.Trim() ?? "unknown", "validation-failed");
return EndpointHelpers.BadRequestError(loginError);
}
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt))
{
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
}
if (string.IsNullOrWhiteSpace(player.DisplayName))
{
@@ -76,6 +99,7 @@ public static class AuthEndpoints
player.LastLoginAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
authAttemptMonitor.RecordSuccess(ctx, "auth-login", normalizedUsername);
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new AuthSessionResponse(

View File

@@ -7,6 +7,8 @@ internal static class AuthValidator
public const int MaxUsernameLength = 24;
public const int MaxDisplayNameLength = 16;
public const int MaxAdminKeyLength = 128;
public const int MinPasswordLength = 8;
public const int MaxPasswordLength = 128;
public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error)
{
@@ -25,6 +27,25 @@ internal static class AuthValidator
return false;
}
var password = request.Password.Trim();
if (password.Length < MinPasswordLength || password.Length > MaxPasswordLength)
{
validated = default;
error = $"Password must be between {MinPasswordLength} and {MaxPasswordLength} characters.";
return false;
}
var hasUpper = password.Any(char.IsUpper);
var hasLower = password.Any(char.IsLower);
var hasDigit = password.Any(char.IsDigit);
var hasSymbol = password.Any(ch => !char.IsLetterOrDigit(ch));
if (!hasUpper || !hasLower || !hasDigit || !hasSymbol)
{
validated = default;
error = "Password must include uppercase, lowercase, number, and symbol.";
return false;
}
if ((request.DisplayName ?? string.Empty).Trim().Length > MaxDisplayNameLength)
{
validated = default;
@@ -63,6 +84,12 @@ internal static class AuthValidator
return false;
}
if (request.Password.Length > MaxPasswordLength)
{
error = $"Password must be <= {MaxPasswordLength} characters.";
return false;
}
normalizedUsername = username.ToLowerInvariant();
error = string.Empty;
return true;

View File

@@ -1,6 +1,8 @@
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Net.Sockets;
using System.Security.Claims;
namespace GameList.Endpoints;
@@ -140,6 +142,36 @@ internal static class EndpointHelpers
|| path.EndsWith(".avif", StringComparison.Ordinal);
}
public static HttpMessageHandler CreateImageValidationHandler()
{
return new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectCallback = async (context, cancellationToken) =>
{
var addresses = await ResolveSafePublicAddressesAsync(context.DnsEndPoint.Host, cancellationToken);
if (addresses.Count == 0)
throw new HttpRequestException("No safe public IPs found for host.");
foreach (var ip in addresses)
{
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
}
}
throw new HttpRequestException("Unable to connect to validated public IP for host.");
}
};
}
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(url))
@@ -148,13 +180,21 @@ internal static class EndpointHelpers
return false;
if (uri.Scheme is not ("http" or "https"))
return false;
if (!await IsSafePublicHostAsync(uri, ct))
if (handler is null)
{
if (!await IsSafePublicHostAsync(uri, ct))
return false;
}
else if (IPAddress.TryParse(uri.Host, out var literal) && IsBlockedAddress(literal))
{
return false;
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3));
var client = handler is null ? httpFactory.CreateClient("imageValidation") : new HttpClient(handler, disposeHandler: false);
using var fallbackClient = handler is null ? null : new HttpClient(handler, disposeHandler: false);
var client = fallbackClient ?? httpFactory.CreateClient("imageValidation");
try
{
@@ -234,24 +274,8 @@ internal static class EndpointHelpers
{
try
{
var host = uri.Host;
if (Uri.CheckHostName(host) == UriHostNameType.Dns || Uri.CheckHostName(host) == UriHostNameType.IPv4 || Uri.CheckHostName(host) == UriHostNameType.IPv6)
{
var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct);
foreach (var ip in addresses)
{
if (System.Net.IPAddress.IsLoopback(ip))
return false;
if (IsPrivate(ip))
return false;
}
}
else
{
return false;
}
return true;
var addresses = await ResolveSafePublicAddressesAsync(uri.Host, ct);
return addresses.Count > 0;
}
catch
{
@@ -259,26 +283,90 @@ internal static class EndpointHelpers
}
}
private static bool IsPrivate(System.Net.IPAddress ip)
private static async Task<IReadOnlyList<IPAddress>> ResolveSafePublicAddressesAsync(string host, CancellationToken ct)
{
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
if (!IsSupportedHostType(host))
return [];
IPAddress[] resolved;
if (IPAddress.TryParse(host, out var literal))
{
var bytes = ip.GetAddressBytes();
return bytes[0] switch
{
10 => true,
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
192 when bytes[1] == 168 => true,
127 => true,
_ => false
};
resolved = [literal];
}
else
{
resolved = await Dns.GetHostAddressesAsync(host, ct);
}
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
var safe = new List<IPAddress>(resolved.Length);
foreach (var ip in resolved)
{
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || System.Net.IPAddress.IsLoopback(ip);
if (!IsBlockedAddress(ip))
safe.Add(ip);
}
return safe.Distinct().ToArray();
}
private static bool IsSupportedHostType(string host)
{
var type = Uri.CheckHostName(host);
return type is UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6;
}
private static bool IsBlockedAddress(IPAddress ip)
{
if (IPAddress.IsLoopback(ip))
return true;
if (ip.IsIPv4MappedToIPv6)
return IsBlockedAddress(ip.MapToIPv4());
if (ip.AddressFamily == AddressFamily.InterNetwork)
return IsBlockedIpv4(ip);
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
return IsBlockedIpv6(ip);
return true;
}
private static bool IsBlockedIpv4(IPAddress ip)
{
var b = ip.GetAddressBytes();
return b[0] switch
{
0 => true, // "This network"
10 => true, // private
100 when b[1] >= 64 && b[1] <= 127 => true, // CGNAT
127 => true, // loopback
169 when b[1] == 254 => true, // link local
172 when b[1] >= 16 && b[1] <= 31 => true, // private
192 when b[1] == 0 && b[2] == 0 => true, // IETF protocol assignments
192 when b[1] == 0 && b[2] == 2 => true, // documentation
192 when b[1] == 88 && b[2] == 99 => true, // 6to4 relay anycast
192 when b[1] == 168 => true, // private
198 when b[1] is 18 or 19 => true, // benchmarking
198 when b[1] == 51 && b[2] == 100 => true, // documentation
203 when b[1] == 0 && b[2] == 113 => true, // documentation
>= 224 => true, // multicast/reserved/broadcast
_ => false
};
}
private static bool IsBlockedIpv6(IPAddress ip)
{
if (ip.Equals(IPAddress.IPv6None))
return true;
if (ip.IsIPv6Multicast || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal)
return true;
var bytes = ip.GetAddressBytes();
if ((bytes[0] & 0xFE) == 0xFC) // fc00::/7 unique local
return true;
if (bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0D && bytes[3] == 0xB8) // 2001:db8::/32 docs
return true;
return false;
}

View File

@@ -62,6 +62,24 @@ public class AuthTests
Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode);
}
[Fact]
public async Task Register_rejects_weak_passwords()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var weak = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = "weakpw",
Password = "alllowercase1!",
DisplayName = "weak"
});
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
var json = await weak.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Password must include uppercase, lowercase, number, and symbol.", json.GetProperty("error").GetString());
}
[Fact]
public async Task Login_sets_last_login_and_fills_missing_display_name()
{
@@ -101,6 +119,23 @@ public class AuthTests
Assert.True(json.GetProperty("isAdmin").GetBoolean());
}
[Fact]
public async Task Register_admin_key_is_bootstrap_only()
{
await using var factory = new TestWebApplicationFactory();
var first = factory.CreateClientWithCookies();
var second = factory.CreateClientWithCookies();
var firstAdmin = await first.RegisterAsync("firstadmin", admin: true);
firstAdmin.EnsureSuccessStatusCode();
var secondAdmin = await second.RegisterAsync("secondadmin", admin: true);
Assert.Equal(HttpStatusCode.BadRequest, secondAdmin.StatusCode);
var body = await secondAdmin.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Admin registration via admin key is disabled after the first admin account.", body.GetProperty("error").GetString());
}
[Fact]
public async Task Register_duplicate_username_returns_conflict()
{

View File

@@ -142,6 +142,31 @@ public class HelperTests
Assert.False(result);
}
[Fact]
public async Task IsReachableImageAsync_rejects_private_and_reserved_ranges()
{
var factory = new StubHttpClientFactory(new StubHttpMessageHandler());
var blockedUrls = new[]
{
"http://0.0.0.1/img.png",
"http://10.0.0.1/img.png",
"http://100.64.1.1/img.png",
"http://169.254.169.254/img.png",
"http://192.168.0.20/img.png",
"http://198.51.100.2/img.png",
"http://203.0.113.8/img.png",
"http://[::1]/img.png",
"http://[fc00::1]/img.png",
"http://[::ffff:127.0.0.1]/img.png"
};
foreach (var url in blockedUrls)
{
var reachable = await EndpointHelpers.IsReachableImageAsync(url, factory);
Assert.False(reachable);
}
}
[Fact]
public void Link_root_helpers_handle_groups()
{
@@ -252,6 +277,78 @@ public class HelperTests
}
}
[Fact]
public async Task Security_headers_are_applied_to_responses()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/health");
response.EnsureSuccessStatusCode();
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());
}
[Fact]
public async Task Auth_endpoints_are_rate_limited()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("ratelimit-user");
HttpResponseMessage? last = null;
for (var i = 0; i < 8; i++)
{
last = await client.PostAsJsonAsync("/api/auth/login", new
{
Username = "ratelimit-user",
Password = "wrong-pass"
});
}
Assert.NotNull(last);
Assert.Equal(HttpStatusCode.TooManyRequests, last!.StatusCode);
}
[Fact]
public async Task Admin_endpoints_are_rate_limited()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("ratelimit-admin", admin: true);
HttpResponseMessage? last = null;
for (var i = 0; i < 25; i++)
{
last = await admin.GetAsync("/api/admin/vote-status");
if (last.StatusCode == HttpStatusCode.TooManyRequests)
break;
}
Assert.NotNull(last);
Assert.Equal(HttpStatusCode.TooManyRequests, last!.StatusCode);
}
[Fact]
public void Frontend_regressions_prevent_modal_html_interpolation_for_untrusted_values()
{
var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
var modalJsPath = Path.Combine(root, "wwwroot", "js", "modals-ui.js");
var adminJsPath = Path.Combine(root, "wwwroot", "js", "admin-ui.js");
var modalJs = File.ReadAllText(modalJsPath);
var adminJs = File.ReadAllText(adminJsPath);
Assert.DoesNotContain("<h3>${title}</h3>", modalJs, StringComparison.Ordinal);
Assert.DoesNotContain("<p>${body}</p>", modalJs, StringComparison.Ordinal);
Assert.Contains("heading.textContent = title ?? \"\";", modalJs, StringComparison.Ordinal);
Assert.Contains("bodyText.textContent = body ?? \"\";", modalJs, StringComparison.Ordinal);
Assert.DoesNotContain("data-name=\"${v.name}\"", adminJs, StringComparison.Ordinal);
}
private class FakeEnv : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "";

9
IIS.md
View File

@@ -16,6 +16,8 @@
- Configure trusted reverse proxies/networks for forwarded headers (do not trust all sources):
- `ForwardedHeaders__KnownProxies__0=10.0.0.10`
- `ForwardedHeaders__KnownNetworks__0=10.0.0.0/24`
- Configure allowed hostnames explicitly (do not use wildcard in production):
- `AllowedHosts=picknplay.example.com;www.picknplay.example.com`
- Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward.
- Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles.
- Frontend base path: set `<meta name="app-base" content="/picknplay">` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root).
@@ -23,3 +25,10 @@
## Permissions
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
- Ensure firewall/HTTPS bindings match `applicationUrl` configured in IIS.
## Security Checklist
- Verify HTTPS binding/certificate is active before exposing the site publicly.
- Confirm `Strict-Transport-Security` is present in production responses.
- Confirm baseline headers are present (`Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`).
- Confirm `AllowedHosts` contains only your actual IIS hostnames.
- Confirm trusted proxy lists are explicit and minimal.

View File

@@ -0,0 +1,85 @@
using System.Collections.Concurrent;
namespace GameList.Infrastructure;
public sealed class AuthAttemptMonitor(ILogger<AuthAttemptMonitor> logger)
{
private static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(10);
private const int AlertThreshold = 5;
private static readonly Action<ILogger, string, string, string, string, int, Exception?> LogAuthFailure =
LoggerMessage.Define<string, string, string, string, int>(
LogLevel.Warning,
new EventId(2001, nameof(LogAuthFailure)),
"Auth failure scope={Scope} actor={Actor} ip={Ip} reason={Reason} failuresInWindow={Count}");
private static readonly Action<ILogger, string, string, string, int, double, Exception?> LogSecurityAlert =
LoggerMessage.Define<string, string, string, int, double>(
LogLevel.Error,
new EventId(2002, nameof(LogSecurityAlert)),
"Security alert: repeated auth failures scope={Scope} actor={Actor} ip={Ip} failuresInWindow={Count} windowMinutes={WindowMinutes}");
private static readonly Action<ILogger, string, string, string, Exception?> LogRateLimited =
LoggerMessage.Define<string, string, string>(
LogLevel.Warning,
new EventId(2003, nameof(LogRateLimited)),
"Rate limit rejection path={Path} ip={Ip} userId={UserId}");
private static readonly Action<ILogger, string, string, DateTimeOffset, Exception?> LogSessionExpired =
LoggerMessage.Define<string, string, DateTimeOffset>(
LogLevel.Warning,
new EventId(2004, nameof(LogSessionExpired)),
"Session expired by absolute lifetime path={Path} ip={Ip} startedAt={StartedAt:o}");
private readonly ConcurrentDictionary<string, AttemptState> _failures = new(StringComparer.Ordinal);
public void RecordFailure(HttpContext context, string scope, string actor, string reason)
{
var now = DateTimeOffset.UtcNow;
var key = BuildKey(context, scope, actor);
var state = _failures.AddOrUpdate(
key,
_ => new AttemptState(1, now, now),
(_, previous) => previous.LastFailureAt + FailureWindow < now
? new AttemptState(1, now, now)
: previous with { Count = previous.Count + 1, LastFailureAt = now });
LogAuthFailure(logger, scope, actor, GetRemoteIp(context), reason, state.Count, null);
if (state.Count >= AlertThreshold && state.Count % AlertThreshold == 0)
{
LogSecurityAlert(logger, scope, actor, GetRemoteIp(context), state.Count, FailureWindow.TotalMinutes, null);
}
}
public void RecordSuccess(HttpContext context, string scope, string actor)
{
_failures.TryRemove(BuildKey(context, scope, actor), out _);
}
public void RecordRateLimited(HttpContext context)
{
LogRateLimited(
logger,
context.Request.Path.Value ?? string.Empty,
GetRemoteIp(context),
context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "anonymous",
null);
}
public void RecordSessionExpired(HttpContext context, DateTimeOffset startedAt)
{
LogSessionExpired(
logger,
context.Request.Path.Value ?? string.Empty,
GetRemoteIp(context),
startedAt,
null);
}
private static string BuildKey(HttpContext context, string scope, string actor)
{
return $"{scope}|{actor}|{GetRemoteIp(context)}";
}
private static string GetRemoteIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
private readonly record struct AttemptState(int Count, DateTimeOffset FirstFailureAt, DateTimeOffset LastFailureAt);
}

View File

@@ -7,7 +7,7 @@ public static class PasswordHasher
{
private const int SaltSize = 16;
private const int KeySize = 32;
private const int Iterations = 100_000;
private const int Iterations = 210_000;
public static (byte[] Hash, byte[] Salt) HashPassword(string password)
{

View File

@@ -1,13 +1,18 @@
using GameList.Data;
using GameList.Endpoints;
using GameList.Infrastructure;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Security.Claims;
using System.Globalization;
using System.Threading.RateLimiting;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
@@ -40,11 +45,57 @@ builder.Services.AddScoped<VoteWorkflowService>();
builder.Services.AddScoped<AdminWorkflowService>();
builder.Services.AddScoped<ResultsWorkflowService>();
builder.Services.AddScoped<StateWorkflowService>();
builder.Services.AddSingleton<AuthAttemptMonitor>();
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(EndpointHelpers.CreateImageValidationHandler);
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory));
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, token) =>
{
var monitor = context.HttpContext.RequestServices.GetRequiredService<AuthAttemptMonitor>();
monitor.RecordRateLimited(context.HttpContext);
if (context.HttpContext.Response.HasStarted)
return;
context.HttpContext.Response.ContentType = "application/problem+json";
var problem = new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "Too Many Requests",
Detail = "Too many requests. Please try again shortly.",
Extensions = { ["error"] = "Too many requests. Please try again shortly." }
};
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken: token);
};
options.AddPolicy("auth-sensitive", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: BuildAuthRateLimitKey(context),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 6,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("admin-sensitive", context =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: BuildAdminRateLimitKey(context),
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueLimit = 0,
AutoReplenishment = true
}));
});
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
{
@@ -53,9 +104,11 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.ExpireTimeSpan = TimeSpan.FromHours(12);
options.Events = new CookieAuthenticationEvents
{
OnSigningIn = EnsureSessionStartAsync,
OnValidatePrincipal = ValidateSessionLifetimeAsync,
OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext),
OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext)
};
@@ -66,6 +119,28 @@ builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityE
var app = builder.Build();
app.UseForwardedHeaders(BuildForwardedHeadersOptions(builder.Configuration));
app.UseRateLimiter();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
app.UseHttpsRedirection();
}
app.Use(async (ctx, next) =>
{
ctx.Response.OnStarting(() =>
{
var headers = ctx.Response.Headers;
headers["X-Content-Type-Options"] = "nosniff";
headers["X-Frame-Options"] = "DENY";
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'";
return Task.CompletedTask;
});
await next();
});
var basePath = builder.Configuration["BasePath"];
if (!string.IsNullOrWhiteSpace(basePath))
@@ -99,6 +174,52 @@ app.MapAdminEndpoints();
app.Run();
static string BuildAuthRateLimitKey(HttpContext context)
{
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
return $"{context.Request.Path}|{ip}";
}
static string BuildAdminRateLimitKey(HttpContext context)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anon";
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
return $"{context.Request.Path}|{userId}|{ip}";
}
const string SessionStartedAtKey = "session_started_at_unix";
const long AbsoluteSessionLifetimeSeconds = 7L * 24 * 60 * 60;
static Task EnsureSessionStartAsync(CookieSigningInContext context)
{
if (!context.Properties.Items.ContainsKey(SessionStartedAtKey))
{
context.Properties.Items[SessionStartedAtKey] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
}
return Task.CompletedTask;
}
static async Task ValidateSessionLifetimeAsync(CookieValidatePrincipalContext context)
{
if (!context.Properties.Items.TryGetValue(SessionStartedAtKey, out var rawStart)
|| !long.TryParse(rawStart, out var unixStart))
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return;
}
var startedAt = DateTimeOffset.FromUnixTimeSeconds(unixStart);
if ((DateTimeOffset.UtcNow - startedAt).TotalSeconds <= AbsoluteSessionLifetimeSeconds)
return;
var monitor = context.HttpContext.RequestServices.GetRequiredService<AuthAttemptMonitor>();
monitor.RecordSessionExpired(context.HttpContext, startedAt);
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config)
{
var options = new ForwardedHeadersOptions

View File

@@ -26,6 +26,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
- Admin authorization: authenticated account with `IsAdmin=true`.
- Gameplay phases: `Suggest`, `Vote`, `Results`.
- Storage: SQLite database under `App_Data/gamelist.db`.
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
## Module Ownership

View File

@@ -9,7 +9,7 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
## High
- [ ] Fix stored XSS in confirmation modal flows.
- [x] Fix stored XSS in confirmation modal flows.
Evidence: `wwwroot/js/modals-ui.js:41`, `wwwroot/js/modals-ui.js:43`, `wwwroot/js/modals-ui.js:47`, `wwwroot/js/suggestions-ui.js:481`, `wwwroot/js/data.js:44`, `wwwroot/js/data.js:57`, `wwwroot/js/admin-ui.js:48`.
Risk: user-controlled names are injected into HTML and can execute script in other users' sessions (including admin interactions).
Tasks:
@@ -19,14 +19,14 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
## Medium
- [ ] Add request-throttling and brute-force protection for authentication/admin-sensitive routes.
- [x] Add request-throttling and brute-force protection for authentication/admin-sensitive routes.
Evidence: `Endpoints/AuthEndpoints.cs:16`, `Endpoints/AuthEndpoints.cs:62`, `Program.cs:49`, `Program.cs:64` (no `AddRateLimiter` / `UseRateLimiter` configured).
Risk: password guessing and admin-key guessing are not rate-limited.
Tasks:
1. Configure ASP.NET Core rate limiting policies for `/api/auth/*` and privileged admin routes.
2. Add lockout/backoff telemetry and alerts for repeated failed auth attempts.
- [ ] Harden screenshot URL validation against SSRF bypass techniques.
- [x] Harden screenshot URL validation against SSRF bypass techniques.
Evidence: `Endpoints/SuggestionValidator.cs:13`, `Endpoints/EndpointHelpers.cs:143`, `Endpoints/EndpointHelpers.cs:233`, `Endpoints/EndpointHelpers.cs:262`.
Risk: DNS rebinding and incomplete private/reserved IP filtering can allow internal network probing via server-side HTTP fetches.
Tasks:
@@ -34,7 +34,7 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
2. Ensure the request is pinned to validated IPs (or use a safe egress proxy/allowlist).
3. Add tests for loopback/private/reserved host bypass attempts.
- [ ] Add baseline HTTP security headers and enforce HTTPS policy.
- [x] Add baseline HTTP security headers and enforce HTTPS policy.
Evidence: `Program.cs:68`, `Program.cs:77`, `Program.cs:90` (no app-level CSP/HSTS/`X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy` middleware).
Risk: weaker browser-side mitigation for XSS/clickjacking/content-type sniffing and transport downgrade mistakes.
Tasks:
@@ -42,7 +42,7 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
2. Enable HSTS in production and verify HTTPS redirection/termination settings.
3. Add deployment checklist validation in `IIS.md`.
- [ ] Restrict accepted host headers.
- [x] Restrict accepted host headers.
Evidence: `appsettings.json:8`.
Risk: wildcard `AllowedHosts` can increase exposure to host-header abuse patterns.
Tasks:
@@ -51,21 +51,21 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
## Low
- [ ] Strengthen credential policy and password hashing parameters.
- [x] Strengthen credential policy and password hashing parameters.
Evidence: `Endpoints/AuthValidator.cs:24`, `Infrastructure/PasswordHasher.cs:9`.
Risk: weak user-selected passwords remain possible; PBKDF2 cost may become insufficient over time.
Tasks:
1. Enforce minimum password length/quality checks.
2. Review and periodically raise PBKDF2 iteration cost (or migrate to a stronger password hashing scheme).
- [ ] Reassess long-lived session defaults.
- [x] Reassess long-lived session defaults.
Evidence: `Program.cs:56`.
Risk: 30-day sliding cookie increases exposure window for stolen session cookies.
Tasks:
1. Reduce expiration window for privileged sessions or apply step-up auth for destructive admin actions.
2. Consider explicit idle timeout + absolute lifetime policy.
- [ ] Reevaluate permanent bootstrap-admin key behavior.
- [x] Reevaluate permanent bootstrap-admin key behavior.
Evidence: `Endpoints/AuthEndpoints.cs:26`, `Endpoints/AuthEndpoints.cs:30`, `Endpoints/AuthEndpoints.cs:34`.
Risk: a leaked `ADMIN_PASSWORD` can be reused indefinitely to create new admin accounts.
Tasks:

View File

@@ -32,7 +32,8 @@ stateDiagram-v2
### 1) Authentication & Identity
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
- Register rejects missing/long username, missing password, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
- Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
- Bootstrap-admin key path only works until the first admin account exists.
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
- Logout clears cookie.
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
@@ -78,11 +79,13 @@ stateDiagram-v2
### 7) Infrastructure/Helpers
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).
- EndpointHelpers.IsValidImageUrl/IsValidHttpUrl: accepts empty, http/https; rejects others/invalid ext.
- IsReachableImageAsync: with mocked Http responses covers head success, get fallback, redirect rejection, size guard, invalid host (private/loopback) detection.
- IsReachableImageAsync: with mocked Http responses covers head success, get fallback, redirect rejection, size guard, and private/reserved host range detection (IPv4/IPv6).
- BuildLinkRoots/LinkedIdsFor/FindRootId: cover disjoint groups, chains, cycles guard (visited set), non-existent ids.
- UpdateIndexMetaBase (Program.cs): rewrites app-base meta when BasePath set; no change when matching/marker missing; safe exceptions swallowed.
- 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.
- Frontend regression guard tests assert modal/admin JS no longer interpolate untrusted values in vulnerable patterns.
## Execution Notes
- Use named test data builders for players/suggestions to keep cases small and isolated.

View File

@@ -5,7 +5,7 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AllowedHosts": "localhost;127.0.0.1;[::1]",
"BasePath": "",
"ConnectionStrings": {
"Default": "Data Source=App_Data/gamelist.db"

View File

@@ -13,7 +13,7 @@ Dein Anzeigename ist erforderlich er erscheint neben all deinen Vorschlägen
### Brauche ich Admin-Rechte?
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Admin-Rechte können später nicht hinzugefügt werden. Um Admin zu werden, musst du dich mit dem korrekten Schlüssel neu registrieren.
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Die Admin-Schlüssel-Registrierung ist nur verfügbar, bis das erste Admin-Konto erstellt wurde. Admin-Rechte können später nicht über die öffentliche Registrierung hinzugefügt werden.
## Phasen im Überblick
@@ -52,7 +52,7 @@ Wenn du eine Screenshot-URL angibst, muss sie:
- Direkt erreichbar sein (keine Weiterleitungen)
- Innerhalb von ~3 Sekunden laden
- Unter **5 MB**groß sein
- Nicht auf lokale oder private Hosts verweisen
- Nicht auf lokale, private oder reservierte Hosts verweisen
Screenshots sind optional.
@@ -189,9 +189,14 @@ Bis dahin zeigt die Navigation in der Vorschlagsphase einen Hinweis statt eines
Registriere dich erneut mit dem korrekten Schlüssel vom Host oder lasse das Feld leer, um ein normales Konto zu erstellen.
### „Zu viele Anfragen. Bitte versuche es in Kürze erneut."
Auth- und Admin-sensitive Routen sind gegen Brute-Force-Angriffe rate-limitiert.
Warte kurz und versuche es dann erneut.
## Daten & Datenschutz
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen **SQLite-Datenbank** gespeichert.
- Passwörtwer werden mit einer SHA256 Verschlüsselung gespeichert.
- Passwörter werden als gesalzene PBKDF2-SHA256-Hashes gespeichert (nicht im Klartext).
- Beim Abmelden wird dein Authentifizierungs-Cookie gelöscht und die Eingaben in Login/Registrierung werden zurückgesetzt.
- Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt.

View File

@@ -14,7 +14,7 @@ Your display name is required it appears next to all of your suggestions and
### Do I need admin privileges?
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
Admin access cannot be added later. To become an admin, you must re-register with the correct key.
Admin-key bootstrap is only available until the first admin account exists. Admin access cannot be added later. To become an admin afterward, an existing admin must create/manage access outside the public registration flow.
## Phases at a Glance
@@ -54,7 +54,7 @@ If you include a screenshot URL, it must:
- Be directly accessible (no redirects)
- Load within ~3 seconds
- Be under **5 MB**
- Not point to local or private hosts
- Not point to local, private, or reserved hosts
Screenshots are optional.
@@ -193,9 +193,14 @@ Until then, the Suggest navigation shows a hint instead of a Next button, and sw
Register again using the correct key from the host or leave it blank to create a regular account.
### "Too many requests. Please try again shortly."
Auth and admin-sensitive routes are rate-limited to reduce brute-force attempts.
Wait briefly, then retry.
## Data & Privacy
- Suggestions, votes, and phase states are stored in a shared **SQLite database**.
- Passwords are stored with a SHA256 encryption.
- Passwords are stored as salted PBKDF2-SHA256 hashes (not plaintext).
- Logging out clears your authentication cookie and resets login/register form inputs.
- If an admin deletes your player account, your suggestions and votes are removed as well.

View File

@@ -1,7 +1,7 @@
import { t } from "./i18n.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import { buildLinkOptionLabel, escapeHtml, truncate } from "./ui-utils.js";
import { buildLinkOptionLabel, truncate } from "./ui-utils.js";
function displayPlayerStatus(player) {
if (!player) return "";
@@ -16,14 +16,24 @@ function displayPlayerStatus(player) {
}
function buildStatusSelect(player) {
const statusText = displayPlayerStatus(player);
const canMoveToSuggest = player.phase === "Vote";
return `
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}">
<option value="" selected>${statusText}</option>
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option>
</select>
`;
const select = document.createElement("select");
select.className = "chip admin-status-select";
select.dataset.setPlayerPhase = player.playerId;
select.setAttribute("aria-label", t("admin.playerStatus"));
const current = document.createElement("option");
current.value = "";
current.selected = true;
current.textContent = displayPlayerStatus(player);
const suggest = document.createElement("option");
suggest.value = "Suggest";
suggest.disabled = !canMoveToSuggest;
suggest.textContent = t("admin.statusMoveToSuggest");
select.append(current, suggest);
return select;
}
export function renderAdminVoteStatus() {
@@ -36,17 +46,49 @@ export function renderAdminVoteStatus() {
table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr");
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24));
tr.innerHTML = `
<td title="${escapeHtml(v.name)}">${nameText}</td>
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
<td>${buildStatusSelect(v)}</td>
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>
`;
const gamesTooltip = (v.suggestionTitles || []).join(", ");
const nameCell = document.createElement("td");
nameCell.title = v.name ?? "";
nameCell.textContent = truncate(v.name, 28);
const usernameCell = document.createElement("td");
usernameCell.className = "muted small";
usernameCell.title = v.username ?? "";
usernameCell.textContent = truncate(v.username, 24);
const statusCell = document.createElement("td");
statusCell.appendChild(buildStatusSelect(v));
const countCell = document.createElement("td");
countCell.title = gamesTooltip;
countCell.textContent = String(v.suggestionCount ?? 0);
const jokerCell = document.createElement("td");
const jokerButton = document.createElement("button");
jokerButton.className = "chip";
jokerButton.dataset.grantJoker = v.playerId;
jokerButton.type = "button";
jokerButton.textContent = v.hasJoker ? "🎟" : t("admin.grantJokerChip");
jokerCell.appendChild(jokerButton);
const deleteCell = document.createElement("td");
const deleteButton = document.createElement("button");
deleteButton.className = "chip danger-chip";
deleteButton.dataset.deletePlayer = v.playerId;
deleteButton.dataset.name = v.name ?? "";
deleteButton.type = "button";
deleteButton.textContent = "✕";
deleteCell.appendChild(deleteButton);
tr.append(
nameCell,
usernameCell,
statusCell,
countCell,
jokerCell,
deleteCell,
);
table.appendChild(tr);
});

View File

@@ -1,18 +1,28 @@
import { t } from "./i18n.js";
import { toast } from "./dom.js";
import { escapeHtml } from "./ui-utils.js";
export function openLightbox(url, title) {
const overlay = document.createElement("div");
overlay.className = "lightbox";
const safeTitle = escapeHtml(title || "");
overlay.innerHTML = `
<div class="lightbox-content">
<button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
<img src="${url}" alt="${safeTitle}" />
<p>${safeTitle}</p>
</div>
`;
const content = document.createElement("div");
content.className = "lightbox-content";
const closeBtn = document.createElement("button");
closeBtn.className = "lightbox-close";
closeBtn.setAttribute("aria-label", t("lightbox.close"));
closeBtn.type = "button";
closeBtn.textContent = "✕";
const image = document.createElement("img");
image.src = url ?? "";
image.alt = title ?? "";
const caption = document.createElement("p");
caption.textContent = title ?? "";
content.append(closeBtn, image, caption);
overlay.appendChild(content);
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("lightbox") ||
@@ -38,15 +48,28 @@ export function openConfirmModal({
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${title}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body">
<p>${body}</p>
</div>
`;
const header = document.createElement("div");
header.className = "edit-header";
const heading = document.createElement("h3");
heading.textContent = title ?? "";
const closeBtn = document.createElement("button");
closeBtn.className = "lightbox-close";
closeBtn.setAttribute("aria-label", t("modal.close"));
closeBtn.type = "button";
closeBtn.textContent = "x";
header.append(heading, closeBtn);
const bodyWrap = document.createElement("div");
bodyWrap.className = "edit-body";
const bodyText = document.createElement("p");
bodyText.textContent = body ?? "";
bodyWrap.appendChild(bodyText);
panel.append(header, bodyWrap);
const close = () => overlay.remove();
const actions = document.createElement("div");
actions.className = "stack horizontal confirm-actions";
@@ -63,7 +86,7 @@ export function openConfirmModal({
actions.append(cancelBtn);
cancelBtn.addEventListener("click", close);
}
const bodyContainer = panel.querySelector(".edit-body");
const bodyContainer = bodyWrap;
let passwordInput = null;
if (requirePassword && bodyContainer) {
const field = document.createElement("label");