diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index e0a579b..0ed8b7a 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -31,6 +31,7 @@ public class Config public LoggingConfig Logging { get; init; } = new(); public DatabaseConfig Database { get; init; } = new(); public StorageConfig Storage { get; init; } = new(); + public LimitsConfig Limits { get; init; } = new(); public EmailAuthConfig EmailAuth { get; init; } = new(); public DiscordAuthConfig DiscordAuth { get; init; } = new(); public GoogleAuthConfig GoogleAuth { get; init; } = new(); @@ -93,4 +94,17 @@ public class Config public string? ClientId { get; init; } public string? ClientSecret { get; init; } } + + public class LimitsConfig + { + public int MaxMemberCount { get; init; } = 1000; + + public int MaxUsernameLength { get; init; } = 40; + public int MaxMemberNameLength { get; init; } = 100; + public int MaxDisplayNameLength { get; init; } = 100; + public int MaxLinks { get; init; } = 25; + public int MaxLinkLength { get; init; } = 256; + public int MaxBioLength { get; init; } = 1024; + public int MaxAvatarLength { get; init; } = 1_500_000; + } } diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 09db30e..8f832c1 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -38,7 +38,9 @@ public class MembersController( ISnowflakeGenerator snowflakeGenerator, ObjectStorageService objectStorageService, IQueue queue, - IClock clock + IClock clock, + ValidationService validationService, + Config config ) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); @@ -65,8 +67,6 @@ public class MembersController( return Ok(memberRenderer.RenderMember(member, CurrentToken)); } - public const int MaxMemberCount = 1000; - [HttpPost("/api/v2/users/@me/members")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize("member.create")] @@ -77,10 +77,10 @@ public class MembersController( { ValidationUtils.Validate( [ - ("name", ValidationUtils.ValidateMemberName(req.Name)), - ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), - ("bio", ValidationUtils.ValidateBio(req.Bio)), - ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)), + ("name", validationService.ValidateMemberName(req.Name)), + ("display_name", validationService.ValidateDisplayName(req.DisplayName)), + ("bio", validationService.ValidateBio(req.Bio)), + ("avatar", validationService.ValidateAvatar(req.Avatar)), .. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), .. ValidationUtils.ValidateFieldEntries( req.Names?.ToArray(), @@ -91,12 +91,12 @@ public class MembersController( req.Pronouns?.ToArray(), CurrentUser!.CustomPreferences ), - .. ValidationUtils.ValidateLinks(req.Links), + .. validationService.ValidateLinks(req.Links), ] ); int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct); - if (memberCount >= MaxMemberCount) + if (memberCount >= config.Limits.MaxMemberCount) throw new ApiError.BadRequest("Maximum number of members reached"); var member = new Member @@ -163,25 +163,25 @@ public class MembersController( // These should only take effect when a member's name is changed, not on other changes. if (req.Name != null && req.Name != member.Name) { - errors.Add(("name", ValidationUtils.ValidateMemberName(req.Name))); + errors.Add(("name", validationService.ValidateMemberName(req.Name))); member.Name = req.Name; } if (req.HasProperty(nameof(req.DisplayName))) { - errors.Add(("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName))); + errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName))); member.DisplayName = req.DisplayName; } if (req.HasProperty(nameof(req.Bio))) { - errors.Add(("bio", ValidationUtils.ValidateBio(req.Bio))); + errors.Add(("bio", validationService.ValidateBio(req.Bio))); member.Bio = req.Bio; } if (req.HasProperty(nameof(req.Links))) { - errors.AddRange(ValidationUtils.ValidateLinks(req.Links)); + errors.AddRange(validationService.ValidateLinks(req.Links)); member.Links = req.Links ?? []; } @@ -228,7 +228,7 @@ public class MembersController( } if (req.HasProperty(nameof(req.Avatar))) - errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar))); + errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar))); ValidationUtils.Validate(errors); // This is fired off regardless of whether the transaction is committed diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index e22fbc1..cf86d55 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -20,7 +20,7 @@ using Microsoft.AspNetCore.Mvc; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/meta")] -public partial class MetaController : ApiControllerBase +public partial class MetaController(Config config) : ApiControllerBase { private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc"; @@ -40,8 +40,8 @@ public partial class MetaController : ApiControllerBase (int)FoxnounsMetrics.UsersActiveDayCount.Value ), new LimitsResponse( - MembersController.MaxMemberCount, - ValidationUtils.MaxBioLength, + config.Limits.MaxMemberCount, + config.Limits.MaxBioLength, ValidationUtils.MaxCustomPreferences, AuthUtils.MaxAuthMethodsPerType, FlagsController.MaxFlagCount diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index f0ae29d..6ccbff0 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -35,7 +35,8 @@ public class UsersController( UserRendererService userRenderer, ISnowflakeGenerator snowflakeGenerator, IQueue queue, - IClock clock + IClock clock, + ValidationService validationService ) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); @@ -65,25 +66,25 @@ public class UsersController( if (req.Username != null && req.Username != user.Username) { - errors.Add(("username", ValidationUtils.ValidateUsername(req.Username))); + errors.Add(("username", validationService.ValidateUsername(req.Username))); user.Username = req.Username; } if (req.HasProperty(nameof(req.DisplayName))) { - errors.Add(("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName))); + errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName))); user.DisplayName = req.DisplayName; } if (req.HasProperty(nameof(req.Bio))) { - errors.Add(("bio", ValidationUtils.ValidateBio(req.Bio))); + errors.Add(("bio", validationService.ValidateBio(req.Bio))); user.Bio = req.Bio; } if (req.HasProperty(nameof(req.Links))) { - errors.AddRange(ValidationUtils.ValidateLinks(req.Links)); + errors.AddRange(validationService.ValidateLinks(req.Links)); user.Links = req.Links ?? []; } @@ -123,7 +124,7 @@ public class UsersController( } if (req.HasProperty(nameof(req.Avatar))) - errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar))); + errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar))); if (req.HasProperty(nameof(req.MemberTitle))) { @@ -133,7 +134,9 @@ public class UsersController( } else { - errors.Add(("member_title", ValidationUtils.ValidateDisplayName(req.MemberTitle))); + errors.Add( + ("member_title", validationService.ValidateDisplayName(req.MemberTitle)) + ); user.MemberTitle = req.MemberTitle; } } diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 426ec12..07394f2 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -122,6 +122,7 @@ public static class WebApplicationExtensions .AddScoped() .AddScoped() .AddTransient() + .AddTransient() // Background services .AddHostedService() // Transient jobs diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs index 6f32dc0..80d05ac 100644 --- a/Foxnouns.Backend/Services/Auth/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -29,7 +29,8 @@ public class AuthService( ILogger logger, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator, - UserRendererService userRenderer + UserRendererService userRenderer, + ValidationService validationService ) { private readonly ILogger _logger = logger.ForContext(); @@ -49,7 +50,7 @@ public class AuthService( // Validate username and whether it's not taken ValidationUtils.Validate( [ - ("username", ValidationUtils.ValidateUsername(username)), + ("username", validationService.ValidateUsername(username)), ("password", ValidationUtils.ValidatePassword(password)), ] ); @@ -97,7 +98,7 @@ public class AuthService( AssertValidAuthType(authType, instance); // Validate username and whether it's not taken - ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(username))]); + ValidationUtils.Validate([("username", validationService.ValidateUsername(username))]); if (await db.Users.AnyAsync(u => u.Username == username, ct)) throw new ApiError.BadRequest("Username is already taken", "username", username); diff --git a/Foxnouns.Backend/Services/ValidationService.Strings.cs b/Foxnouns.Backend/Services/ValidationService.Strings.cs new file mode 100644 index 0000000..8f43052 --- /dev/null +++ b/Foxnouns.Backend/Services/ValidationService.Strings.cs @@ -0,0 +1,256 @@ +// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +using System.Text.RegularExpressions; + +namespace Foxnouns.Backend.Services; + +public partial class ValidationService +{ + private static readonly string[] InvalidUsernames = + [ + "..", + "admin", + "administrator", + "mod", + "moderator", + "api", + "page", + "pronouns", + "settings", + "pronouns.cc", + "pronounscc", + ]; + + private static readonly string[] InvalidMemberNames = + [ + // these break routing outright + ".", + "..", + // the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible + "edit", + ]; + + public ValidationError? ValidateUsername(string username) + { + if (!UsernameRegex().IsMatch(username)) + { + if (username.Length < 2) + { + return ValidationError.LengthError( + "Username is too short", + 2, + _limits.MaxUsernameLength, + username.Length + ); + } + + if (username.Length > _limits.MaxUsernameLength) + { + return ValidationError.LengthError( + "Username is too long", + 2, + _limits.MaxUsernameLength, + username.Length + ); + } + + return ValidationError.GenericValidationError( + "Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods", + username + ); + } + + if ( + InvalidUsernames.Any(u => + string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase) + ) + ) + { + return ValidationError.GenericValidationError("Username is not allowed", username); + } + + return null; + } + + public ValidationError? ValidateMemberName(string memberName) + { + if (!MemberRegex().IsMatch(memberName)) + { + if (memberName.Length < 1) + { + return ValidationError.LengthError( + "Name is too short", + 1, + _limits.MaxMemberNameLength, + memberName.Length + ); + } + + if (memberName.Length > _limits.MaxMemberNameLength) + { + return ValidationError.LengthError( + "Name is too long", + 1, + _limits.MaxMemberNameLength, + memberName.Length + ); + } + + return ValidationError.GenericValidationError( + "Member name cannot contain any of the following: " + + " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " + + "and cannot be one or two periods", + memberName + ); + } + + if ( + InvalidMemberNames.Any(u => + string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase) + ) + ) + { + return ValidationError.GenericValidationError("Name is not allowed", memberName); + } + + return null; + } + + public ValidationError? ValidateDisplayName(string? displayName) + { + if (displayName?.Length == 0) + { + return ValidationError.LengthError( + "Display name is too short", + 1, + _limits.MaxDisplayNameLength, + displayName.Length + ); + } + + if (displayName?.Length > _limits.MaxDisplayNameLength) + { + return ValidationError.LengthError( + "Display name is too long", + 1, + _limits.MaxDisplayNameLength, + displayName.Length + ); + } + + return null; + } + + public IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links) + { + if (links == null) + return []; + if (links.Length > _limits.MaxLinks) + { + return + [ + ( + "links", + ValidationError.LengthError("Too many links", 0, _limits.MaxLinks, links.Length) + ), + ]; + } + + var errors = new List<(string, ValidationError?)>(); + foreach ((string link, int idx) in links.Select((l, i) => (l, i))) + { + if (link.Length == 0) + { + errors.Add( + ( + $"links.{idx}", + ValidationError.LengthError( + "Link cannot be empty", + 1, + _limits.MaxLinkLength, + 0 + ) + ) + ); + } + else if (link.Length > _limits.MaxLinkLength) + { + errors.Add( + ( + $"links.{idx}", + ValidationError.LengthError( + "Link is too long", + 1, + _limits.MaxLinkLength, + link.Length + ) + ) + ); + } + } + + return errors; + } + + public ValidationError? ValidateBio(string? bio) + { + if (bio?.Length == 0) + { + return ValidationError.LengthError( + "Bio is too short", + 1, + _limits.MaxBioLength, + bio.Length + ); + } + + if (bio?.Length > _limits.MaxBioLength) + { + return ValidationError.LengthError( + "Bio is too long", + 1, + _limits.MaxBioLength, + bio.Length + ); + } + + return null; + } + + public ValidationError? ValidateAvatar(string? avatar) + { + if (avatar?.Length == 0) + { + return ValidationError.GenericValidationError("Avatar cannot be empty", null); + } + + if (avatar?.Length > _limits.MaxAvatarLength) + { + return ValidationError.GenericValidationError("Avatar is too large", null); + } + + return null; + } + + [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex UsernameRegex(); + + [GeneratedRegex( + """^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", + RegexOptions.IgnoreCase, + "en-US" + )] + private static partial Regex MemberRegex(); +} diff --git a/Foxnouns.Backend/Services/ValidationService.cs b/Foxnouns.Backend/Services/ValidationService.cs new file mode 100644 index 0000000..989f469 --- /dev/null +++ b/Foxnouns.Backend/Services/ValidationService.cs @@ -0,0 +1,6 @@ +namespace Foxnouns.Backend.Services; + +public partial class ValidationService(Config config) +{ + private readonly Config.LimitsConfig _limits = config.Limits; +} diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index 2ce46e2..d57eb73 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -135,7 +135,7 @@ public static class AuthUtils Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('='); public static string RandomToken(int bytes = 48) => - RandomUrlUnsafeToken() + RandomUrlUnsafeToken(bytes) // Make the token URL-safe .Replace('+', '-') .Replace('/', '_'); diff --git a/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs b/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs index d38f274..1a99993 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs @@ -12,190 +12,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using System.Text.RegularExpressions; - namespace Foxnouns.Backend.Utils; public static partial class ValidationUtils { - private static readonly string[] InvalidUsernames = - [ - "..", - "admin", - "administrator", - "mod", - "moderator", - "api", - "page", - "pronouns", - "settings", - "pronouns.cc", - "pronounscc", - ]; - - private static readonly string[] InvalidMemberNames = - [ - // these break routing outright - ".", - "..", - // the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible - "edit", - ]; - - public static ValidationError? ValidateUsername(string username) - { - if (!UsernameRegex().IsMatch(username)) - { - return username.Length switch - { - < 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length), - > 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length), - _ => ValidationError.GenericValidationError( - "Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods", - username - ), - }; - } - - if ( - InvalidUsernames.Any(u => - string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase) - ) - ) - { - return ValidationError.GenericValidationError("Username is not allowed", username); - } - - return null; - } - - public static ValidationError? ValidateMemberName(string memberName) - { - if (!MemberRegex().IsMatch(memberName)) - { - return memberName.Length switch - { - < 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length), - > 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length), - _ => ValidationError.GenericValidationError( - "Member name cannot contain any of the following: " - + " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " - + "and cannot be one or two periods", - memberName - ), - }; - } - - if ( - InvalidMemberNames.Any(u => - string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase) - ) - ) - { - return ValidationError.GenericValidationError("Name is not allowed", memberName); - } - - return null; - } - - public static ValidationError? ValidateDisplayName(string? displayName) - { - return displayName?.Length switch - { - 0 => ValidationError.LengthError( - "Display name is too short", - 1, - 100, - displayName.Length - ), - > 100 => ValidationError.LengthError( - "Display name is too long", - 1, - 100, - displayName.Length - ), - _ => null, - }; - } - - private const int MaxLinks = 25; - private const int MaxLinkLength = 256; - - public static IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links) - { - if (links == null) - return []; - if (links.Length > MaxLinks) - { - return - [ - ("links", ValidationError.LengthError("Too many links", 0, MaxLinks, links.Length)), - ]; - } - - var errors = new List<(string, ValidationError?)>(); - foreach ((string link, int idx) in links.Select((l, i) => (l, i))) - { - switch (link.Length) - { - case 0: - errors.Add( - ( - $"links.{idx}", - ValidationError.LengthError("Link cannot be empty", 1, 256, 0) - ) - ); - break; - case > MaxLinkLength: - errors.Add( - ( - $"links.{idx}", - ValidationError.LengthError( - "Link is too long", - 1, - MaxLinkLength, - link.Length - ) - ) - ); - break; - } - } - - return errors; - } - - public const int MaxBioLength = 1024; - public const int MaxAvatarLength = 1_500_000; - - public static ValidationError? ValidateBio(string? bio) - { - return bio?.Length switch - { - 0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length), - > MaxBioLength => ValidationError.LengthError( - "Bio is too long", - 1, - MaxBioLength, - bio.Length - ), - _ => null, - }; - } - - public static ValidationError? ValidateAvatar(string? avatar) - { - return avatar?.Length switch - { - 0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), - > MaxAvatarLength => ValidationError.GenericValidationError( - "Avatar is too large", - null - ), - _ => null, - }; - } - public const int MaximumReportContextLength = 512; public static ValidationError? ValidateReportContext(string? context) => @@ -223,14 +43,4 @@ public static partial class ValidationUtils ), _ => null, }; - - [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")] - private static partial Regex UsernameRegex(); - - [GeneratedRegex( - """^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", - RegexOptions.IgnoreCase, - "en-NL" - )] - private static partial Regex MemberRegex(); } diff --git a/Foxnouns.Backend/config.example.ini b/Foxnouns.Backend/config.example.ini index 9c6097e..4d7c17c 100644 --- a/Foxnouns.Backend/config.example.ini +++ b/Foxnouns.Backend/config.example.ini @@ -43,6 +43,9 @@ AccessKey = SecretKey = Bucket = pronounscc +[Limits] +MaxMemberCount = 5000 + [EmailAuth] ; The address that emails will be sent from. If not set, email auth is disabled. From = noreply@accounts.pronouns.cc