diff --git a/cmd/felis/nano.go b/cmd/felis/nano.go index 4a96618..a6c3bf4 100644 --- a/cmd/felis/nano.go +++ b/cmd/felis/nano.go @@ -48,6 +48,12 @@ type nanoStubRepo struct{ api.Repo } func (nanoStubRepo) IsUsernameBlacklisted(context.Context, string) (bool, error) { return false, nil } +// nanoDefaultListen is loopback because hasJoined carries no auth token (Velocity speaks the +// vanilla sessionserver protocol), so a public bind is an open auth relay: anyone can point +// their proxy at it and spend this host's egress IP on Mojang. A same-host Velocity reaches +// 127.0.0.1; serving an off-host proxy is an explicit -listen opt-in. +const nanoDefaultListen = "127.0.0.1:8081" + // nanoLogURIMax is room for a real hasJoined query (a 16-character name, a 41-character // serverId, an address) several times over. const nanoLogURIMax = 256 @@ -56,11 +62,7 @@ func cmdNano(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("nano", flag.ContinueOnError) fs.SetOutput(stderr) cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml (reads [[auth_source]])") - // Loopback default: hasJoined carries no auth token (authlib speaks the vanilla - // sessionserver protocol), so a public bind is an open auth relay — anyone can point - // their proxy at it and spend this host's egress IP on Mojang. A same-host Velocity - // reaches 127.0.0.1; serving an off-host proxy is an explicit -listen opt-in. - listen := fs.String("listen", "127.0.0.1:8081", "listen address for the hasJoined endpoint") + listen := fs.String("listen", nanoDefaultListen, "listen address for the hasJoined endpoint") if err := fs.Parse(args); err != nil { return 2 } diff --git a/cmd/felis/nano_test.go b/cmd/felis/nano_test.go index 87c8c62..df43c68 100644 --- a/cmd/felis/nano_test.go +++ b/cmd/felis/nano_test.go @@ -14,6 +14,8 @@ import ( "testing" "time" "unicode/utf8" + + "felis.lolicon.best/internal/api" ) // [server] listen in a nano config reads like the bind address but is not one; nano must @@ -85,6 +87,33 @@ func TestNanoDrainsInFlightLoginOnShutdown(t *testing.T) { } } +// The nano delivery path: the shared handler behind nano's stub store must admit a login its +// source validated. nanoStubRepo implements only the bar-list lookup, so a new store call in +// handleHasJoined would reach its nil embedded Repo and panic here, while the full-api tests, +// which use a complete fake store, stay green. +func TestNanoAdmitsAValidatedLogin(t *testing.T) { + const id = "069a79f444e94726a5befca90e38aaf5" + ygg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"id":"`+id+`","name":"Notch"}`) + })) + defer ygg.Close() + h := api.HasJoinedHandler([]api.AuthSource{{Tag: "mojang", URL: ygg.URL, Identity: true}}, nanoStubRepo{}) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/session/minecraft/hasJoined?username=Notch&serverId=abc", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), id) { + t.Fatalf("code = %d body = %q, want the validated profile", w.Code, w.Body.String()) + } +} + +// An unauthenticated relay on a public address spends this host's Mojang rate limit for +// anyone who finds it, so the default bind has to stay loopback. +func TestNanoListensOnLoopbackByDefault(t *testing.T) { + host, _, err := net.SplitHostPort(nanoDefaultListen) + if ip := net.ParseIP(host); err != nil || ip == nil || !ip.IsLoopback() { + t.Fatalf("default -listen %q is not a loopback address", nanoDefaultListen) + } +} + // The request log prints text the caller chose. A bidi override must not reorder the line, // an invalid byte must not make journald store the entry as a blob, and a huge query must // not become a huge log line. serverId is left out so the handler answers without asking