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

fix(nano): refuse hasJoined requests that declare a body

A GET to hasJoined with a Content-Length and no body held its
connection indefinitely. The handler returned, but net/http tries to
drain an unread body before it writes the answer, and nothing bounds
that wait: ReadHeaderTimeout ends with the headers. One such request
per socket pins a goroutine and a descriptor on nano or on felis-api's
internal face.

Velocity never sends a body, so any request that declares one, including
a chunked one, now gets a 400 with Connection: close, which skips the
drain and releases the connection once the answer is written.

The new subtest writes that request over a raw socket and waits three
seconds for an answer. Before the change it times out with no response
at all; now it reads a 400 marked close.
parent ff81295a
Loading
Loading
Loading
Loading
+9 −0
Changes for internal/api/handlers_hasjoined.go: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -106,6 +106,15 @@ func HasJoinedHandler(sources []AuthSource, repo Repo) http.Handler {
// service token. A rejected login is 204 No Content — exactly what Mojang returns for an
// invalid session, which authlib maps to "failed to verify username".
func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
	// Velocity sends no body. When a request declares one anyway, net/http tries to drain it
	// before writing any answer, so one that never arrives holds the connection with no
	// timeout: ReadHeaderTimeout stopped at the headers. Marking the reply as the last one on
	// this connection skips the drain.
	if r.ContentLength != 0 {
		w.Header().Set("Connection", "close")
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	q := r.URL.Query()
	username, serverID := q.Get("username"), q.Get("serverId")
	if username == "" || serverID == "" {
+27 −0
Changes for internal/api/handlers_hasjoined_test.go: 27 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"bufio"
	"encoding/hex"
	"encoding/json"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"path"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/google/uuid"
)
@@ -311,6 +315,29 @@ func TestHasJoined(t *testing.T) {
		}
	})

	// A GET that declares a body it never sends must still be answered and lose its
	// connection; otherwise each such socket stays open for as long as the client likes.
	t.Run("request declaring a body is refused and closed", func(t *testing.T) {
		api := newTestAPI(newFakeRepo(), newFakeCluster())
		srv := httptest.NewServer(api.InternalHandler())
		t.Cleanup(srv.Close)
		conn, err := net.Dial("tcp", srv.Listener.Addr().String())
		if err != nil {
			t.Fatal(err)
		}
		defer conn.Close()
		_, _ = io.WriteString(conn, "GET /session/minecraft/hasJoined?username=a&serverId=b HTTP/1.1\r\nHost: x\r\nContent-Length: 1000\r\n\r\n")
		_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
		resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
		if err != nil {
			t.Fatalf("no answer while the declared body never arrives: %v", err)
		}
		resp.Body.Close()
		if resp.StatusCode != http.StatusBadRequest || !resp.Close {
			t.Fatalf("code = %d close = %v, want 400 with Connection: close", resp.StatusCode, resp.Close)
		}
	})

	// Missing query fields → 204 without touching any source.
	t.Run("missing username -> 204", func(t *testing.T) {
		api := newTestAPI(newFakeRepo(), newFakeCluster())