Unverified Commit 1dd62a9b authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 2180e77c
Loading
Loading
Loading
Loading
+12 −1
Changes for internal/api/handlers_hasjoined.go: 12 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
+23 −0
Changes for internal/api/handlers_hasjoined_test.go: 23 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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.