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
+36
-2
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user