fix(api): mc_uuid 统一校验为 UUID,非法值返回 400 bad_mc_uuid 并按规范形式存储

This commit is contained in:
Lemon-miaow committed 2026-09-27 12:57:40 +08:00
1 parent 5b0686dc06
commit e91886822c
13 files changed
+159 -35

No files matched your search

+33 -8
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"strings"
"time"
"github.com/google/uuid"
)
// Account-linking endpoints (spec §10). The flow is forced by the
@@ -51,6 +53,28 @@ func validAuthSource(s string) bool {
return s == authSourceMojang || s == authSourceThirdParty
}
// errBadMCUUID answers an mc_uuid that is not a UUID. Every mc_uuid column is
// Postgres's uuid type, which refuses such text with 22P02, and that reached the
// caller as a 500.
var errBadMCUUID = newError(http.StatusBadRequest, "bad_mc_uuid",
"mc_uuid must be a UUID, such as 069a79f4-44e9-4726-a5be-fca90e38aaf5")
// parseMCUUID reads an mc_uuid from a request: surrounding spaces trimmed, empty
// → 400 bad_request "mc_uuid is required", not a UUID → errBadMCUUID. It returns
// the canonical lowercase hyphenated form, the text Postgres gives back for the
// column, so what a handler stores, echoes and compares is one spelling.
func parseMCUUID(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
return "", newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")
}
id, err := uuid.Parse(s)
if err != nil {
return "", errBadMCUUID
}
return id.String(), nil
}
// deriveAuthSource infers the auth source from the UUID's version nibble when
// the minting backend omitted auth_source. Felis-nano rewrites every
// third-party profile to a name-based UUIDv3 under its namespace before it ever
@@ -104,8 +128,9 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
if req.MCUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
mcUUID, err := parseMCUUID(req.MCUUID)
if err != nil {
writeError(w, r, err)
return
}
// Default an omitted source from the UUID's version nibble (v3 = felis-nano
@@ -114,7 +139,7 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
// stored value the panel will later mislabel.
authSource := req.AuthSource
if authSource == "" {
authSource = deriveAuthSource(req.MCUUID)
authSource = deriveAuthSource(mcUUID)
}
if !validAuthSource(authSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
@@ -127,7 +152,7 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
return
}
expiresAt := a.now().Add(linkCodeTTL)
if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, authSource, expiresAt); err != nil {
if err := a.Repo.CreateLinkCode(r.Context(), code, mcUUID, authSource, expiresAt); err != nil {
writeError(w, r, err)
return
}
@@ -171,12 +196,12 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
// handlers_player_reclaim.go keeps CODE-ONLY (reclaimed_by_user_id stays NULL on
// the verifiable path); this endpoint reports link completion only, not that choice.
func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) {
mcUUID := r.PathValue("mc_uuid")
if mcUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
mcUUID, err := parseMCUUID(r.PathValue("mc_uuid"))
if err != nil {
writeError(w, r, err)
return
}
_, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
_, err = a.Repo.UserByMCUUID(r.Context(), mcUUID)
switch {
case errors.Is(err, ErrNotFound):
// Not linked yet. For the poller this is simply "keep waiting": velocity
+3 -3
View File
@@ -111,9 +111,9 @@ func (a *API) handleMigrateStart(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
mcUUID := strings.TrimSpace(req.MCUUID)
if mcUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
mcUUID, err := parseMCUUID(req.MCUUID)
if err != nil {
writeError(w, r, err)
return
}
sourceUserID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
+1 -1
View File
@@ -66,7 +66,7 @@ func (a *API) handleAllowlistSetWake(w http.ResponseWriter, r *http.Request) {
}
id, err := uuid.Parse(r.PathValue("uuid"))
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "invalid Minecraft UUID"))
writeError(w, r, errBadMCUUID)
return
}
var req allowlistWakeRequest
+15 -12
View File
@@ -83,11 +83,12 @@ func (a *API) handleJoinEvent(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
if req.MCUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
mcUUID, err := parseMCUUID(req.MCUUID)
if err != nil {
writeError(w, r, err)
return
}
if err := a.Repo.RecordJoin(r.Context(), name, req.MCUUID); err != nil {
if err := a.Repo.RecordJoin(r.Context(), name, mcUUID); err != nil {
a.writeLookupError(w, r, err)
return
}
@@ -120,8 +121,9 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
if req.MCUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
mcUUID, err := parseMCUUID(req.MCUUID)
if err != nil {
writeError(w, r, err)
return
}
@@ -148,7 +150,7 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.authorizeWakeByUUID(r.Context(), req.MCUUID, info, rec); err != nil {
if err := a.authorizeWakeByUUID(r.Context(), mcUUID, info, rec); err != nil {
writeError(w, r, err)
return
}
@@ -235,8 +237,9 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
if req.MCUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
mcUUID, err := parseMCUUID(req.MCUUID)
if err != nil {
writeError(w, r, err)
return
}
@@ -245,7 +248,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
// no separate IsLinked check (mirrors the external claim's order, link → quota
// → write). ErrNotFound here is "claimer not linked" (412), never "server
// missing" — that distinction is the claim call's, below.
userID, err := a.Repo.UserByMCUUID(r.Context(), req.MCUUID)
userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusPreconditionFailed, "not_linked",
@@ -364,9 +367,9 @@ const (
// since a retry gets past those. Retiring comes first: nobody may start such a
// server, so it is the reason a stranger is shown too.
func (a *API) handleInternalMenuAccess(w http.ResponseWriter, r *http.Request) {
mcUUID := r.PathValue("mc_uuid")
if mcUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
mcUUID, err := parseMCUUID(r.PathValue("mc_uuid"))
if err != nil {
writeError(w, r, err)
return
}
infos, err := a.Cluster.ListServers(r.Context())
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"encoding/json"
"net/http"
"testing"
)
// Every door that takes an mc_uuid refuses text that is not a UUID with 400
// bad_mc_uuid. The columns are Postgres uuids, and such text used to reach the
// database, fail there with 22P02 and come back as a 500. A UUID in another
// spelling is stored and echoed in the one Postgres gives back.
func TestMCUUIDMustBeAUUID(t *testing.T) {
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "u1"}
repo.claimOK["survival"] = true
repo.seedUser(UserView{ID: "u2", Username: "alice", Role: "user"})
cl := newFakeCluster()
cl.byName["survival"] = &ServerInfo{Name: "survival", AutostartPolicy: "public"}
api := newTestAPI(repo, cl)
api.External = staticExternal{p: &Principal{UserID: "owner1", Role: "owner", ViaAdminAccess: true}}
ih, eh := api.InternalHandler(), api.ExternalHandler()
const bad = "not-a-uuid"
body := `{"mc_uuid":"` + bad + `"}`
for _, c := range []struct {
h http.Handler
method, path, body string
}{
{ih, "POST", "/api/v1/internal/servers/survival/join-event", body},
{ih, "POST", "/api/v1/internal/servers/survival/wake", body},
{ih, "POST", "/api/v1/internal/servers/survival/claim", body},
{ih, "GET", "/api/v1/internal/player/menu-access/" + bad, ""},
{ih, "POST", "/api/v1/internal/account/link/code", body},
{ih, "GET", "/api/v1/internal/account/link/status/" + bad, ""},
{ih, "POST", "/api/v1/internal/account/migrate/start", body},
{ih, "GET", "/api/v1/internal/player/blacklist/" + bad, ""},
{eh, "PUT", "/api/v1/servers/survival/allowlist/" + bad, `{"can_wake":true}`},
{eh, "DELETE", "/api/v1/users/u2/links/" + bad, ""},
{eh, "POST", "/api/v1/users/u2/links", body},
} {
w := do(c.h, c.method, c.path, c.body, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_mc_uuid" {
t.Errorf("%s %s with %q: code = %d body %s, want 400 bad_mc_uuid", c.method, c.path, bad, w.Code, w.Body.String())
}
}
if len(repo.joins) != 0 || len(repo.linkCodes) != 0 || len(repo.links) != 0 || len(repo.audits) != 0 {
t.Fatalf("a refused mc_uuid wrote joins=%v codes=%v links=%v audits=%v", repo.joins, repo.linkCodes, repo.links, repo.audits)
}
const spelled, canonical = " 069A79F444E94726A5BEFCA90E38AAF5 ", "069a79f4-44e9-4726-a5be-fca90e38aaf5"
w := do(eh, "POST", "/api/v1/users/u2/links", `{"mc_uuid":"`+spelled+`"}`, jsonHeader)
var linked struct {
MCUUID string `json:"mc_uuid"`
}
if err := json.Unmarshal(w.Body.Bytes(), &linked); err != nil || w.Code != http.StatusOK || linked.MCUUID != canonical {
t.Fatalf("link of %q: code = %d body %s, want 200 echoing %s", spelled, w.Code, w.Body.String(), canonical)
}
if repo.links[canonical] != "u2" || len(repo.links) != 1 {
t.Fatalf("links = %v, want only %s → u2", repo.links, canonical)
}
if w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+spelled+`"}`, jsonHeader); w.Code != http.StatusCreated {
t.Fatalf("link code for %q: code = %d body %s, want 201", spelled, w.Code, w.Body.String())
}
for _, lc := range repo.linkCodes {
if lc.mcUUID != canonical {
t.Fatalf("link code minted for %q, want %s", lc.mcUUID, canonical)
}
}
}
+3 -3
View File
@@ -140,9 +140,9 @@ func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) {
// squatter before letting them in; the genuine Mojang UUID (same name, different
// UUID) is never on the list, so it always passes.
func (a *API) handleCheckBlacklist(w http.ResponseWriter, r *http.Request) {
mcUUID := r.PathValue("mc_uuid")
if mcUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
mcUUID, err := parseMCUUID(r.PathValue("mc_uuid"))
if err != nil {
writeError(w, r, err)
return
}
blacklisted, err := a.Repo.IsUsernameBlacklisted(r.Context(), mcUUID)
+12 -8
View File
@@ -447,11 +447,15 @@ func (a *API) handleUnbindUserPasskeys(w http.ResponseWriter, r *http.Request) {
// (DELETE /users/{id}/links/{mc_uuid}).
func (a *API) handleUnlinkAccount(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
mcUUID := r.PathValue("mc_uuid")
if userID == "" || mcUUID == "" {
if userID == "" {
writeError(w, r, errBadRequest)
return
}
mcUUID, err := parseMCUUID(r.PathValue("mc_uuid"))
if err != nil {
writeError(w, r, err)
return
}
if err := a.Repo.UnlinkAccount(r.Context(), userID, mcUUID); err != nil {
if errors.Is(err, ErrNotFound) {
@@ -484,16 +488,16 @@ func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
if body.MCUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"mc_uuid is required"))
mcUUID, err := parseMCUUID(body.MCUUID)
if err != nil {
writeError(w, r, err)
return
}
if body.AuthSource == "" {
// Same version-nibble inference as the mint path (handlers_account.go):
// defaulting to mojang here would leave a force-linked thirdparty UUID
// outside the reclaim guard.
body.AuthSource = deriveAuthSource(body.MCUUID)
body.AuthSource = deriveAuthSource(mcUUID)
}
if !validAuthSource(body.AuthSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
@@ -501,7 +505,7 @@ func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.Repo.LinkAccount(r.Context(), userID, body.MCUUID, body.AuthSource); err != nil {
if err := a.Repo.LinkAccount(r.Context(), userID, mcUUID, body.AuthSource); err != nil {
if errors.Is(err, ErrConflict) {
writeError(w, r, newError(http.StatusConflict, "already_linked",
"this UUID is already linked to a different user"))
@@ -518,7 +522,7 @@ func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) {
a.audit(r, "user.link_account", userID)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"mc_uuid": body.MCUUID,
"mc_uuid": mcUUID,
"auth_source": body.AuthSource,
})
}