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:
2 files changed
+35
-1
No files matched your search
@@ -45,6 +45,7 @@ var felisAuthNS = uuid.NewSHA1(uuid.NameSpaceURL, []byte("nano.felis.lolicon.bes
|
||||
// wasted Mojang round-trip; add parallel fan-out only if login latency bites.
|
||||
var authHTTPClient = &http.Client{
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user