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)
|
# 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).
|
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
|
## 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/login
|
||||||
POST /api/auth/logout
|
POST /api/auth/logout
|
||||||
Display names are set during registration and are immutable afterward.
|
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)
|
## State (requires auth)
|
||||||
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
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/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/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags
|
||||||
POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state
|
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 GameList.Contracts;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ public static class AdminEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
|
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));
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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));
|
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)
|
if (passwordError is not null)
|
||||||
return passwordError;
|
return passwordError;
|
||||||
|
|
||||||
@@ -208,9 +208,9 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
|
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)
|
if (passwordError is not null)
|
||||||
return passwordError;
|
return passwordError;
|
||||||
|
|
||||||
@@ -229,9 +229,9 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
|
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)
|
if (passwordError is not null)
|
||||||
return passwordError;
|
return passwordError;
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
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))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
return EndpointHelpers.BadRequestError("Admin password is required.");
|
return EndpointHelpers.BadRequestError("Admin password is required.");
|
||||||
@@ -260,8 +260,15 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
if (admin is null)
|
if (admin is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
return PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt)
|
var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
|
||||||
? null
|
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt);
|
||||||
: EndpointHelpers.BadRequestError("Invalid admin password.");
|
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.Domain;
|
||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
@@ -11,12 +12,15 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
|
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))
|
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);
|
return EndpointHelpers.BadRequestError(registrationError);
|
||||||
|
}
|
||||||
|
|
||||||
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername);
|
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername);
|
||||||
if (exists)
|
if (exists)
|
||||||
@@ -28,9 +32,19 @@ public static class AuthEndpoints
|
|||||||
if (wantsAdmin)
|
if (wantsAdmin)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey)
|
if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey)
|
||||||
|
{
|
||||||
|
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "invalid-admin-key");
|
||||||
return EndpointHelpers.BadRequestError("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 isAdmin = wantsAdmin;
|
||||||
|
|
||||||
var player = new Player
|
var player = new Player
|
||||||
@@ -49,6 +63,9 @@ public static class AuthEndpoints
|
|||||||
db.Players.Add(player);
|
db.Players.Add(player);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (isAdmin)
|
||||||
|
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
|
||||||
|
|
||||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||||
|
|
||||||
return Results.Ok(new AuthSessionResponse(
|
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))
|
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);
|
return EndpointHelpers.BadRequestError(loginError);
|
||||||
|
}
|
||||||
|
|
||||||
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
|
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
|
||||||
if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt))
|
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.");
|
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||||
{
|
{
|
||||||
@@ -76,6 +99,7 @@ public static class AuthEndpoints
|
|||||||
player.LastLoginAt = DateTimeOffset.UtcNow;
|
player.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
authAttemptMonitor.RecordSuccess(ctx, "auth-login", normalizedUsername);
|
||||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||||
|
|
||||||
return Results.Ok(new AuthSessionResponse(
|
return Results.Ok(new AuthSessionResponse(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ internal static class AuthValidator
|
|||||||
public const int MaxUsernameLength = 24;
|
public const int MaxUsernameLength = 24;
|
||||||
public const int MaxDisplayNameLength = 16;
|
public const int MaxDisplayNameLength = 16;
|
||||||
public const int MaxAdminKeyLength = 128;
|
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)
|
public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error)
|
||||||
{
|
{
|
||||||
@@ -25,6 +27,25 @@ internal static class AuthValidator
|
|||||||
return false;
|
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)
|
if ((request.DisplayName ?? string.Empty).Trim().Length > MaxDisplayNameLength)
|
||||||
{
|
{
|
||||||
validated = default;
|
validated = default;
|
||||||
@@ -63,6 +84,12 @@ internal static class AuthValidator
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.Password.Length > MaxPasswordLength)
|
||||||
|
{
|
||||||
|
error = $"Password must be <= {MaxPasswordLength} characters.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
normalizedUsername = username.ToLowerInvariant();
|
normalizedUsername = username.ToLowerInvariant();
|
||||||
error = string.Empty;
|
error = string.Empty;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
using GameList.Domain;
|
using GameList.Domain;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
@@ -140,6 +142,36 @@ internal static class EndpointHelpers
|
|||||||
|| path.EndsWith(".avif", StringComparison.Ordinal);
|
|| 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)
|
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
@@ -148,13 +180,21 @@ internal static class EndpointHelpers
|
|||||||
return false;
|
return false;
|
||||||
if (uri.Scheme is not ("http" or "https"))
|
if (uri.Scheme is not ("http" or "https"))
|
||||||
return false;
|
return false;
|
||||||
|
if (handler is null)
|
||||||
|
{
|
||||||
if (!await IsSafePublicHostAsync(uri, ct))
|
if (!await IsSafePublicHostAsync(uri, ct))
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
else if (IPAddress.TryParse(uri.Host, out var literal) && IsBlockedAddress(literal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -234,24 +274,8 @@ internal static class EndpointHelpers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var host = uri.Host;
|
var addresses = await ResolveSafePublicAddressesAsync(uri.Host, ct);
|
||||||
if (Uri.CheckHostName(host) == UriHostNameType.Dns || Uri.CheckHostName(host) == UriHostNameType.IPv4 || Uri.CheckHostName(host) == UriHostNameType.IPv6)
|
return addresses.Count > 0;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
catch
|
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();
|
resolved = [literal];
|
||||||
return bytes[0] switch
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
10 => true,
|
resolved = await Dns.GetHostAddressesAsync(host, ct);
|
||||||
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
|
}
|
||||||
192 when bytes[1] == 168 => true,
|
|
||||||
127 => true,
|
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
|
_ => 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ public class AuthTests
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task Login_sets_last_login_and_fills_missing_display_name()
|
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());
|
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]
|
[Fact]
|
||||||
public async Task Register_duplicate_username_returns_conflict()
|
public async Task Register_duplicate_username_returns_conflict()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -142,6 +142,31 @@ public class HelperTests
|
|||||||
Assert.False(result);
|
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]
|
[Fact]
|
||||||
public void Link_root_helpers_handle_groups()
|
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
|
private class FakeEnv : IWebHostEnvironment
|
||||||
{
|
{
|
||||||
public string ApplicationName { get; set; } = "";
|
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):
|
- Configure trusted reverse proxies/networks for forwarded headers (do not trust all sources):
|
||||||
- `ForwardedHeaders__KnownProxies__0=10.0.0.10`
|
- `ForwardedHeaders__KnownProxies__0=10.0.0.10`
|
||||||
- `ForwardedHeaders__KnownNetworks__0=10.0.0.0/24`
|
- `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.
|
- 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.
|
- 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).
|
- 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
|
## Permissions
|
||||||
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
|
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
|
||||||
- Ensure firewall/HTTPS bindings match `applicationUrl` configured in IIS.
|
- 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 SaltSize = 16;
|
||||||
private const int KeySize = 32;
|
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)
|
public static (byte[] Hash, byte[] Salt) HashPassword(string password)
|
||||||
{
|
{
|
||||||
|
|||||||
125
Program.cs
125
Program.cs
@@ -1,13 +1,18 @@
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
using GameList.Endpoints;
|
using GameList.Endpoints;
|
||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -40,11 +45,57 @@ builder.Services.AddScoped<VoteWorkflowService>();
|
|||||||
builder.Services.AddScoped<AdminWorkflowService>();
|
builder.Services.AddScoped<AdminWorkflowService>();
|
||||||
builder.Services.AddScoped<ResultsWorkflowService>();
|
builder.Services.AddScoped<ResultsWorkflowService>();
|
||||||
builder.Services.AddScoped<StateWorkflowService>();
|
builder.Services.AddScoped<StateWorkflowService>();
|
||||||
|
builder.Services.AddSingleton<AuthAttemptMonitor>();
|
||||||
|
|
||||||
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
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.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 =>
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
|
||||||
{
|
{
|
||||||
@@ -53,9 +104,11 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
|||||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
|
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
|
||||||
options.SlidingExpiration = true;
|
options.SlidingExpiration = true;
|
||||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
options.ExpireTimeSpan = TimeSpan.FromHours(12);
|
||||||
options.Events = new CookieAuthenticationEvents
|
options.Events = new CookieAuthenticationEvents
|
||||||
{
|
{
|
||||||
|
OnSigningIn = EnsureSessionStartAsync,
|
||||||
|
OnValidatePrincipal = ValidateSessionLifetimeAsync,
|
||||||
OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext),
|
OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext),
|
||||||
OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext)
|
OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext)
|
||||||
};
|
};
|
||||||
@@ -66,6 +119,28 @@ builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityE
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseForwardedHeaders(BuildForwardedHeadersOptions(builder.Configuration));
|
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"];
|
var basePath = builder.Configuration["BasePath"];
|
||||||
if (!string.IsNullOrWhiteSpace(basePath))
|
if (!string.IsNullOrWhiteSpace(basePath))
|
||||||
@@ -99,6 +174,52 @@ app.MapAdminEndpoints();
|
|||||||
|
|
||||||
app.Run();
|
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)
|
static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config)
|
||||||
{
|
{
|
||||||
var options = new ForwardedHeadersOptions
|
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`.
|
- Admin authorization: authenticated account with `IsAdmin=true`.
|
||||||
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
||||||
- Storage: SQLite database under `App_Data/gamelist.db`.
|
- 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
|
## Module Ownership
|
||||||
|
|
||||||
|
|||||||
16
TASKS.md
16
TASKS.md
@@ -9,7 +9,7 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
|
|||||||
|
|
||||||
## High
|
## 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`.
|
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).
|
Risk: user-controlled names are injected into HTML and can execute script in other users' sessions (including admin interactions).
|
||||||
Tasks:
|
Tasks:
|
||||||
@@ -19,14 +19,14 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
|
|||||||
|
|
||||||
## Medium
|
## 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).
|
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.
|
Risk: password guessing and admin-key guessing are not rate-limited.
|
||||||
Tasks:
|
Tasks:
|
||||||
1. Configure ASP.NET Core rate limiting policies for `/api/auth/*` and privileged admin routes.
|
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.
|
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`.
|
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.
|
Risk: DNS rebinding and incomplete private/reserved IP filtering can allow internal network probing via server-side HTTP fetches.
|
||||||
Tasks:
|
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).
|
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.
|
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).
|
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.
|
Risk: weaker browser-side mitigation for XSS/clickjacking/content-type sniffing and transport downgrade mistakes.
|
||||||
Tasks:
|
Tasks:
|
||||||
@@ -42,7 +42,7 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
|
|||||||
2. Enable HSTS in production and verify HTTPS redirection/termination settings.
|
2. Enable HSTS in production and verify HTTPS redirection/termination settings.
|
||||||
3. Add deployment checklist validation in `IIS.md`.
|
3. Add deployment checklist validation in `IIS.md`.
|
||||||
|
|
||||||
- [ ] Restrict accepted host headers.
|
- [x] Restrict accepted host headers.
|
||||||
Evidence: `appsettings.json:8`.
|
Evidence: `appsettings.json:8`.
|
||||||
Risk: wildcard `AllowedHosts` can increase exposure to host-header abuse patterns.
|
Risk: wildcard `AllowedHosts` can increase exposure to host-header abuse patterns.
|
||||||
Tasks:
|
Tasks:
|
||||||
@@ -51,21 +51,21 @@ Scope: `Program.cs`, `Endpoints/*`, `Infrastructure/*`, `wwwroot/js/*`, deployme
|
|||||||
|
|
||||||
## Low
|
## 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`.
|
Evidence: `Endpoints/AuthValidator.cs:24`, `Infrastructure/PasswordHasher.cs:9`.
|
||||||
Risk: weak user-selected passwords remain possible; PBKDF2 cost may become insufficient over time.
|
Risk: weak user-selected passwords remain possible; PBKDF2 cost may become insufficient over time.
|
||||||
Tasks:
|
Tasks:
|
||||||
1. Enforce minimum password length/quality checks.
|
1. Enforce minimum password length/quality checks.
|
||||||
2. Review and periodically raise PBKDF2 iteration cost (or migrate to a stronger password hashing scheme).
|
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`.
|
Evidence: `Program.cs:56`.
|
||||||
Risk: 30-day sliding cookie increases exposure window for stolen session cookies.
|
Risk: 30-day sliding cookie increases exposure window for stolen session cookies.
|
||||||
Tasks:
|
Tasks:
|
||||||
1. Reduce expiration window for privileged sessions or apply step-up auth for destructive admin actions.
|
1. Reduce expiration window for privileged sessions or apply step-up auth for destructive admin actions.
|
||||||
2. Consider explicit idle timeout + absolute lifetime policy.
|
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`.
|
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.
|
Risk: a leaked `ADMIN_PASSWORD` can be reused indefinitely to create new admin accounts.
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|||||||
7
TESTS.md
7
TESTS.md
@@ -32,7 +32,8 @@ stateDiagram-v2
|
|||||||
|
|
||||||
### 1) Authentication & Identity
|
### 1) Authentication & Identity
|
||||||
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
|
- 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.
|
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
|
||||||
- Logout clears cookie.
|
- Logout clears cookie.
|
||||||
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
|
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
|
||||||
@@ -78,11 +79,13 @@ stateDiagram-v2
|
|||||||
### 7) Infrastructure/Helpers
|
### 7) Infrastructure/Helpers
|
||||||
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).
|
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).
|
||||||
- EndpointHelpers.IsValidImageUrl/IsValidHttpUrl: accepts empty, http/https; rejects others/invalid ext.
|
- 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.
|
- 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.
|
- 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.
|
- Global exception handler returns 500 with JSON body and logs error.
|
||||||
- /health returns {status:"ok"}.
|
- /health returns {status:"ok"}.
|
||||||
|
- Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes.
|
||||||
|
- Frontend regression guard tests assert modal/admin JS no longer interpolate untrusted values in vulnerable patterns.
|
||||||
|
|
||||||
## Execution Notes
|
## Execution Notes
|
||||||
- Use named test data builders for players/suggestions to keep cases small and isolated.
|
- Use named test data builders for players/suggestions to keep cases small and isolated.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "localhost;127.0.0.1;[::1]",
|
||||||
"BasePath": "",
|
"BasePath": "",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Data Source=App_Data/gamelist.db"
|
"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?
|
### 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
|
## Phasen im Überblick
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ Wenn du eine Screenshot-URL angibst, muss sie:
|
|||||||
- Direkt erreichbar sein (keine Weiterleitungen)
|
- Direkt erreichbar sein (keine Weiterleitungen)
|
||||||
- Innerhalb von ~3 Sekunden laden
|
- Innerhalb von ~3 Sekunden laden
|
||||||
- Unter **5 MB**groß sein
|
- Unter **5 MB**groß sein
|
||||||
- Nicht auf lokale oder private Hosts verweisen
|
- Nicht auf lokale, private oder reservierte Hosts verweisen
|
||||||
|
|
||||||
Screenshots sind optional.
|
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.
|
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
|
## Daten & Datenschutz
|
||||||
|
|
||||||
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen **SQLite-Datenbank** gespeichert.
|
- 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.
|
- 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.
|
- 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?
|
### 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.
|
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
|
## Phases at a Glance
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ If you include a screenshot URL, it must:
|
|||||||
- Be directly accessible (no redirects)
|
- Be directly accessible (no redirects)
|
||||||
- Load within ~3 seconds
|
- Load within ~3 seconds
|
||||||
- Be under **5 MB**
|
- Be under **5 MB**
|
||||||
- Not point to local or private hosts
|
- Not point to local, private, or reserved hosts
|
||||||
|
|
||||||
Screenshots are optional.
|
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.
|
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
|
## Data & Privacy
|
||||||
|
|
||||||
- Suggestions, votes, and phase states are stored in a shared **SQLite database**.
|
- 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.
|
- 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.
|
- 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 { t } from "./i18n.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
import { $ } from "./dom.js";
|
import { $ } from "./dom.js";
|
||||||
import { buildLinkOptionLabel, escapeHtml, truncate } from "./ui-utils.js";
|
import { buildLinkOptionLabel, truncate } from "./ui-utils.js";
|
||||||
|
|
||||||
function displayPlayerStatus(player) {
|
function displayPlayerStatus(player) {
|
||||||
if (!player) return "";
|
if (!player) return "";
|
||||||
@@ -16,14 +16,24 @@ function displayPlayerStatus(player) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildStatusSelect(player) {
|
function buildStatusSelect(player) {
|
||||||
const statusText = displayPlayerStatus(player);
|
|
||||||
const canMoveToSuggest = player.phase === "Vote";
|
const canMoveToSuggest = player.phase === "Vote";
|
||||||
return `
|
const select = document.createElement("select");
|
||||||
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}">
|
select.className = "chip admin-status-select";
|
||||||
<option value="" selected>${statusText}</option>
|
select.dataset.setPlayerPhase = player.playerId;
|
||||||
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option>
|
select.setAttribute("aria-label", t("admin.playerStatus"));
|
||||||
</select>
|
|
||||||
`;
|
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() {
|
export function renderAdminVoteStatus() {
|
||||||
@@ -36,17 +46,49 @@ export function renderAdminVoteStatus() {
|
|||||||
table.innerHTML = "";
|
table.innerHTML = "";
|
||||||
state.adminVoteStatus.voters.forEach((v) => {
|
state.adminVoteStatus.voters.forEach((v) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
|
const gamesTooltip = (v.suggestionTitles || []).join(", ");
|
||||||
const nameText = escapeHtml(truncate(v.name, 28));
|
|
||||||
const userText = escapeHtml(truncate(v.username, 24));
|
const nameCell = document.createElement("td");
|
||||||
tr.innerHTML = `
|
nameCell.title = v.name ?? "";
|
||||||
<td title="${escapeHtml(v.name)}">${nameText}</td>
|
nameCell.textContent = truncate(v.name, 28);
|
||||||
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
|
|
||||||
<td>${buildStatusSelect(v)}</td>
|
const usernameCell = document.createElement("td");
|
||||||
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
|
usernameCell.className = "muted small";
|
||||||
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
|
usernameCell.title = v.username ?? "";
|
||||||
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>
|
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);
|
table.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import { t } from "./i18n.js";
|
import { t } from "./i18n.js";
|
||||||
import { toast } from "./dom.js";
|
import { toast } from "./dom.js";
|
||||||
import { escapeHtml } from "./ui-utils.js";
|
|
||||||
|
|
||||||
export function openLightbox(url, title) {
|
export function openLightbox(url, title) {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.className = "lightbox";
|
overlay.className = "lightbox";
|
||||||
const safeTitle = escapeHtml(title || "");
|
|
||||||
overlay.innerHTML = `
|
const content = document.createElement("div");
|
||||||
<div class="lightbox-content">
|
content.className = "lightbox-content";
|
||||||
<button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
|
|
||||||
<img src="${url}" alt="${safeTitle}" />
|
const closeBtn = document.createElement("button");
|
||||||
<p>${safeTitle}</p>
|
closeBtn.className = "lightbox-close";
|
||||||
</div>
|
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) => {
|
overlay.addEventListener("click", (e) => {
|
||||||
if (
|
if (
|
||||||
e.target.classList.contains("lightbox") ||
|
e.target.classList.contains("lightbox") ||
|
||||||
@@ -38,15 +48,28 @@ export function openConfirmModal({
|
|||||||
overlay.className = "edit-modal";
|
overlay.className = "edit-modal";
|
||||||
const panel = document.createElement("div");
|
const panel = document.createElement("div");
|
||||||
panel.className = "edit-panel";
|
panel.className = "edit-panel";
|
||||||
panel.innerHTML = `
|
|
||||||
<div class="edit-header">
|
const header = document.createElement("div");
|
||||||
<h3>${title}</h3>
|
header.className = "edit-header";
|
||||||
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
|
|
||||||
</div>
|
const heading = document.createElement("h3");
|
||||||
<div class="edit-body">
|
heading.textContent = title ?? "";
|
||||||
<p>${body}</p>
|
|
||||||
</div>
|
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 close = () => overlay.remove();
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "stack horizontal confirm-actions";
|
actions.className = "stack horizontal confirm-actions";
|
||||||
@@ -63,7 +86,7 @@ export function openConfirmModal({
|
|||||||
actions.append(cancelBtn);
|
actions.append(cancelBtn);
|
||||||
cancelBtn.addEventListener("click", close);
|
cancelBtn.addEventListener("click", close);
|
||||||
}
|
}
|
||||||
const bodyContainer = panel.querySelector(".edit-body");
|
const bodyContainer = bodyWrap;
|
||||||
let passwordInput = null;
|
let passwordInput = null;
|
||||||
if (requirePassword && bodyContainer) {
|
if (requirePassword && bodyContainer) {
|
||||||
const field = document.createElement("label");
|
const field = document.createElement("label");
|
||||||
|
|||||||
Reference in New Issue
Block a user