diff --git a/API.md b/API.md index 71e8045..2124753 100644 --- a/API.md +++ b/API.md @@ -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. diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index e58d6f8..829065e 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -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(); + var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter(); 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); }); } } diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index 43ace57..67a6108 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -87,9 +87,9 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal)); } - public async Task DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password) + public async Task 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 ResetAsync(Guid adminPlayerId, string password) + public async Task 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 FactoryResetAsync(Guid adminPlayerId, string password) + public async Task 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 ValidateAdminPasswordAsync(Guid adminPlayerId, string password) + private async Task 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(); + 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; } } diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index 506b77a..c5993c0 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -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( diff --git a/Endpoints/AuthValidator.cs b/Endpoints/AuthValidator.cs index dd783f6..e617d80 100644 --- a/Endpoints/AuthValidator.cs +++ b/Endpoints/AuthValidator.cs @@ -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; diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index e8c0b7d..061abc6 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -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 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> 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(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; } diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index c0fd0a0..516ab29 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -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(); + 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(); + 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() { diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 698c6f0..4e60309 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -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("

${title}

", modalJs, StringComparison.Ordinal); + Assert.DoesNotContain("

${body}

", 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; } = ""; diff --git a/IIS.md b/IIS.md index 5914b35..bad3291 100644 --- a/IIS.md +++ b/IIS.md @@ -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 `` 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. diff --git a/Infrastructure/AuthAttemptMonitor.cs b/Infrastructure/AuthAttemptMonitor.cs new file mode 100644 index 0000000..4b48c37 --- /dev/null +++ b/Infrastructure/AuthAttemptMonitor.cs @@ -0,0 +1,85 @@ +using System.Collections.Concurrent; + +namespace GameList.Infrastructure; + +public sealed class AuthAttemptMonitor(ILogger logger) +{ + private static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(10); + private const int AlertThreshold = 5; + private static readonly Action LogAuthFailure = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(2001, nameof(LogAuthFailure)), + "Auth failure scope={Scope} actor={Actor} ip={Ip} reason={Reason} failuresInWindow={Count}"); + private static readonly Action LogSecurityAlert = + LoggerMessage.Define( + 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 LogRateLimited = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(2003, nameof(LogRateLimited)), + "Rate limit rejection path={Path} ip={Ip} userId={UserId}"); + private static readonly Action LogSessionExpired = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(2004, nameof(LogSessionExpired)), + "Session expired by absolute lifetime path={Path} ip={Ip} startedAt={StartedAt:o}"); + + private readonly ConcurrentDictionary _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); +} diff --git a/Infrastructure/PasswordHasher.cs b/Infrastructure/PasswordHasher.cs index 1367566..90dc3ae 100644 --- a/Infrastructure/PasswordHasher.cs +++ b/Infrastructure/PasswordHasher.cs @@ -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) { diff --git a/Program.cs b/Program.cs index 9a980f5..97f6ab1 100644 --- a/Program.cs +++ b/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); 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(); + 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(); + monitor.RecordSessionExpired(context.HttpContext, startedAt); + context.RejectPrincipal(); + await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); +} + static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config) { var options = new ForwardedHeadersOptions diff --git a/README.md b/README.md index c2b7f7b..7e23b5a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TASKS.md b/TASKS.md index 02e2ebd..a1a1492 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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: diff --git a/TESTS.md b/TESTS.md index ed1cb3e..1cfd947 100644 --- a/TESTS.md +++ b/TESTS.md @@ -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. diff --git a/appsettings.json b/appsettings.json index 30ec4cd..ff03104 100644 --- a/appsettings.json +++ b/appsettings.json @@ -5,7 +5,7 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", + "AllowedHosts": "localhost;127.0.0.1;[::1]", "BasePath": "", "ConnectionStrings": { "Default": "Data Source=App_Data/gamelist.db" diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index 501b7b6..d7f6c36 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -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. diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index 8eb205a..658e4d6 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -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. diff --git a/wwwroot/js/admin-ui.js b/wwwroot/js/admin-ui.js index 6ef9c64..49f8ba5 100644 --- a/wwwroot/js/admin-ui.js +++ b/wwwroot/js/admin-ui.js @@ -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 ` - - `; + 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 = ` - ${nameText} - ${userText} - ${buildStatusSelect(v)} - ${v.suggestionCount ?? 0} - - - `; + 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); }); diff --git a/wwwroot/js/modals-ui.js b/wwwroot/js/modals-ui.js index ec731f6..260221a 100644 --- a/wwwroot/js/modals-ui.js +++ b/wwwroot/js/modals-ui.js @@ -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 = ` - - `; + + 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 = ` -
-

${title}

- -
-
-

${body}

-
- `; + + 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");