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.
This commit is contained in:
flyemoji committed 2026-09-22 13:02:48 +09:00
1 parent 2180e77cf5
commit 1dd62a9bdc
2 files changed
+36 -2

No files matched your search

+13 -2
View File
@@ -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 // 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. // wasted Mojang round-trip; add parallel fan-out only if login latency bites.
var authHTTPClient = &http.Client{ 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 // 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 // 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 // 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 }, 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 // 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. // 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 // 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 // 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 // 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. // 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 // 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 // asymmetrically, because the two directions have very different costs. "Taken" is nearly
+23
View File
@@ -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 // 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 // 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. // dashed canonical (post-rewrite), the same form Repo.ReclaimUsername stores.