Files
Felis/internal/api/handlers_hasjoined_test.go
T
flyemoji fd062882ed feat(nano): give a Mojang player's name back to them, by prefixing the squatter
A premium player and a third-party player sharing a username could not both be
online. Whichever logged in second was kicked with "You are already connected to
this proxy!" -- even though the UUID rewrite had already made them two distinct
players on the backend. Velocity's player registry is keyed on the NAME (lowercased),
not the UUID, so two identities holding one name are one player as far as the proxy
is concerned, and the reclaim invariant the rewrite buys is invisible to it.

The fix needs no plugin and no state, because Velocity honours the name in the
hasJoined RESPONSE rather than pinning the one the client sent at login-start --
established by a real login, not by reading the source. So the multiplexer hands
back a different name and the collision is simply gone.

A third-party player whose name belongs to a Mojang account now joins as
PREFIX_name (LS_steve). Everyone else keeps their own name: the rename fires only
on an actual collision, decided by asking api.mojang.com whether the name is
registered. The name's owner is never the one renamed, which is 正版优先 falling out
for free -- the identity source is never rewritten, so there is no policy to encode
and no 30-day hold to track.

The premium-name answer is cached asymmetrically, because the two directions have
very different costs. "Taken" is nearly permanent (Mojang does not recycle names) and
is trusted for a day; "free" can stop being true the moment someone buys that name,
and a stale "free" leaves a squatter holding a name its real owner has just bought,
so it is trusted for ten minutes. A lookup that fails with nothing cached fails
CLOSED -- assume premium, rename the third-party player: a Mojang outage must not
become an opportunity to hold someone else's name, and being wrong that way costs a
cosmetic prefix while being wrong the other way bounces the name's owner off the
proxy. The lookup gets its own 2s client rather than sharing the 5s auth client,
since it is a SECOND Mojang round-trip on a login that already spent one.

prefix is a required, unique, 1-4 character config field rather than something
derived from the tag, because it is player-visible and no derivation can know that
"littleskin" is meant to read LS. Two sources sharing a prefix would rewrite their
same-named players onto one name, so uniqueness is enforced case-insensitively --
the proxy folds case, and LS/ls would collide there while reading as distinct here.

Also close a pre-existing hole on the path this touches: a third-party source's
profile name was relayed verbatim, so a hostile or sloppy Yggdrasil root could put
"§4admin", an empty string, or 200 characters straight into the proxy's player list.
The name is now checked against the Minecraft username charset and a bad one is a 204,
the same way a bad UUID already was.

Verified end to end on the deploy host (Velocity 3.5.1 + Paper 26.2), both branches:

  premium FLYEMOJ1     -> 195fadbd-f72e-4b9b-9f8f-f92586fe16ad, name unchanged
  LittleSkin FLYEMOJ1  -> LS_FLYEMOJ1, f1b7b6ae-f250-348a-b069-a2ec0fcae668
  both online at once, zero "already connected" rejections
  LittleSkin FelisNyaTest01 -> joins as FelisNyaTest01, no prefix, UUID still v3

The last line is the one that matters: an ordinary third-party player collides with
nobody and keeps their name, while the rewrite that keeps identities apart still ran.
Paper's "LS_FLYEMOJ1 (formerly known as li_FLYEMOJ1) joined the game" is the other
half of it -- the rename moved the player's display name and their playerdata came
along untouched, because every server-side key is the UUID and the UUID does not
depend on the name.

Known ceiling, left alone deliberately: two players of one source whose names agree
on their first 16-len(prefix)-1 characters truncate onto the same in-game name, and a
prefixed name may itself happen to be a premium name. Both cost an "already connected"
bounce, not an identity -- the UUID rewrite does not depend on the name at all.

BREAKING CHANGE: every [[auth_source]] now requires prefix = "XX" (1-4 letters or
digits, unique across sources). An existing nano felis.toml without it fails to load
with an error naming the field, rather than silently keeping the collision.
2026-07-13 13:00:54 +09:00

288 lines
12 KiB
Go

package api
import (
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"path"
"strings"
"testing"
"github.com/google/uuid"
)
// stubMojangNames points the premium-name lookup at a fake api.mojang.com that reports the
// given names as registered and every other name as free, and clears the process-wide cache
// so no subtest can see another's answers. Without it these tests would query the real
// Mojang over the network — and get a different answer on the day someone buys the name.
func stubMojangNames(t *testing.T, taken ...string) {
t.Helper()
registered := make(map[string]bool, len(taken))
for _, n := range taken {
registered[strings.ToLower(n)] = true
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.ToLower(path.Base(r.URL.Path))
if !registered[name] {
w.WriteHeader(http.StatusNotFound) // Mojang's "nobody owns this name"
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"id": notchMojangID, "name": name})
}))
t.Cleanup(srv.Close)
setProfileAPI(t, srv.URL+"/")
}
func setProfileAPI(t *testing.T, base string) {
t.Helper()
prev := mojangProfileAPI
mojangProfileAPI = base
resetPremiumCache()
t.Cleanup(func() { mojangProfileAPI = prev; resetPremiumCache() })
}
func resetPremiumCache() {
premiumNames.Lock()
clear(premiumNames.m)
premiumNames.Unlock()
}
// fakeYgg stands in for one upstream Yggdrasil root. It answers hasJoined with the
// given profile, or 204 when id == "" ("not my player") or a query field is missing —
// the same contract Mojang's real sessionserver honors.
func fakeYgg(t *testing.T, id, name string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if id == "" || q.Get("username") == "" || q.Get("serverId") == "" {
w.WriteHeader(http.StatusNoContent)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": id, "name": name,
"properties": []map[string]string{{"name": "textures", "value": "SKIN", "signature": "SIG"}},
})
}))
t.Cleanup(srv.Close)
return srv
}
func getHasJoined(h http.Handler, username, serverID string) *httptest.ResponseRecorder {
return do(h, "GET", "/session/minecraft/hasJoined?username="+username+"&serverId="+serverID, "", nil)
}
func profileOf(t *testing.T, w *httptest.ResponseRecorder) sessionProfile {
t.Helper()
var p sessionProfile
if err := json.Unmarshal(w.Body.Bytes(), &p); err != nil {
t.Fatalf("profile body not JSON: %v (%q)", err, w.Body.String())
}
return p
}
// undashed is the 32-hex form the resolver must emit (authlib's GameProfile format).
func undashed(u uuid.UUID) string { return hex.EncodeToString(u[:]) }
const notchMojangID = "069a79f444e94726a5befca90e38aaf5" // a real Mojang-space UUID, undashed
func TestHasJoined(t *testing.T) {
// A trusted (Mojang) source passes its UUID through byte-for-byte.
t.Run("mojang identity passthrough", func(t *testing.T) {
mojang := fakeYgg(t, notchMojangID, "Notch")
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{{Tag: "mojang", URL: mojang.URL, Identity: true}}
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%q)", w.Code, w.Body.String())
}
p := profileOf(t, w)
if p.ID != notchMojangID {
t.Fatalf("mojang id = %q, want unchanged %q", p.ID, notchMojangID)
}
if len(p.Properties) != 1 {
t.Fatalf("properties not relayed: %v", p.Properties)
}
})
// THE security invariant: a self-asserted source claiming a Mojang-space UUID must
// NOT be emitted as-is — it is rewritten into the per-source namespace. Without this,
// a malicious third-party could impersonate any Mojang player with full UUID fidelity
// and the reclaim/blacklist layer (keyed on "genuine Mojang has a different UUID")
// could never catch it.
t.Run("thirdparty UUID rewritten, never emitted as-is", func(t *testing.T) {
stubMojangNames(t)
evil := fakeYgg(t, notchMojangID, "Notch") // lies: returns real Notch's Mojang UUID
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: evil.URL, Identity: false}}
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200", w.Code)
}
got := profileOf(t, w).ID
if got == notchMojangID {
t.Fatalf("SECURITY: third-party Mojang-space UUID emitted as-is (%q) — impersonation open", got)
}
want := undashed(uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID)))
if got != want {
t.Fatalf("rewrite = %q, want deterministic UUIDv3 %q", got, want)
}
})
// Mojang is priority-first: when both would validate the same name, Mojang wins.
t.Run("mojang priority wins over thirdparty", func(t *testing.T) {
stubMojangNames(t, "Notch")
mojang := fakeYgg(t, notchMojangID, "Notch")
third := fakeYgg(t, "aaaaaaaaaaaa4aaaaaaaaaaaaaaaaaaa", "Notch")
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{
{Tag: "mojang", URL: mojang.URL, Identity: true},
{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false},
}
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
p := profileOf(t, w)
if p.ID != notchMojangID {
t.Fatalf("id = %q, want mojang %q (priority)", p.ID, notchMojangID)
}
// The player who OWNS the name is never the one who gets renamed, even though the
// name is (of course) a registered Mojang name.
if p.Name != "Notch" {
t.Fatalf("mojang name = %q, want unchanged %q", p.Name, "Notch")
}
})
// Mojang doesn't know the player (204) → fall through to the third-party source,
// whose profile is returned rewritten.
t.Run("fallthrough to thirdparty when mojang 204s", func(t *testing.T) {
stubMojangNames(t)
mojang := fakeYgg(t, "", "") // 204: not my player
third := fakeYgg(t, notchMojangID, "Notch")
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{
{Tag: "mojang", URL: mojang.URL, Identity: true},
{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false},
}
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200", w.Code)
}
want := undashed(uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID)))
if p := profileOf(t, w); p.ID != want {
t.Fatalf("id = %q, want rewritten thirdparty %q", p.ID, want)
}
})
// No source validates → 204 (authlib maps this to a verify failure).
t.Run("no source validates -> 204", func(t *testing.T) {
a := fakeYgg(t, "", "")
b := fakeYgg(t, "", "")
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{
{Tag: "mojang", URL: a.URL, Identity: true},
{Tag: "littleskin", URL: b.URL, Identity: false},
}
if w := getHasJoined(api.InternalHandler(), "Ghost", "abc"); w.Code != http.StatusNoContent {
t.Fatalf("code = %d, want 204", w.Code)
}
})
// The reused bar gate: a barred CANONICAL UUID is rejected at the resolver, so a
// reclaimed squatter stays out even on a consumer with no limbo plugin. Keyed on the
// dashed canonical (post-rewrite), the same form Repo.ReclaimUsername stores.
t.Run("barred canonical UUID -> 204", func(t *testing.T) {
stubMojangNames(t)
third := fakeYgg(t, notchMojangID, "Notch")
repo := newFakeRepo()
canonical := uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID))
repo.blacklist[canonical.String()] = true // barred by a prior reclaim
api := newTestAPI(repo, newFakeCluster())
api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false}}
if w := getHasJoined(api.InternalHandler(), "Notch", "abc"); w.Code != http.StatusNoContent {
t.Fatalf("barred login: code = %d, want 204", w.Code)
}
})
// Missing query fields → 204 without touching any source.
t.Run("missing username -> 204", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{{Tag: "mojang", URL: "http://127.0.0.1:0", Identity: true}}
w := do(api.InternalHandler(), "GET", "/session/minecraft/hasJoined?serverId=abc", "", nil)
if w.Code != http.StatusNoContent {
t.Fatalf("code = %d, want 204", w.Code)
}
})
}
// The username namespace is the proxy's, not ours: Velocity keys its player registry on the
// NAME, so a third-party player holding a Mojang player's name locks the owner out of their
// own proxy ("You are already connected to this proxy!") even though the UUID rewrite makes
// them different players. These cover both branches of the rename — the rename itself, and
// the far more common case of leaving an ordinary player's name alone.
func TestHasJoinedPremiumNameRename(t *testing.T) {
thirdPartyLogin := func(t *testing.T, name string) sessionProfile {
t.Helper()
third := fakeYgg(t, notchMojangID, name)
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false}}
w := getHasJoined(api.InternalHandler(), name, "abc")
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%q)", w.Code, w.Body.String())
}
return profileOf(t, w)
}
// The branch the whole "only on collision" policy exists for: nobody at Mojang owns
// this name, so the third-party player keeps it.
t.Run("free name is left alone", func(t *testing.T) {
stubMojangNames(t, "Notch") // Notch is taken; Steve0 is not
if got := thirdPartyLogin(t, "Steve0").Name; got != "Steve0" {
t.Fatalf("name = %q, want unchanged %q — a player nobody collides with must keep their name", got, "Steve0")
}
})
t.Run("premium name is prefixed", func(t *testing.T) {
stubMojangNames(t, "Notch")
if got := thirdPartyLogin(t, "Notch").Name; got != "LS_Notch" {
t.Fatalf("name = %q, want %q", got, "LS_Notch")
}
})
// A name too long to prefix must be TRUNCATED, not left alone — skipping the rename
// here is what would silently hand a long premium name back to the squatter.
t.Run("long premium name is truncated to fit", func(t *testing.T) {
stubMojangNames(t, "Antidisestablish") // 16 chars: the protocol maximum
got := thirdPartyLogin(t, "Antidisestablish").Name
if got != "LS_Antidisestabl" {
t.Fatalf("name = %q, want %q", got, "LS_Antidisestabl")
}
if len(got) > mcUsernameMax {
t.Fatalf("name %q is %d chars, over the %d-char protocol limit", got, len(got), mcUsernameMax)
}
})
// Mojang unreachable, nothing cached → assume the name is premium and rename. A Mojang
// outage must not become an opportunity to hold someone else's name.
t.Run("mojang unreachable -> fails closed and renames", func(t *testing.T) {
setProfileAPI(t, "http://127.0.0.1:1/") // nothing listening: instant connection refused
if got := thirdPartyLogin(t, "Notch").Name; got != "LS_Notch" {
t.Fatalf("name = %q, want %q — a failed lookup must not leave a squatter holding the name", got, "LS_Notch")
}
})
// A third-party root is untrusted input, its name field included.
t.Run("hostile upstream name -> 204", func(t *testing.T) {
stubMojangNames(t)
for _, bad := range []string{"§4admin", "not a name", "ab", strings.Repeat("x", 17), ""} {
third := fakeYgg(t, notchMojangID, bad)
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false}}
if w := getHasJoined(api.InternalHandler(), "Notch", "abc"); w.Code != http.StatusNoContent {
t.Fatalf("upstream name %q: code = %d, want 204 (%q)", bad, w.Code, w.Body.String())
}
}
})
}