From 8955279ef102ebd8aeac46e8c68c4006419f860b Mon Sep 17 00:00:00 2001 From: Heimdall Date: Tue, 10 Nov 2026 14:06:22 +0000 Subject: [PATCH 1/2] Reserve Matrix localparts and system names at identity creation The identity becomes the Matrix localpart at SSO, so a new account must not mint a name that a Matrix account already holds (the 13 Synapse localparts) or a system name. Native register refuses a reserved name; a derived identity takes the next free suffix. Lookups of existing accounts and existing rows are untouched; there is no exemption path. Co-Authored-By: Claude Sonnet 5.5 --- .../Membership/BifrostIdentityService.cs | 23 +++- .../ReservedIdentityTests.cs | 122 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/Bifrost.Web.Tests/ReservedIdentityTests.cs diff --git a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs index d485b91..af6596e 100644 --- a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs +++ b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs @@ -18,6 +18,11 @@ public sealed partial class BifrostIdentityService( CancellationToken cancellationToken) { var normalizedIdentity = NormalizeIdentity(identity); + if (IsReserved(normalizedIdentity)) + { + throw new BifrostIdentityException("That Bifrost identity is reserved."); + } + var exists = await dbContext.UserAccounts.AnyAsync( x => x.NormalizedBifrostIdentity == normalizedIdentity, cancellationToken); @@ -69,7 +74,9 @@ public sealed partial class BifrostIdentityService( var normalizedBase = NormalizeCandidate(preferredIdentity); var normalizedIdentity = normalizedBase; - for (var suffix = 2; await IdentityExistsAsync(userAccount.Id, normalizedIdentity, cancellationToken); suffix++) + for (var suffix = 2; + IsReserved(normalizedIdentity) || await IdentityExistsAsync(userAccount.Id, normalizedIdentity, cancellationToken); + suffix++) { normalizedIdentity = $"{normalizedBase}-{suffix}"; } @@ -104,6 +111,20 @@ public sealed partial class BifrostIdentityService( return new ClaimsPrincipal(identity); } + // Matrix localparts that already belong to someone else, and system names. The identity becomes the Matrix + // localpart at SSO, so a new account must never mint one of these. Applied to the normalized identity when an + // account is created (native register refuses, a derived identity takes the next free suffix); lookups of + // existing accounts go through NormalizeIdentity and are untouched. There is no exemption path: Minds and + // other known holders do not sign in through Bifrost identities. + private static readonly HashSet ReservedIdentities = new(StringComparer.OrdinalIgnoreCase) + { + "admin", "aqua", "deru", "eureka", "heimdall", "ivixey", "kiko", "libby", "meta", "nibu", "unnie", "void", "weksa", + "root", "system", "matrix", "synapse", "support", "bot", "mod", "moderator", "postmaster", "gamecult", + "bifrost", "idunn", "odin", "huginn", "ratatoskr" + }; + + public static bool IsReserved(string normalizedIdentity) => ReservedIdentities.Contains(normalizedIdentity); + public static string NormalizeIdentity(string identity) { var normalized = identity.Trim().ToLowerInvariant(); diff --git a/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs b/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs new file mode 100644 index 0000000..959329b --- /dev/null +++ b/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs @@ -0,0 +1,122 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Tests; + +// The identity becomes the Matrix localpart at SSO, so a name a Matrix account already holds (or a system name) must +// not be minted for a new account. Native register refuses it; a derived identity takes the next free suffix. +public sealed class ReservedIdentityTests +{ + private static BifrostIdentityService NewService(out BifrostDbContext db) + { + db = new BifrostDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"reserved-{Guid.NewGuid():N}") + .Options); + return new BifrostIdentityService(db, TimeProvider.System); + } + + [Theory] + [InlineData("meta")] + [InlineData("eureka")] + [InlineData("admin")] + [InlineData("heimdall")] + [InlineData("root")] + [InlineData("postmaster")] + public async Task Native_register_refuses_a_reserved_name(string name) + { + var service = NewService(out var db); + + var ex = await Assert.ThrowsAsync(() => service.RegisterNativeAsync(name, null, default)); + + Assert.Equal("That Bifrost identity is reserved.", ex.Message); + Assert.Empty(db.UserAccounts); + } + + [Theory] + [InlineData("Meta")] + [InlineData("ADMIN")] + [InlineData(" EuReKa ")] + public async Task Native_register_refuses_a_reserved_name_in_any_case(string name) + { + var service = NewService(out var db); + + await Assert.ThrowsAsync(() => service.RegisterNativeAsync(name, null, default)); + + Assert.Empty(db.UserAccounts); + } + + [Theory] + [InlineData("meta2")] + [InlineData("meta-x")] + [InlineData("admins")] + [InlineData("ordinary-member")] + public async Task Native_register_accepts_a_name_that_only_resembles_a_reserved_one(string name) + { + var service = NewService(out var db); + + var account = await service.RegisterNativeAsync(name, null, default); + + Assert.Equal(name, account.NormalizedBifrostIdentity); + Assert.Single(db.UserAccounts); + } + + [Theory] + [InlineData("Meta", "meta-2")] + [InlineData("Admin", "admin-2")] + [InlineData("Eureka!", "eureka-2")] + public async Task A_derived_identity_never_lands_on_a_reserved_name(string preferred, string expected) + { + var service = NewService(out var db); + var account = new UserAccount { DisplayName = preferred }; + + await service.EnsureIdentityAsync(account, preferred, default); + + Assert.Equal(expected, account.NormalizedBifrostIdentity); + Assert.Equal(expected, account.BifrostIdentity); + } + + [Fact] + public async Task A_derived_identity_skips_the_reserved_name_and_a_taken_suffix() + { + var service = NewService(out var db); + db.UserAccounts.Add(new UserAccount { BifrostIdentity = "meta-2", NormalizedBifrostIdentity = "meta-2", DisplayName = "x" }); + await db.SaveChangesAsync(); + var account = new UserAccount { DisplayName = "Meta" }; + + await service.EnsureIdentityAsync(account, "Meta", default); + + Assert.Equal("meta-3", account.NormalizedBifrostIdentity); + } + + [Fact] + public async Task An_ordinary_derived_identity_is_unchanged() + { + var service = NewService(out _); + var account = new UserAccount { DisplayName = "Some Person" }; + + await service.EnsureIdentityAsync(account, "Some Person", default); + + Assert.Equal("some-person", account.NormalizedBifrostIdentity); + } + + [Fact] + public async Task An_account_that_already_holds_a_reserved_identity_is_left_alone() + { + var service = NewService(out _); + var account = new UserAccount { DisplayName = "x", BifrostIdentity = "meta", NormalizedBifrostIdentity = "meta" }; + + await service.EnsureIdentityAsync(account, "something-else", default); + + Assert.Equal("meta", account.NormalizedBifrostIdentity); + } + + [Fact] + public void Lookup_normalization_still_accepts_a_reserved_name() + { + Assert.Equal("meta", BifrostIdentityService.NormalizeIdentity("Meta")); + Assert.True(BifrostIdentityService.IsReserved("meta")); + Assert.False(BifrostIdentityService.IsReserved("meta-2")); + } +} -- 2.47.3 From 5ba6571fa67274f327ad098e96adb2824c3a6586 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Tue, 10 Nov 2026 14:30:50 +0000 Subject: [PATCH 2/2] Test the whole reserved set through both paths and pin case-insensitive matching Co-Authored-By: Claude Sonnet 5.5 --- .../ReservedIdentityTests.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs b/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs index 959329b..035a1e0 100644 --- a/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs +++ b/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs @@ -90,6 +90,71 @@ public sealed class ReservedIdentityTests Assert.Equal("meta-3", account.NormalizedBifrostIdentity); } + // The whole set, so dropping or misspelling any one name fails here. + public static IEnumerable EveryReservedName() => + new[] + { + "admin", "aqua", "deru", "eureka", "heimdall", "ivixey", "kiko", "libby", "meta", "nibu", "unnie", "void", "weksa", + "root", "system", "matrix", "synapse", "support", "bot", "mod", "moderator", "postmaster", "gamecult", + "bifrost", "idunn", "odin", "huginn", "ratatoskr" + }.Select(name => new object[] { name }); + + [Fact] + public void The_reserved_set_is_exactly_the_28_named_names() + { + Assert.Equal(28, EveryReservedName().Count()); + Assert.All(EveryReservedName().Select(x => (string)x[0]), name => Assert.True(BifrostIdentityService.IsReserved(name), name)); + } + + [Theory] + [MemberData(nameof(EveryReservedName))] + public async Task Every_reserved_name_is_refused_by_native_register(string name) + { + var service = NewService(out var db); + + var ex = await Assert.ThrowsAsync(() => service.RegisterNativeAsync(name, null, default)); + + Assert.Equal("That Bifrost identity is reserved.", ex.Message); + Assert.Empty(db.UserAccounts); + } + + [Theory] + [MemberData(nameof(EveryReservedName))] + public async Task Every_reserved_name_takes_a_suffix_as_a_derived_identity(string name) + { + var service = NewService(out _); + var account = new UserAccount { DisplayName = name }; + + await service.EnsureIdentityAsync(account, name, default); + + Assert.Equal($"{name}-2", account.NormalizedBifrostIdentity); + } + + [Theory] + [InlineData("Meta")] + [InlineData("mETA")] + [InlineData("RATATOSKR")] + [InlineData("Postmaster")] + public void Matching_is_case_insensitive_on_the_set_itself(string mixedCase) + { + Assert.True(BifrostIdentityService.IsReserved(mixedCase)); + } + + [Theory] + [InlineData("MeTa")] + [InlineData("RATATOSKR")] + [InlineData(" Moderator ")] + public async Task Mixed_case_input_is_refused_by_register_and_suffixed_when_derived(string input) + { + var service = NewService(out var db); + await Assert.ThrowsAsync(() => service.RegisterNativeAsync(input, null, default)); + Assert.Empty(db.UserAccounts); + + var account = new UserAccount { DisplayName = input }; + await service.EnsureIdentityAsync(account, input, default); + Assert.Equal(input.Trim().ToLowerInvariant() + "-2", account.NormalizedBifrostIdentity); + } + [Fact] public async Task An_ordinary_derived_identity_is_unchanged() { -- 2.47.3