isPremiumName decides on every third-party login whether the player keeps their name, and none of its rules had a test that fails when the rule breaks: treating a 429 or 5xx from api.mojang.com as "free", swapping the free and taken TTLs, flipping the freshness comparison, answering "free" from an expired taken entry during an outage, or dropping the clear-at-4096 bound. Each of those leaves a squatter holding a name its owner has bought, or grows the cache without limit, with CI green. TestPremiumNameCache drives isPremiumName against a stub that answers with a fixed status and counts lookups, and seeds cache entries at chosen ages. Five mutants of handlers_hasjoined.go, one per rule above, each fail at least one subtest. It does not test an expired "free" entry during an outage; what that case should return is still open.
523 lines
21 KiB
Go
523 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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)
|
|
}
|
|
// Also pinned as literals: every third-party player's UUID, and every ban and account
|
|
// link keyed on one, depends on these exact bytes, and a change to the namespace seed
|
|
// moves both sides of the derived comparison above at once.
|
|
if ns := felisAuthNS.String(); ns != "07228eae-77f6-500e-9dc0-436afbc87c27" || got != "b63bcc1c611432eeb7b3af3a15012e48" {
|
|
t.Fatalf("namespace %s / rewrite %s changed; every existing third-party player would get a new UUID", ns, got)
|
|
}
|
|
})
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// profileAPIAnswering stands in for api.mojang.com answering every lookup with status and
|
|
// counts how often it is asked. 200 means taken, 404 free, anything else is an outage.
|
|
func profileAPIAnswering(t *testing.T, status int) *atomic.Int32 {
|
|
t.Helper()
|
|
var n atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
n.Add(1)
|
|
w.WriteHeader(status)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
setProfileAPI(t, srv.URL+"/")
|
|
return &n
|
|
}
|
|
|
|
func seedPremium(name string, taken bool, age time.Duration) {
|
|
premiumNames.Lock()
|
|
premiumNames.m[strings.ToLower(name)] = premiumEntry{taken: taken, at: time.Now().Add(-age)}
|
|
premiumNames.Unlock()
|
|
}
|
|
|
|
// The premium-name cache decides on each third-party login whether the player keeps their
|
|
// name. Each case pins one rule that an innocent-looking edit would break unnoticed.
|
|
func TestPremiumNameCache(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
|
|
t.Run(fmt.Sprintf("mojang %d is an outage, not a free name", status), func(t *testing.T) {
|
|
profileAPIAnswering(t, status)
|
|
if !isPremiumName(ctx, "Notch") {
|
|
t.Fatal("name treated as free; a squatter would keep it through the outage")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("a free answer is asked again once it expires", func(t *testing.T) {
|
|
n := profileAPIAnswering(t, http.StatusNotFound)
|
|
seedPremium("Steve0", false, premiumFreeTTL+time.Minute)
|
|
isPremiumName(ctx, "Steve0")
|
|
if n.Load() != 1 {
|
|
t.Fatalf("expired free answer: %d lookups, want 1", n.Load())
|
|
}
|
|
})
|
|
|
|
t.Run("answers within their TTL come from the cache", func(t *testing.T) {
|
|
n := profileAPIAnswering(t, http.StatusNotFound)
|
|
seedPremium("Steve0", false, premiumFreeTTL-time.Minute)
|
|
seedPremium("Notch", true, premiumTakenTTL-time.Hour)
|
|
if isPremiumName(ctx, "Steve0") || !isPremiumName(ctx, "Notch") || n.Load() != 0 {
|
|
t.Fatalf("cached answers not served as cached (%d lookups)", n.Load())
|
|
}
|
|
})
|
|
|
|
t.Run("an expired taken answer outlives an outage", func(t *testing.T) {
|
|
profileAPIAnswering(t, http.StatusServiceUnavailable)
|
|
seedPremium("Notch", true, 2*premiumTakenTTL)
|
|
if !isPremiumName(ctx, "Notch") {
|
|
t.Fatal("known premium name treated as free during an outage")
|
|
}
|
|
})
|
|
|
|
t.Run("the cache is cleared rather than grown past its bound", func(t *testing.T) {
|
|
profileAPIAnswering(t, http.StatusNotFound)
|
|
for i := range premiumCacheMax {
|
|
seedPremium(fmt.Sprintf("n%d", i), false, 0)
|
|
}
|
|
isPremiumName(ctx, "Steve0")
|
|
premiumNames.Lock()
|
|
size := len(premiumNames.m)
|
|
premiumNames.Unlock()
|
|
if size != 1 {
|
|
t.Fatalf("cache holds %d entries after passing its bound, want 1", size)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
})
|
|
}
|