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.");
}