From 1976fca80980a7322f245d51180154b311b650a6 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 22 Sep 2026 12:52:04 +0900 Subject: [PATCH] fix(nano): always relay properties as an array sessionProfile tagged properties with omitempty, so an upstream answer of "properties": [] (or null, or no key at all) reached Velocity with no properties key. A Yggdrasil root may legitimately answer that way for a player without a skin. Velocity 3.5.1's GameProfile deserializer passes the missing key on as null and ImmutableList.copyOf throws, so that player hangs at login with nothing logged, even though the same answer sent straight to Velocity is accepted. Mojang always sends textures, which is why the premium path and the hardware runs never hit it. Drop omitempty and replace a nil slice with an empty one before the response is written. Removing omitempty alone is not enough: a nil slice marshals as null, which Velocity rejects the same way. The new subtest feeds the relay [], null and a missing key and expects "properties":[] every time. The previous handler fails all three. --- internal/api/handlers_hasjoined.go | 10 +++++++-- internal/api/handlers_hasjoined_test.go | 27 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) 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.