From 4e98ae6e56175e8486227617564a0765d46f4acf Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 22 Sep 2026 12:53:35 +0900 Subject: [PATCH] fix(config): reject auth-source tags that contain a colon A third-party player's canonical UUID is UUIDv3 over tag+":"+nativeID, and the native id is whatever the source answers. Tags were only checked for being non-empty and unique, so both "guild" and "guild:eu" could be configured. The "guild" root could then answer hasJoined with id "eu:X" and receive exactly the UUID of "guild:eu"'s player X, along with their playerdata, permissions and account links. Real native ids are 32 hex digits, so only the shorter tag's source can do this, and only when the operator has configured such a pair; when they have, it is a full impersonation. Reject a ':' in a tag at load. With colon-free tags the join is unambiguous: two different (tag, id) pairs can no longer produce the same input, since equal inputs force equal tags and duplicate tags are already refused. The tag is deliberately not narrowed any further. It is a permanent UUID namespace, and forcing an operator to rename a tag that has no colon would move every one of its players to a new UUID. The hash input and the native id are left exactly as they were, so no existing player's UUID changes. Load and LoadNano share validateAuthSources; the new test runs both against the guild / guild:eu pair and fails on the previous config.go. --- internal/config/config.go | 15 ++++++++++++--- internal/config/config_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index d3769ef..ee9b383 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -339,9 +339,10 @@ var authSourcePrefixRe = regexp.MustCompile(`^[A-Za-z0-9]{1,4}$`) // validateAuthSources checks the [[auth_source]] block: each needs a namespace tag, a rename // prefix, and a scheme-qualified hasJoined URL, and both tag and prefix must be unique. A -// blank or duplicate tag collapses two sources into one UUID namespace (cross-source -// impersonation — the exact invariant the per-source rewrite exists to hold); a duplicate -// prefix collapses two same-named players from different sources onto one in-game name +// blank, duplicate or colon-bearing tag collapses two sources into one UUID namespace +// (cross-source impersonation — the exact invariant the per-source rewrite exists to +// hold); a duplicate prefix collapses two same-named players from different sources onto +// one in-game name // (they stay distinct identities, but neither can be online while the other is); a // scheme-less URL makes http.NewRequest fail so the source is silently dead (never validates // any login). All fail fast at load, not per-login. Split out from Validate so the nano-only @@ -354,6 +355,14 @@ func (c *Config) validateAuthSources() error { if s.Tag == "" { return fmt.Errorf("config: [[auth_source]] #%d has an empty tag; each source's tag is its per-source UUID namespace", i+1) } + // 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. + 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) + } if _, dup := seenTags[s.Tag]; dup { return fmt.Errorf("config: [[auth_source]] tag %q is used twice — tags are per-source UUID namespaces and must be unique", s.Tag) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e733455..50d6bb4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -275,6 +275,39 @@ url = "https://b.example.net/hasJoined" } } +// TestLoadRejectsColonInAuthSourceTag pins the separator guard. The UUID of a third-party +// player is derived from tag+":"+nativeID, and the native id is chosen by the source, so with +// "guild" and "guild:eu" both configured the "guild" root could answer id "eu:X" and receive +// the UUID of "guild:eu"'s player X. Both loaders share the check, so both are exercised. +func TestLoadRejectsColonInAuthSourceTag(t *testing.T) { + const sources = ` +[[auth_source]] +tag = "guild" +prefix = "GD" +url = "https://a.example.net/hasJoined" +[[auth_source]] +tag = "guild:eu" +prefix = "GE" +url = "https://b.example.net/hasJoined" +` + _, errFull := config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +`+sources)) + _, errNano := config.LoadNano(writeTOML(t, sources)) + for loader, err := range map[string]error{"Load": errFull, "LoadNano": errNano} { + if err == nil { + t.Errorf("%s accepted a tag containing ':'", loader) + continue + } + if !strings.Contains(err.Error(), `"guild:eu"`) || !strings.Contains(err.Error(), "':'") { + t.Errorf("%s: error should name the tag and the ':' rule, got: %v", loader, err) + } + } +} + // TestLoadRejectsSchemelessAuthSourceURL pins the silently-dead-source guard: a URL with no // http(s):// scheme makes http.NewRequest fail, so the source never validates any login yet // felis-api boots green. Reject at load with the scheme contract spelled out. An empty tag