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.
This commit is contained in:
flyemoji committed 2026-09-22 13:19:55 +09:00
1 parent ff81295aa9
commit a4779186a4
2 files changed
+36

No files matched your search

+9
View File
@@ -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
View File
@@ -1,14 +1,18 @@
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())