diff --git a/internal/api/handlers_hasjoined.go b/internal/api/handlers_hasjoined.go index ff03a81..8974ca5 100644 --- a/internal/api/handlers_hasjoined.go +++ b/internal/api/handlers_hasjoined.go @@ -66,11 +66,14 @@ type AuthSource struct { } // sessionProfile is the Mojang hasJoined contract. properties is relayed verbatim -// (json.RawMessage) so a source's signed textures survive the multiplexer untouched. +// (json.RawMessage) so a source's signed textures survive the multiplexer untouched, and +// it is always emitted as an array: Velocity's GameProfile parser throws on a missing or +// null properties key, while a Yggdrasil root may legitimately send [] or omit it for a +// player with no skin. type sessionProfile struct { ID string `json:"id"` Name string `json:"name"` - Properties []json.RawMessage `json:"properties,omitempty"` + Properties []json.RawMessage `json:"properties"` } // HasJoinedHandler returns an http.Handler serving only the Felis-nano hasJoined @@ -151,6 +154,9 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) { // Emit the canonical UUID undashed — the 32-hex form authlib's GameProfile expects. prof.ID = hex.EncodeToString(canonical[:]) + if prof.Properties == nil { + prof.Properties = []json.RawMessage{} // a nil slice would marshal as null + } writeJSON(w, http.StatusOK, prof) } diff --git a/internal/api/handlers_hasjoined_test.go b/internal/api/handlers_hasjoined_test.go index 6bffed7..60e9987 100644 --- a/internal/api/handlers_hasjoined_test.go +++ b/internal/api/handlers_hasjoined_test.go @@ -189,6 +189,33 @@ func TestHasJoined(t *testing.T) { } }) + // A skinless player's profile may come back with properties [], null or absent. The + // relay must still send an array: Velocity's GameProfile parser throws on a missing or + // null key and the login hangs, where the same answer sent straight to Velocity works. + t.Run("properties always emitted as an array", func(t *testing.T) { + for _, upstream := range []string{ + `{"id":"` + notchMojangID + `","name":"Notch","properties":[]}`, + `{"id":"` + notchMojangID + `","name":"Notch","properties":null}`, + `{"id":"` + notchMojangID + `","name":"Notch"}`, + } { + src := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(upstream)) + })) + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{{Tag: "mojang", URL: src.URL, Identity: true}} + + w := getHasJoined(api.InternalHandler(), "Notch", "abc") + src.Close() + var body map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || w.Code != http.StatusOK { + t.Fatalf("upstream %s: code = %d body = %q", upstream, w.Code, w.Body.String()) + } + if got := string(body["properties"]); got != "[]" { + t.Errorf("upstream %s: properties = %q, want []", upstream, got) + } + } + }) + // A root that answers with a redirect is skipped, not followed: following it lets that // root aim this host at arbitrary URLs, including its own hasJoined route, which re-enters // the scan and multiplies the upstream traffic one login causes.