feat(nano): federating hasJoined multiplexer with per-source UUID namespacing
This commit is contained in:
5 files changed
+362
-1
No files matched your search
@@ -0,0 +1,169 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
evil := fakeYgg(t, notchMojangID, "Notch") // lies: returns real Notch's Mojang UUID
|
||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
api.AuthSources = []AuthSource{{Tag: "littleskin", 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) {
|
||||
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", URL: third.URL, Identity: false},
|
||||
}
|
||||
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
|
||||
if p := profileOf(t, w); p.ID != notchMojangID {
|
||||
t.Fatalf("id = %q, want mojang %q (priority)", p.ID, notchMojangID)
|
||||
}
|
||||
})
|
||||
|
||||
// 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) {
|
||||
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", 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) {
|
||||
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", 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user