From 1dd62a9bdc8654a6ff7ad4407216a60105ee1698 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 22 Sep 2026 13:02:48 +0900 Subject: [PATCH] fix(nano): cap upstream response headers at 16 KiB The hasJoined and name-lookup clients limited the body to 64 KiB but left headers at the transport default of 1 MiB. A configured root could answer with a megabyte of headers and stall the body, holding a few MiB of heap per in-flight login for the full five seconds; enough parallel logins take down the host, and every source's logins with it. Both clients now share a transport with MaxResponseHeaderBytes set to 16 KiB. Real roots come nowhere near it: Mojang's sessionserver sends 338 bytes of headers, LittleSkin 752, api.mojang.com 327. A source over the cap fails the request and the resolver moves on to the next one. The new subtest puts a source with 64 KiB of headers and a valid profile ahead of an honest one and expects the honest player. Without the cap the padded source wins. --- internal/api/handlers_hasjoined.go | 15 +++++++++++++-- internal/api/handlers_hasjoined_test.go | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers_hasjoined.go b/internal/api/handlers_hasjoined.go index 695f277..df1feba 100644 --- a/internal/api/handlers_hasjoined.go +++ b/internal/api/handlers_hasjoined.go @@ -44,7 +44,8 @@ var felisAuthNS = uuid.NewSHA1(uuid.NameSpaceURL, []byte("nano.felis.lolicon.bes // One shared client, sequential priority scan — a third-party login costs one // wasted Mojang round-trip; add parallel fan-out only if login latency bites. var authHTTPClient = &http.Client{ - Timeout: 5 * time.Second, + Timeout: 5 * time.Second, + Transport: upstreamTransport, // A redirect is not a hasJoined answer. Following one would let a configured root point // this host at any URL it can reach — this listener included, where each hop re-runs the // whole source scan inside the same login's timeout. The 3xx is returned as-is and the @@ -52,6 +53,16 @@ var authHTTPClient = &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } +// upstreamTransport caps response headers, which the 64 KiB body limit does not cover. The +// default allows 1 MiB, so a root that sends that much and then stalls the body pins a few +// MiB per in-flight login for the whole timeout, and enough parallel logins OOM the host +// for every source. Real roots answer in well under 1 KiB of headers. +var upstreamTransport = func() *http.Transport { + t := http.DefaultTransport.(*http.Transport).Clone() + t.MaxResponseHeaderBytes = 16 << 10 + return t +}() + // 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 @@ -193,7 +204,7 @@ var mojangProfileAPI = "https://api.mojang.com/users/profiles/minecraft/" // 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} +var profileHTTPClient = &http.Client{Timeout: 2 * time.Second, Transport: upstreamTransport} // 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 diff --git a/internal/api/handlers_hasjoined_test.go b/internal/api/handlers_hasjoined_test.go index 60e9987..47ef7b7 100644 --- a/internal/api/handlers_hasjoined_test.go +++ b/internal/api/handlers_hasjoined_test.go @@ -247,6 +247,29 @@ func TestHasJoined(t *testing.T) { } }) + // 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.