Harden app security controls from audit
This commit is contained in:
9
API.md
9
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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +32,19 @@ 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;
|
||||
|
||||
var player = new Player
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (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,25 +283,89 @@ 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
|
||||
resolved = [literal];
|
||||
}
|
||||
else
|
||||
{
|
||||
10 => true,
|
||||
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
|
||||
192 when bytes[1] == 168 => true,
|
||||
127 => true,
|
||||
resolved = await Dns.GetHostAddressesAsync(host, ct);
|
||||
}
|
||||
|
||||
var safe = new List<IPAddress>(resolved.Length);
|
||||
foreach (var ip in resolved)
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
private static bool IsBlockedIpv6(IPAddress ip)
|
||||
{
|
||||
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || System.Net.IPAddress.IsLoopback(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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
9
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 `<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.
|
||||
|
||||
85
Infrastructure/AuthAttemptMonitor.cs
Normal file
85
Infrastructure/AuthAttemptMonitor.cs
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
125
Program.cs
125
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<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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
16
TASKS.md
16
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:
|
||||
|
||||
7
TESTS.md
7
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.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AllowedHosts": "localhost;127.0.0.1;[::1]",
|
||||
"BasePath": "",
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=App_Data/gamelist.db"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user