Files
Felis/internal/api/handlers_hasjoined_test.go
T
flyemoji 3338d6f0fe fix(nano): drop oversized hasJoined parameters before asking sources
username, serverId and ip were forwarded to every configured source at
whatever length the caller sent, up to the megabyte net/http allows in a
request line. Velocity never sends more than a 16-character name, a
41-character signed SHA-1 serverId and a textual IP address, so only a
direct caller reaches those sizes, and each such request cost one
oversized upstream call per source.

Any of the three over 64 bytes is now answered 204 before a source is
asked, the same as a missing username or serverId. 64 bytes still
leaves room for a 16-character name in multi-byte UTF-8.

The subtest behind this points a source that validates anything at the
handler and sends missing and oversized fields, expecting 204 and zero
upstream requests, then a well-formed login that gets 200. It replaces
the old missing-username case, whose only source was unreachable, so
the test passed even with the guard removed. Dropping the length check
now fails it on the long username; dropping the whole guard fails it on
the first missing field.
2026-09-22 13:21:43 +09:00

440 lines
18 KiB
Go

package api
import (
"bufio"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"path"
"strings"
"sync/atomic"
"testing"
"time"
"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)
}
})
// A source that could not answer has not said no. With nobody validating, the login is
// an outage whether that source errored, redirected or was unreachable.
t.Run("failing source and no validator -> 503", func(t *testing.T) {
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(down.Close)
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://elsewhere.example/hasJoined", http.StatusMovedPermanently)
}))
t.Cleanup(redirector.Close)
nobody := fakeYgg(t, "", "")
for _, failing := range []string{down.URL, redirector.URL, "http://127.0.0.1:1"} {
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{
{Tag: "mojang", URL: nobody.URL, Identity: true},
{Tag: "littleskin", Prefix: "LS", URL: failing},
}
if w := getHasJoined(api.InternalHandler(), "Ghost", "abc"); w.Code != http.StatusServiceUnavailable {
t.Fatalf("source %s: code = %d, want 503", failing, w.Code)
}
}
})
// 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.
t.Run("redirecting source is skipped, not followed", func(t *testing.T) {
stubMojangNames(t)
var followed atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
followed.Store(true)
_ = json.NewEncoder(w).Encode(map[string]any{"id": notchMojangID, "name": "Notch"})
}))
t.Cleanup(target.Close)
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/hasJoined?"+r.URL.RawQuery, http.StatusFound)
}))
t.Cleanup(redirector.Close)
honest := fakeYgg(t, "0123456789abcdef0123456789abcdef", "Steve0")
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{
{Tag: "evil", Prefix: "EV", URL: redirector.URL},
{Tag: "littleskin", Prefix: "LS", URL: honest.URL},
}
w := getHasJoined(api.InternalHandler(), "Steve0", "abc")
if followed.Load() {
t.Fatal("the redirect was followed")
}
if w.Code != http.StatusOK || profileOf(t, w).Name != "Steve0" {
t.Fatalf("code = %d body = %q, want the next source's player", w.Code, w.Body.String())
}
})
// A root whose headers blow past the cap is dropped like any failed source, even when the
// body behind them is a well-formed profile.
t.Run("oversized response headers skip the source", func(t *testing.T) {
stubMojangNames(t)
bloated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Padding", strings.Repeat("a", 64<<10))
_ = json.NewEncoder(w).Encode(map[string]any{"id": notchMojangID, "name": "Steve0"})
}))
t.Cleanup(bloated.Close)
honest := fakeYgg(t, "0123456789abcdef0123456789abcdef", "Steve0")
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{
{Tag: "evil", Prefix: "EV", URL: bloated.URL},
{Tag: "littleskin", Prefix: "LS", URL: honest.URL},
}
w := getHasJoined(api.InternalHandler(), "Steve0", "abc")
want := undashed(uuid.NewMD5(felisAuthNS, []byte("littleskin:0123456789abcdef0123456789abcdef")))
if w.Code != http.StatusOK || profileOf(t, w).ID != want {
t.Fatalf("code = %d body = %q, want the next source's player", w.Code, w.Body.String())
}
})
// 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)
}
})
// A GET that declares a body it never sends must still be answered and lose its
// connection; otherwise each such socket stays open for as long as the client likes.
t.Run("request declaring a body is refused and closed", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster())
srv := httptest.NewServer(api.InternalHandler())
t.Cleanup(srv.Close)
conn, err := net.Dial("tcp", srv.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_, _ = io.WriteString(conn, "GET /session/minecraft/hasJoined?username=a&serverId=b HTTP/1.1\r\nHost: x\r\nContent-Length: 1000\r\n\r\n")
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("no answer while the declared body never arrives: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest || !resp.Close {
t.Fatalf("code = %d close = %v, want 400 with Connection: close", resp.StatusCode, resp.Close)
}
})
// A missing or oversized field is answered 204 before any source sees it. The source
// here validates anything it is asked, so only a request that never reaches it is a 204.
t.Run("missing or oversized query field -> 204 without asking a source", func(t *testing.T) {
var hits atomic.Int32
src := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"id": notchMojangID, "name": "Notch"})
}))
t.Cleanup(src.Close)
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.AuthSources = []AuthSource{{Tag: "mojang", URL: src.URL, Identity: true}}
long := strings.Repeat("a", maxHasJoinedParam+1)
for _, query := range []string{
"serverId=abc",
"username=Notch",
"username=" + long + "&serverId=abc",
"username=Notch&serverId=" + long,
"username=Notch&serverId=abc&ip=" + long,
} {
w := do(api.InternalHandler(), "GET", "/session/minecraft/hasJoined?"+query, "", nil)
if w.Code != http.StatusNoContent || hits.Load() != 0 {
t.Fatalf("%.40s: code = %d, source asked %d times; want 204 and 0", query, w.Code, hits.Load())
}
}
if w := getHasJoined(api.InternalHandler(), "Notch", "abc"); w.Code != http.StatusOK {
t.Fatalf("a well-formed login: code = %d, want 200 (the source is live)", 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())
}
}
})
}