diff --git a/Guestlist/AccessCodeService.cs b/Guestlist/AccessCodeService.cs index bea04bd..0c2f216 100644 --- a/Guestlist/AccessCodeService.cs +++ b/Guestlist/AccessCodeService.cs @@ -12,22 +12,28 @@ public sealed class AccessCodeService(MongoDbContext db) private readonly PasswordHasher _hasher = new(); + public async Task EnsureIndexesAsync() + { + var activeIndex = new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.IsActive)); + + await _accessCodes.Indexes.CreateOneAsync(activeIndex); + } + public async Task SetGuestAccessCodeAsync(string code, string label = "Guest access") { - if (string.IsNullOrWhiteSpace(code)) - { - throw new InvalidOperationException("Guest access code cannot be empty."); - } + var cleanedCode = InputRules.CleanAccessCode(code); + var cleanedLabel = InputRules.CleanRequiredText(label, "a label", 80); var document = new AccessCodeDocument { Id = GuestAccessCodeId, - Label = label, + Label = cleanedLabel, IsActive = true, CreatedAt = DateTime.UtcNow }; - document.CodeHash = _hasher.HashPassword(document, code); + document.CodeHash = _hasher.HashPassword(document, cleanedCode); await _accessCodes.ReplaceOneAsync( x => x.Id == GuestAccessCodeId, @@ -42,7 +48,13 @@ public sealed class AccessCodeService(MongoDbContext db) public async Task ValidateGuestAccessCodeAsync(string code) { - if (string.IsNullOrWhiteSpace(code)) + string cleanedCode; + + try + { + cleanedCode = InputRules.CleanAccessCode(code); + } + catch (InvalidOperationException) { return false; } @@ -64,7 +76,7 @@ public sealed class AccessCodeService(MongoDbContext db) var result = _hasher.VerifyHashedPassword( document, document.CodeHash, - code); + cleanedCode); return result == PasswordVerificationResult.Success; } diff --git a/Guestlist/Components/Pages/Guests.razor.cs b/Guestlist/Components/Pages/Guests.razor.cs index 5c9b76a..d48541e 100644 --- a/Guestlist/Components/Pages/Guests.razor.cs +++ b/Guestlist/Components/Pages/Guests.razor.cs @@ -97,6 +97,11 @@ public partial class Guests : ComponentBase userId, username); } + catch (InvalidOperationException exception) + { + ErrorMessage = exception.Message; + return; + } catch { ErrorMessage = "Guest could not be added."; @@ -180,6 +185,11 @@ public partial class Guests : ComponentBase EditGuestName, EditGuestNotes); } + catch (InvalidOperationException exception) + { + ErrorMessage = exception.Message; + return; + } catch { ErrorMessage = "Guest could not be updated."; diff --git a/Guestlist/Components/Pages/Home.razor b/Guestlist/Components/Pages/Home.razor index 7c6323c..93184f8 100644 --- a/Guestlist/Components/Pages/Home.razor +++ b/Guestlist/Components/Pages/Home.razor @@ -10,7 +10,7 @@

Manage guests

Add, edit and delete guest entries.

- Open management + Open management
@@ -18,7 +18,7 @@

Guest list

View the read-only list. Guests are asked for an access code when needed.

- Open guest list + Open guest list
diff --git a/Guestlist/GuestEntryService.cs b/Guestlist/GuestEntryService.cs index 4b9bc13..7d48474 100644 --- a/Guestlist/GuestEntryService.cs +++ b/Guestlist/GuestEntryService.cs @@ -15,11 +15,26 @@ public sealed class GuestEntryService(MongoDbContext db) .ToListAsync(); } + public async Task EnsureIndexesAsync() + { + var nameIndex = new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Name)); + + var createdAtIndex = new CreateIndexModel( + Builders.IndexKeys.Descending(x => x.CreatedAt)); + + await _guestEntries.Indexes.CreateManyAsync([nameIndex, createdAtIndex]); + } + public async Task NameExistsAsync(string name, string? excludeId = null) { - var normalizedName = name.Trim(); + string normalizedName; - if (string.IsNullOrWhiteSpace(normalizedName)) + try + { + normalizedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength); + } + catch (InvalidOperationException) { return false; } @@ -38,14 +53,19 @@ public sealed class GuestEntryService(MongoDbContext db) public async Task NameAndNotesExistAsync(string name, string? notes, string? excludeId = null) { - var normalizedName = name.Trim(); - var normalizedNotes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); + string normalizedName; - if (string.IsNullOrWhiteSpace(normalizedName)) + try + { + normalizedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength); + } + catch (InvalidOperationException) { return false; } + var normalizedNotes = InputRules.CleanOptionalText(notes, "Notes", InputRules.GuestNotesMaxLength); + var filter = Builders.Filter.Regex( x => x.Name, new MongoDB.Bson.BsonRegularExpression($"^{System.Text.RegularExpressions.Regex.Escape(normalizedName)}$", "i")); @@ -68,10 +88,13 @@ public sealed class GuestEntryService(MongoDbContext db) public async Task AddAsync(string name, string? notes, string addedByUserId, string addedByUsername) { + var cleanedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength); + var cleanedNotes = InputRules.CleanOptionalText(notes, "Notes", InputRules.GuestNotesMaxLength); + var entry = new GuestEntryDocument { - Name = name.Trim(), - Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(), + Name = cleanedName, + Notes = cleanedNotes, AddedByUserId = addedByUserId, AddedByUsername = addedByUsername, CreatedAt = DateTime.UtcNow @@ -87,9 +110,12 @@ public sealed class GuestEntryService(MongoDbContext db) public async Task UpdateAsync(string id, string name, string? notes) { + var cleanedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength); + var cleanedNotes = InputRules.CleanOptionalText(notes, "Notes", InputRules.GuestNotesMaxLength); + var update = Builders.Update - .Set(x => x.Name, name.Trim()) - .Set(x => x.Notes, string.IsNullOrWhiteSpace(notes) ? null : notes.Trim()); + .Set(x => x.Name, cleanedName) + .Set(x => x.Notes, cleanedNotes); await _guestEntries.UpdateOneAsync(x => x.Id == id, update); } diff --git a/Guestlist/Program.cs b/Guestlist/Program.cs index 57ebf2a..9062370 100644 --- a/Guestlist/Program.cs +++ b/Guestlist/Program.cs @@ -28,6 +28,11 @@ builder.Services { options.LoginPath = "/login"; options.AccessDeniedPath = "/login"; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; }); builder.Services.AddAuthorization(); @@ -43,10 +48,22 @@ builder.Services.AddSession(options => options.IdleTimeout = TimeSpan.FromHours(8); options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; }); var app = builder.Build(); +app.Use(async (context, next) => +{ + context.Response.Headers.TryAdd("X-Content-Type-Options", "nosniff"); + context.Response.Headers.TryAdd("X-Frame-Options", "DENY"); + context.Response.Headers.TryAdd("Referrer-Policy", "no-referrer"); + context.Response.Headers.TryAdd("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + + await next(); +}); + app.MapPost("/auth/login", async ( HttpContext httpContext, UserService userService) => @@ -57,6 +74,13 @@ app.MapPost("/auth/login", async ( var password = form["password"].ToString(); var returnUrl = form["returnUrl"].ToString(); + if (username.Length > InputRules.UsernameMaxLength || + password.Length > InputRules.PasswordMaxLength || + returnUrl.Length > 300) + { + return Results.Redirect("/login?error=invalid"); + } + UserDocument? user; try @@ -116,6 +140,11 @@ app.MapPost("/guest/access", async ( var form = await httpContext.Request.ReadFormAsync(); var accessCode = form["accessCode"].ToString(); + if (accessCode.Length > InputRules.AccessCodeMaxLength) + { + return Results.Redirect("/guest?error=invalid"); + } + bool isValid; try @@ -147,7 +176,12 @@ app.MapPost("/guest/logout", (HttpContext httpContext) => using (var scope = app.Services.CreateScope()) { var userService = scope.ServiceProvider.GetRequiredService(); + var guestEntryService = scope.ServiceProvider.GetRequiredService(); + var accessCodeService = scope.ServiceProvider.GetRequiredService(); + await userService.EnsureIndexesAsync(); + await guestEntryService.EnsureIndexesAsync(); + await accessCodeService.EnsureIndexesAsync(); } // Configure the HTTP request pipeline. diff --git a/Guestlist/UserService.cs b/Guestlist/UserService.cs index e213dc1..a207702 100644 --- a/Guestlist/UserService.cs +++ b/Guestlist/UserService.cs @@ -35,13 +35,18 @@ public sealed class UserService(MongoDbContext db) public async Task DeleteUserByUsernameAsync(string username) { - var normalizedUsername = username.Trim().ToLowerInvariant(); + var normalizedUsername = InputRules.NormalizeUsername(username); await _users.DeleteOneAsync(x => x.Username == normalizedUsername); } private async Task CreateUserAsync(string displayName, string username, string password, string role) { - var normalizedUsername = username.Trim().ToLowerInvariant(); + var normalizedUsername = InputRules.NormalizeUsername(username); + var cleanedDisplayName = string.IsNullOrWhiteSpace(displayName) + ? normalizedUsername + : InputRules.CleanRequiredText(displayName, "a name", InputRules.DisplayNameMaxLength); + + InputRules.ValidatePassword(password); if (await UsernameExistsAsync(normalizedUsername)) { @@ -50,7 +55,7 @@ public sealed class UserService(MongoDbContext db) var user = new UserDocument { - DisplayName = displayName.Trim(), + DisplayName = cleanedDisplayName, Username = normalizedUsername, Role = role, IsActive = true, @@ -143,7 +148,7 @@ public sealed class UserService(MongoDbContext db) public async Task UsernameExistsAsync(string username) { - var normalizedUsername = username.Trim().ToLowerInvariant(); + var normalizedUsername = InputRules.NormalizeUsername(username); return await _users.CountDocumentsAsync(x => x.Username == normalizedUsername) > 0; } @@ -158,7 +163,26 @@ public sealed class UserService(MongoDbContext db) public async Task ValidateCredentialsAsync(string username, string password) { - var normalizedUsername = username.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return null; + } + + if (password.Length > InputRules.PasswordMaxLength) + { + return null; + } + + string normalizedUsername; + + try + { + normalizedUsername = InputRules.NormalizeUsername(username); + } + catch (InvalidOperationException) + { + return null; + } var user = await _users .Find(x => x.Username == normalizedUsername && x.IsActive)