From 0969b2e9720f1643deebe5fae9cb257873a5ba63 Mon Sep 17 00:00:00 2001 From: Ted Wollman <25165500+TheTedder@users.noreply.github.com> Date: Sat, 10 Aug 2024 21:37:20 -0400 Subject: [PATCH] Disallow URL encoded characters in slugs. --- ...811013306_NoUrlEncodingInSlugs.Designer.cs | 376 ++++++++++++++++++ .../20240811013306_NoUrlEncodingInSlugs.cs | 54 +++ .../ApplicationContextModelSnapshot.cs | 4 +- .../Models/Entities/Category.cs | 3 +- .../Models/Entities/Leaderboard.cs | 3 +- .../Models/Validation/SlugRule.cs | 3 +- 6 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 LeaderboardBackend/Migrations/20240811013306_NoUrlEncodingInSlugs.Designer.cs create mode 100644 LeaderboardBackend/Migrations/20240811013306_NoUrlEncodingInSlugs.cs diff --git a/LeaderboardBackend/Migrations/20240811013306_NoUrlEncodingInSlugs.Designer.cs b/LeaderboardBackend/Migrations/20240811013306_NoUrlEncodingInSlugs.Designer.cs new file mode 100644 index 00000000..962edc18 --- /dev/null +++ b/LeaderboardBackend/Migrations/20240811013306_NoUrlEncodingInSlugs.Designer.cs @@ -0,0 +1,376 @@ +// +using System; +using LeaderboardBackend.Models; +using LeaderboardBackend.Models.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LeaderboardBackend.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20240811013306_NoUrlEncodingInSlugs")] + partial class NoUrlEncodingInSlugs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "run_type", new[] { "time", "score" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "sort_direction", new[] { "ascending", "descending" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_role", new[] { "registered", "confirmed", "administrator", "banned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.AccountConfirmation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_account_confirmations"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_account_confirmations_user_id"); + + b.ToTable("account_confirmations", (string)null); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.AccountRecovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_account_recoveries"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_account_recoveries_user_id"); + + b.ToTable("account_recoveries", (string)null); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Info") + .HasColumnType("text") + .HasColumnName("info"); + + b.Property("LeaderboardId") + .HasColumnType("bigint") + .HasColumnName("leaderboard_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("slug"); + + b.Property("SortDirection") + .HasColumnType("sort_direction") + .HasColumnName("sort_direction"); + + b.Property("Type") + .HasColumnType("run_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_categories"); + + b.HasIndex("LeaderboardId") + .HasDatabaseName("ix_categories_leaderboard_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_categories_slug"); + + b.ToTable("categories", null, t => + { + t.HasCheckConstraint("CK_categories_slug_MinLength", "LENGTH(slug) >= 2"); + + t.HasCheckConstraint("CK_categories_slug_RegularExpression", "slug ~ '^[a-zA-Z0-9\\-_]*$'"); + }); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Info") + .HasColumnType("text") + .HasColumnName("info"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_leaderboards"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_leaderboards_slug"); + + b.ToTable("leaderboards", null, t => + { + t.HasCheckConstraint("CK_leaderboards_slug_MinLength", "LENGTH(slug) >= 2"); + + t.HasCheckConstraint("CK_leaderboards_slug_RegularExpression", "slug ~ '^[a-zA-Z0-9\\-_]*$'"); + }); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.Run", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Info") + .HasColumnType("text") + .HasColumnName("info"); + + b.Property("PlayedOn") + .HasColumnType("date") + .HasColumnName("played_on"); + + b.Property("TimeOrScore") + .HasColumnType("bigint") + .HasColumnName("time_or_score"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_runs"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_runs_category_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_runs_user_id"); + + b.ToTable("runs", (string)null); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email") + .UseCollation("case_insensitive"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("user_role") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username") + .UseCollation("case_insensitive"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.AccountConfirmation", b => + { + b.HasOne("LeaderboardBackend.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_confirmations_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.AccountRecovery", b => + { + b.HasOne("LeaderboardBackend.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_recoveries_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.Category", b => + { + b.HasOne("LeaderboardBackend.Models.Entities.Leaderboard", "Leaderboard") + .WithMany("Categories") + .HasForeignKey("LeaderboardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_categories_leaderboards_leaderboard_id"); + + b.Navigation("Leaderboard"); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.Run", b => + { + b.HasOne("LeaderboardBackend.Models.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_runs_categories_category_id"); + + b.HasOne("LeaderboardBackend.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_runs_users_user_id"); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LeaderboardBackend.Models.Entities.Leaderboard", b => + { + b.Navigation("Categories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LeaderboardBackend/Migrations/20240811013306_NoUrlEncodingInSlugs.cs b/LeaderboardBackend/Migrations/20240811013306_NoUrlEncodingInSlugs.cs new file mode 100644 index 00000000..44e555f8 --- /dev/null +++ b/LeaderboardBackend/Migrations/20240811013306_NoUrlEncodingInSlugs.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LeaderboardBackend.Migrations +{ + /// + public partial class NoUrlEncodingInSlugs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_leaderboards_slug_RegularExpression", + table: "leaderboards"); + + migrationBuilder.DropCheckConstraint( + name: "CK_categories_slug_RegularExpression", + table: "categories"); + + migrationBuilder.AddCheckConstraint( + name: "CK_leaderboards_slug_RegularExpression", + table: "leaderboards", + sql: "slug ~ '^[a-zA-Z0-9\\-_]*$'"); + + migrationBuilder.AddCheckConstraint( + name: "CK_categories_slug_RegularExpression", + table: "categories", + sql: "slug ~ '^[a-zA-Z0-9\\-_]*$'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_leaderboards_slug_RegularExpression", + table: "leaderboards"); + + migrationBuilder.DropCheckConstraint( + name: "CK_categories_slug_RegularExpression", + table: "categories"); + + migrationBuilder.AddCheckConstraint( + name: "CK_leaderboards_slug_RegularExpression", + table: "leaderboards", + sql: "slug ~ '^([a-zA-Z0-9\\-_]|%[A-F0-9]{2})*$'"); + + migrationBuilder.AddCheckConstraint( + name: "CK_categories_slug_RegularExpression", + table: "categories", + sql: "slug ~ '^([a-zA-Z0-9\\-_]|%[A-F0-9]{2})*$'"); + } + } +} diff --git a/LeaderboardBackend/Migrations/ApplicationContextModelSnapshot.cs b/LeaderboardBackend/Migrations/ApplicationContextModelSnapshot.cs index 05eab4f2..7f5cffd5 100644 --- a/LeaderboardBackend/Migrations/ApplicationContextModelSnapshot.cs +++ b/LeaderboardBackend/Migrations/ApplicationContextModelSnapshot.cs @@ -154,7 +154,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { t.HasCheckConstraint("CK_categories_slug_MinLength", "LENGTH(slug) >= 2"); - t.HasCheckConstraint("CK_categories_slug_RegularExpression", "slug ~ '^([a-zA-Z0-9\\-_]|%[A-F0-9]{2})*$'"); + t.HasCheckConstraint("CK_categories_slug_RegularExpression", "slug ~ '^[a-zA-Z0-9\\-_]*$'"); }); }); @@ -205,7 +205,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { t.HasCheckConstraint("CK_leaderboards_slug_MinLength", "LENGTH(slug) >= 2"); - t.HasCheckConstraint("CK_leaderboards_slug_RegularExpression", "slug ~ '^([a-zA-Z0-9\\-_]|%[A-F0-9]{2})*$'"); + t.HasCheckConstraint("CK_leaderboards_slug_RegularExpression", "slug ~ '^[a-zA-Z0-9\\-_]*$'"); }); }); diff --git a/LeaderboardBackend/Models/Entities/Category.cs b/LeaderboardBackend/Models/Entities/Category.cs index 52e9d186..7cc3cb13 100644 --- a/LeaderboardBackend/Models/Entities/Category.cs +++ b/LeaderboardBackend/Models/Entities/Category.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using LeaderboardBackend.Models.Validation; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -44,7 +45,7 @@ public class Category /// /// foo-bar-baz [StringLength(80, MinimumLength = 2)] - [RegularExpression(@"^([a-zA-Z0-9\-_]|%[A-F0-9]{2})*$")] + [RegularExpression(SlugRule.REGEX)] public required string Slug { get; set; } /// diff --git a/LeaderboardBackend/Models/Entities/Leaderboard.cs b/LeaderboardBackend/Models/Entities/Leaderboard.cs index de518476..26399eb8 100644 --- a/LeaderboardBackend/Models/Entities/Leaderboard.cs +++ b/LeaderboardBackend/Models/Entities/Leaderboard.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using LeaderboardBackend.Models.Validation; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -28,7 +29,7 @@ public class Leaderboard /// /// foo-bar [StringLength(80, MinimumLength = 2)] - [RegularExpression(@"^([a-zA-Z0-9\-_]|%[A-F0-9]{2})*$")] + [RegularExpression(SlugRule.REGEX)] public required string Slug { get; set; } /// diff --git a/LeaderboardBackend/Models/Validation/SlugRule.cs b/LeaderboardBackend/Models/Validation/SlugRule.cs index 3630540b..89181749 100644 --- a/LeaderboardBackend/Models/Validation/SlugRule.cs +++ b/LeaderboardBackend/Models/Validation/SlugRule.cs @@ -5,12 +5,13 @@ namespace LeaderboardBackend.Models.Validation; public static class SlugRule { public const string SLUG_FORMAT = "SlugFormat"; + public const string REGEX = @"^[a-zA-Z0-9\-_]*$"; public static IRuleBuilderOptions Slug(this IRuleBuilder ruleBuilder) => ruleBuilder .Length(2, 80) .WithErrorCode(SLUG_FORMAT) - .Matches(@"^([a-zA-Z0-9-_]|%[A-F0-9]{2})*$") + .Matches(REGEX) .WithErrorCode(SLUG_FORMAT) .WithMessage("Invalid slug format."); }