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.
This commit is contained in:
flyemoji committed 2026-09-22 12:52:04 +09:00
1 parent 28d3638952
commit 1976fca809
2 files changed
+35 -2

No files matched your search

+8 -2
View File
@@ -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)
}
+27
View File
@@ -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.