From 1905cac950298951935dd150b03571499252d4a2 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 22 Sep 2026 13:24:23 +0900 Subject: [PATCH] fix(config): refuse auth-source tags padded with whitespace A third-party player's UUID is hashed from the source tag byte for byte, so the tag is a permanent namespace: change it and every player of that source comes back as someone new, with their playerdata, permissions, account links and reclaim bans left behind. Nothing said so, and a tag with a stray leading or trailing space, which nobody can see in the file, loaded as a brand new namespace. Such a tag is now rejected at load, and the AuthSourceConfig doc states that the tag is permanent, case included. The charset stays otherwise open: tightening it would force existing installs to rename, which is the very thing that rekeys their players. The new test loads a tag with a trailing space, a leading space and a trailing tab through LoadNano; all three loaded before this change. --- internal/config/config.go | 16 ++++++++++++---- internal/config/config_test.go | 11 +++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 9836ced..949a7b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,8 +37,11 @@ type Config struct { // AuthSourceConfig is one [[auth_source]] entry: a third-party Yggdrasil root the // Felis-nano multiplexer federates over. Tag names the source's per-source UUID -// namespace (must be unique — two sources sharing a tag would collide onto one identity); -// URL is the full hasJoined endpoint (scheme-qualified) the query string is appended to. +// namespace (must be unique — two sources sharing a tag would collide onto one identity). +// It is permanent: every player UUID of the source is hashed from it byte for byte, so +// changing it, even its case, gives all of them new UUIDs and orphans their playerdata, +// account links and bans. URL is the full hasJoined endpoint (scheme-qualified) the query +// string is appended to. // Prefix is what a player from this source is renamed with when their name belongs to a // Mojang player (LS_steve) — player-visible, so it is written out rather than derived from // the tag, which cannot know that "littleskin" is meant to read LS. @@ -359,11 +362,16 @@ func (c *Config) validateAuthSources() error { // A player's UUID is derived from tag+":"+nativeID, and the native id is whatever the // source says it is. With a ':' allowed in tags, "guild" answering id "eu:X" hashes // exactly like "guild:eu" answering "X", so one source could mint another's players. - // Colon-free tags make the join unambiguous; nothing else about the tag is restricted, - // because renaming an existing tag would move every one of its players to a new UUID. + // Colon-free tags make the join unambiguous. The charset is otherwise left open, because + // renaming an existing tag would move every one of its players to a new UUID. if strings.Contains(s.Tag, ":") { return fmt.Errorf("config: [[auth_source]] tag %q contains ':'; the tag and a player's native id are joined with ':' to derive their UUID, so a ':' in a tag would let another source mint this source's players", s.Tag) } + // Refused for the same permanence: a stray space is invisible in the file yet is a + // different namespace, and so a different UUID for every player of the source. + if strings.TrimSpace(s.Tag) != s.Tag { + return fmt.Errorf("config: [[auth_source]] tag %q has leading or trailing whitespace; the tag is hashed into every player UUID of the source, so an invisible edit to it would give all of them new ones", s.Tag) + } // Mojang is the built-in first source. A listed "mojang" is never it: it is asked again, // after Mojang, on every login that reaches it, and nano's startup list then reads as if // Mojang had been pointed at that url. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8e0885f..b2b9024 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -308,6 +308,17 @@ url = "postgres://felis@db/felis" } } +// TestLoadRejectsPaddedAuthSourceTag: whitespace around a tag cannot be seen in the file but +// is part of the namespace every player UUID of the source is hashed from. +func TestLoadRejectsPaddedAuthSourceTag(t *testing.T) { + for _, tag := range []string{"littleskin ", " littleskin", "littleskin\t"} { + _, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \""+tag+"\"\nprefix = \"LS\"\nurl = \"https://a.example.net/hasJoined\"\n")) + if err == nil || !strings.Contains(err.Error(), "whitespace") { + t.Errorf("tag %q: err = %v, want a whitespace refusal", tag, err) + } + } +} + // TestLoadRejectsMojangAuthSourceTag: Mojang is prepended in code, so a listed "mojang" is a // second, different source that only looks like a Mojang override. func TestLoadRejectsMojangAuthSourceTag(t *testing.T) {