feat(nano): give a Mojang player's name back to them, by prefixing the squatter

A premium player and a third-party player sharing a username could not both be
online. Whichever logged in second was kicked with "You are already connected to
this proxy!" -- even though the UUID rewrite had already made them two distinct
players on the backend. Velocity's player registry is keyed on the NAME (lowercased),
not the UUID, so two identities holding one name are one player as far as the proxy
is concerned, and the reclaim invariant the rewrite buys is invisible to it.

The fix needs no plugin and no state, because Velocity honours the name in the
hasJoined RESPONSE rather than pinning the one the client sent at login-start --
established by a real login, not by reading the source. So the multiplexer hands
back a different name and the collision is simply gone.

A third-party player whose name belongs to a Mojang account now joins as
PREFIX_name (LS_steve). Everyone else keeps their own name: the rename fires only
on an actual collision, decided by asking api.mojang.com whether the name is
registered. The name's owner is never the one renamed, which is 正版优先 falling out
for free -- the identity source is never rewritten, so there is no policy to encode
and no 30-day hold to track.

The premium-name answer is cached asymmetrically, because the two directions have
very different costs. "Taken" is nearly permanent (Mojang does not recycle names) and
is trusted for a day; "free" can stop being true the moment someone buys that name,
and a stale "free" leaves a squatter holding a name its real owner has just bought,
so it is trusted for ten minutes. A lookup that fails with nothing cached fails
CLOSED -- assume premium, rename the third-party player: a Mojang outage must not
become an opportunity to hold someone else's name, and being wrong that way costs a
cosmetic prefix while being wrong the other way bounces the name's owner off the
proxy. The lookup gets its own 2s client rather than sharing the 5s auth client,
since it is a SECOND Mojang round-trip on a login that already spent one.

prefix is a required, unique, 1-4 character config field rather than something
derived from the tag, because it is player-visible and no derivation can know that
"littleskin" is meant to read LS. Two sources sharing a prefix would rewrite their
same-named players onto one name, so uniqueness is enforced case-insensitively --
the proxy folds case, and LS/ls would collide there while reading as distinct here.

Also close a pre-existing hole on the path this touches: a third-party source's
profile name was relayed verbatim, so a hostile or sloppy Yggdrasil root could put
"§4admin", an empty string, or 200 characters straight into the proxy's player list.
The name is now checked against the Minecraft username charset and a bad one is a 204,
the same way a bad UUID already was.

Verified end to end on the deploy host (Velocity 3.5.1 + Paper 26.2), both branches:

  premium FLYEMOJ1     -> 195fadbd-f72e-4b9b-9f8f-f92586fe16ad, name unchanged
  LittleSkin FLYEMOJ1  -> LS_FLYEMOJ1, f1b7b6ae-f250-348a-b069-a2ec0fcae668
  both online at once, zero "already connected" rejections
  LittleSkin FelisNyaTest01 -> joins as FelisNyaTest01, no prefix, UUID still v3

The last line is the one that matters: an ordinary third-party player collides with
nobody and keeps their name, while the rewrite that keeps identities apart still ran.
Paper's "LS_FLYEMOJ1 (formerly known as li_FLYEMOJ1) joined the game" is the other
half of it -- the rename moved the player's display name and their playerdata came
along untouched, because every server-side key is the UUID and the UUID does not
depend on the name.

Known ceiling, left alone deliberately: two players of one source whose names agree
on their first 16-len(prefix)-1 characters truncate onto the same in-game name, and a
prefixed name may itself happen to be a premium name. Both cost an "already connected"
bounce, not an identity -- the UUID rewrite does not depend on the name at all.

BREAKING CHANGE: every [[auth_source]] now requires prefix = "XX" (1-4 letters or
digits, unique across sources). An existing nano felis.toml without it fails to load
with an error naming the field, rather than silently keeping the collision.
This commit is contained in:
flyemoji committed 2026-07-13 13:00:54 +09:00
1 parent b323975ddb
commit fd062882ed
6 files changed
+370 -16

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSourc
sources := make([]api.AuthSource, 0, len(configured)+1)
sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true})
for _, s := range configured {
sources = append(sources, api.AuthSource{Tag: s.Tag, URL: s.URL})
sources = append(sources, api.AuthSource{Tag: s.Tag, Prefix: s.Prefix, URL: s.URL})
}
return sources
}
+7
View File
@@ -1178,8 +1178,15 @@ write_nano_config() {
# Add each third-party Yggdrasil root below (priority = order). url is the FULL
# hasJoined endpoint. After editing: sudo systemctl restart felis-nano
#
# prefix is required, 1-4 letters/digits, unique per source. A player of this source
# whose name belongs to a Mojang account joins as PREFIX_name (LS_steve) instead —
# otherwise the proxy, which keys its player list on the NAME, refuses to have both
# online at once ("You are already connected to this proxy!"). Everyone else keeps
# their own name.
#
# [[auth_source]]
# tag = "littleskin"
# prefix = "LS"
# url = "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
EOF
chmod 0644 "$target"
+147 -1
View File
@@ -4,9 +4,13 @@ import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -44,9 +48,12 @@ var authHTTPClient = &http.Client{Timeout: 5 * time.Second}
// AuthSource is one upstream Yggdrasil root in the multiplexer's priority list (config
// order = priority). URL is the full hasJoined endpoint the query string is appended to.
// Identity marks the authoritative source (Mojang) whose UUIDs are trusted as-is; every
// other source is rewritten into felisAuthNS.
// other source is rewritten into felisAuthNS. Prefix is the in-game rename applied to a
// player of this source who is holding a Mojang player's name (see prefixedName); it is
// unused on the identity source, whose players are never renamed.
type AuthSource struct {
Tag string
Prefix string
URL string
Identity bool
}
@@ -102,7 +109,24 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
}
canonical = id
} else {
// A third-party source is untrusted input, its name included: nothing stops a
// hostile or sloppy root from answering with "§4admin", an empty string, or 200
// characters, all of which this handler would otherwise relay straight into the
// proxy's player list.
if !mcUsernameRe.MatchString(prof.Name) {
w.WriteHeader(http.StatusNoContent)
return
}
canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID))
// Give a Mojang player's name back to the Mojang player. The UUID rewrite above
// already keeps the two apart as identities, but the proxy's player registry is
// keyed on the NAME (Velocity: "You are already connected to this proxy!"), so
// without this they cannot even be online at the same time. Renaming only on an
// actual collision leaves the ordinary third-party player's name untouched.
if isPremiumName(r.Context(), prof.Name) {
prof.Name = prefixedName(src.Prefix, prof.Name)
}
}
// Bar gate at the single chokepoint every login crosses, so a reclaimed squatter
@@ -123,6 +147,128 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, prof)
}
// mcUsernameRe is Minecraft's username charset — the trust boundary on a third-party
// source's self-asserted profile name.
var mcUsernameRe = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
// mcUsernameMax is the protocol's username length ceiling, which prefixedName must respect.
const mcUsernameMax = 16
// prefixedName is the squatter rename: ("LS", "steve") → "LS_steve". The base name is
// TRUNCATED to fit rather than the rename being skipped when it would not fit — skipping is
// what would silently hand a 14-character premium name back to the squatter.
//
// ponytail: two players of one source whose names agree on their first mcUsernameMax-len(prefix)-1
// characters truncate onto the same in-game name, as does a prefixed name that happens to be
// a premium name itself. Both cost an "already connected" bounce, not an identity: the UUID
// rewrite is what keeps players apart, and it does not depend on the name at all. Add a
// disambiguating suffix only if real players actually collide.
func prefixedName(prefix, name string) string {
p := prefix + "_"
if keep := mcUsernameMax - len(p); len(name) > keep {
name = name[:keep]
}
return p + name
}
// mojangProfileAPI answers the one question that decides a rename: is this username
// registered to a Mojang account? A var, not a const, so a test can point it at a stub
// instead of the real Mojang.
var mojangProfileAPI = "https://api.mojang.com/users/profiles/minecraft/"
// profileHTTPClient is deliberately more impatient than authHTTPClient: the name lookup is a
// SECOND Mojang round-trip on a third-party login (the identity leg already spent one), and
// api.mojang.com is exactly what is unreliable from the networks these servers sit on. A
// slow answer falls back to the cache instead of holding the login open.
var profileHTTPClient = &http.Client{Timeout: 2 * time.Second}
// A name's premium status changes on human timescales, not per login, so it is cached — but
// asymmetrically, because the two directions have very different costs. "Taken" is nearly
// permanent (Mojang does not recycle names), while "free" can stop being true the moment
// someone buys that name, and a stale "free" is the dangerous one: it leaves a squatter
// holding a name its real owner has just bought. So a "free" answer is trusted for minutes
// and a "taken" answer for a day.
const (
premiumTakenTTL = 24 * time.Hour
premiumFreeTTL = 10 * time.Minute
premiumCacheMax = 4096
)
type premiumEntry struct {
taken bool
at time.Time
}
var premiumNames = struct {
sync.Mutex
m map[string]premiumEntry
}{m: make(map[string]premiumEntry)}
// isPremiumName reports whether username belongs to a real Mojang account — which is what
// makes a third-party player holding it a squatter. On a lookup failure it prefers a stale
// cached answer, and with nothing cached it fails CLOSED (assume premium → rename the
// third-party player): a Mojang outage must not let a squatter keep a name the real owner is
// about to log in with. Being wrong that way costs a cosmetic prefix; being wrong the other
// way bounces the name's actual owner off the proxy.
func isPremiumName(ctx context.Context, username string) bool {
key := strings.ToLower(username)
premiumNames.Lock()
cached, hit := premiumNames.m[key]
premiumNames.Unlock()
if hit && time.Since(cached.at) < premiumTTL(cached.taken) {
return cached.taken
}
taken, err := lookupPremiumName(ctx, username)
if err != nil {
if hit {
return cached.taken
}
return true
}
premiumNames.Lock()
// ponytail: bounded by dropping the whole map rather than evicting LRU — entries are
// only minted by players who actually authenticated somewhere, so this is a backstop
// against an unbounded map, not a cache policy worth tuning.
if len(premiumNames.m) >= premiumCacheMax {
clear(premiumNames.m)
}
premiumNames.m[key] = premiumEntry{taken: taken, at: time.Now()}
premiumNames.Unlock()
return taken
}
func premiumTTL(taken bool) time.Duration {
if taken {
return premiumTakenTTL
}
return premiumFreeTTL
}
// lookupPremiumName asks Mojang whether a name is registered: 200 = it is, 404 (204 on the
// legacy endpoint) = it is free. Anything else is an ERROR, never a "no" — a 429 or a 503
// must not read as "this name is unowned"; see isPremiumName's fail-closed rule.
func lookupPremiumName(ctx context.Context, username string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mojangProfileAPI+url.PathEscape(username), nil)
if err != nil {
return false, err
}
resp, err := profileHTTPClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound, http.StatusNoContent:
return false, nil
}
return false, fmt.Errorf("mojang profile api: %s", resp.Status)
}
// resolveHasJoined queries each configured source in priority order and returns the
// first that validates the session (200 with a profile). A source that is down, answers
// non-200 (204 = "not my player"), or returns garbage is skipped.
+123 -5
View File
@@ -5,11 +5,49 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"path"
"strings"
"testing"
"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.
@@ -74,9 +112,10 @@ func TestHasJoined(t *testing.T) {
// 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", URL: evil.URL, Identity: false}}
api.AuthSources = []AuthSource{{Tag: "littleskin", Prefix: "LS", URL: evil.URL, Identity: false}}
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
if w.Code != http.StatusOK {
@@ -94,28 +133,36 @@ func TestHasJoined(t *testing.T) {
// 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", URL: third.URL, Identity: false},
{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false},
}
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
if p := profileOf(t, w); p.ID != notchMojangID {
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", URL: third.URL, Identity: false},
{Tag: "littleskin", Prefix: "LS", URL: third.URL, Identity: false},
}
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
if w.Code != http.StatusOK {
@@ -145,12 +192,13 @@ func TestHasJoined(t *testing.T) {
// 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", URL: third.URL, Identity: false}}
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)
@@ -167,3 +215,73 @@ func TestHasJoined(t *testing.T) {
}
})
}
// 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())
}
}
})
}
+33 -9
View File
@@ -6,6 +6,7 @@ package config
import (
"fmt"
"regexp"
"strings"
"github.com/BurntSushi/toml"
@@ -36,10 +37,14 @@ type Config struct {
// Felis-nano multiplexer federates over. Tag names the source's per-source UUID
// namespace (must be unique — two sources sharing a tag would collide onto one identity);
// URL is the full hasJoined endpoint (scheme-qualified) the query string is appended to.
// Prefix is what a player from this source is renamed with when their name belongs to a
// Mojang player (LS_steve) — player-visible, so it is written out rather than derived from
// the tag, which cannot know that "littleskin" is meant to read LS.
// No trusted/identity field, by design — see Config.AuthSources.
type AuthSourceConfig struct {
Tag string `toml:"tag"`
URL string `toml:"url"`
Tag string `toml:"tag"`
Prefix string `toml:"prefix"`
URL string `toml:"url"`
}
// ServerConfig is the [server] table.
@@ -286,15 +291,24 @@ func (c *Config) Validate() error {
return c.validateAuthSources()
}
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag and a
// scheme-qualified hasJoined URL, and tags must be unique. A blank or duplicate tag collapses
// two sources into one UUID namespace (cross-source impersonation — the exact invariant the
// per-source rewrite exists to hold); a scheme-less URL makes http.NewRequest fail so the
// source is silently dead (never validates any login). Both fail fast at load, not per-login.
// Split out from Validate so the nano-only LoadNano (no control-plane fields) enforces the
// identical rules — the impersonation guard has one owner, shared by full-api and nano.
// authSourcePrefixRe is the shape of a prefix. It is prepended to a real Minecraft
// username (LS_steve), so it is confined to the username charset and kept short enough to
// leave a legible name behind after truncation.
var authSourcePrefixRe = regexp.MustCompile(`^[A-Za-z0-9]{1,4}$`)
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag, a rename
// prefix, and a scheme-qualified hasJoined URL, and both tag and prefix must be unique. A
// blank or duplicate tag collapses two sources into one UUID namespace (cross-source
// impersonation — the exact invariant the per-source rewrite exists to hold); a duplicate
// prefix collapses two same-named players from different sources onto one in-game name
// (they stay distinct identities, but neither can be online while the other is); a
// scheme-less URL makes http.NewRequest fail so the source is silently dead (never validates
// any login). All fail fast at load, not per-login. Split out from Validate so the nano-only
// LoadNano (no control-plane fields) enforces the identical rules — the impersonation guard
// has one owner, shared by full-api and nano.
func (c *Config) validateAuthSources() error {
seenTags := make(map[string]struct{}, len(c.AuthSources))
seenPrefixes := make(map[string]struct{}, len(c.AuthSources))
for i, s := range c.AuthSources {
if s.Tag == "" {
return fmt.Errorf("config: [[auth_source]] #%d has an empty tag; each source's tag is its per-source UUID namespace", i+1)
@@ -303,6 +317,16 @@ func (c *Config) validateAuthSources() error {
return fmt.Errorf("config: [[auth_source]] tag %q is used twice — tags are per-source UUID namespaces and must be unique", s.Tag)
}
seenTags[s.Tag] = struct{}{}
if !authSourcePrefixRe.MatchString(s.Prefix) {
return fmt.Errorf(`config: [[auth_source]] %q needs prefix = "XX" (1-4 letters or digits, e.g. "LS" for LittleSkin), got %q; a player of this source whose name belongs to a Mojang account is renamed XX_name so the two can be online at once`, s.Tag, s.Prefix)
}
// Case-insensitively — the proxy's player registry folds case, so LS and ls would
// collide there even though they read as two different prefixes here.
lower := strings.ToLower(s.Prefix)
if _, dup := seenPrefixes[lower]; dup {
return fmt.Errorf("config: [[auth_source]] prefix %q is used twice — two sources sharing a prefix rewrite their same-named players onto the same in-game name", s.Prefix)
}
seenPrefixes[lower] = struct{}{}
if !strings.HasPrefix(s.URL, "http://") && !strings.HasPrefix(s.URL, "https://") {
return fmt.Errorf("config: [[auth_source]] %q url %q must be a scheme-qualified http(s):// hasJoined endpoint", s.Tag, s.URL)
}
+59
View File
@@ -211,9 +211,11 @@ root_domain = "mc.example.net"
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
[[auth_source]]
tag = "guild"
prefix = "GD"
url = "https://guild.example.net/sessionserver/session/minecraft/hasJoined"
`))
if err != nil {
@@ -258,9 +260,11 @@ root_domain = "mc.example.net"
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "dup"
prefix = "AA"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "dup"
prefix = "BB"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
@@ -283,6 +287,7 @@ root_domain = "mc.example.net"
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "bare"
prefix = "BR"
url = "bare.example.net/hasJoined"
`))
if err == nil {
@@ -301,6 +306,7 @@ func TestLoadNanoAcceptsMinimalConfig(t *testing.T) {
cfg, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
`))
if err != nil {
@@ -321,9 +327,11 @@ func TestLoadNanoStillEnforcesAuthSourceRules(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "dup"
prefix = "AA"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "dup"
prefix = "BB"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
@@ -334,6 +342,57 @@ url = "https://b.example.net/hasJoined"
}
}
// TestLoadRejectsBadAuthSourcePrefix pins the rename-prefix contract. The prefix is prepended
// to a real Minecraft username (LS_steve) when a third-party player is holding a Mojang
// player's name, so it must exist and must be legal there — a missing or illegal prefix would
// otherwise only surface as a login the proxy silently refuses, months later, the first time
// two players collide.
func TestLoadRejectsBadAuthSourcePrefix(t *testing.T) {
for name, prefix := range map[string]string{
"missing": "",
"too long": "TOOLONG",
"underscore": "L_",
"non-ascii": "皮肤",
} {
t.Run(name, func(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "`+prefix+`"
url = "https://littleskin.example.net/hasJoined"
`))
if err == nil {
t.Fatalf("expected error for prefix %q", prefix)
}
if !strings.Contains(err.Error(), "prefix") {
t.Errorf("error should name the prefix contract, got: %v", err)
}
})
}
}
// TestLoadRejectsDuplicateAuthSourcePrefix: two sources sharing a prefix rewrite their
// same-named players onto the SAME in-game name, which is the collision the prefix exists to
// break. Case-insensitively, because the proxy's player registry folds case.
func TestLoadRejectsDuplicateAuthSourcePrefix(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "otherskin"
prefix = "ls"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
t.Fatal("expected LoadNano to reject two sources sharing a prefix (case-insensitively)")
}
if !strings.Contains(err.Error(), "prefix") {
t.Errorf("error should name the prefix contract, got: %v", err)
}
}
func TestLoadRejectsUnknownKeys(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]