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.
This commit is contained in:
flyemoji committed 2026-09-22 12:53:35 +09:00
1 parent 1976fca809
commit 4e98ae6e56
2 files changed
+45 -3

No files matched your search

+33
View File
@@ -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