From 99c31c1d4eaecd16035823e0ae4de7432dc8d35c Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 22 Sep 2026 14:24:41 +0900 Subject: [PATCH] fix(config): refuse plaintext auth-source urls to public hosts An auth_source url could be http:// to any host. Anyone on the path to a public root, or anyone who can spoof its DNS name, can then answer hasJoined with a 200 and log in as any player of that source, including a third-party account linked to staff. The player's IP also travels in cleartext. Mojang logins are unaffected, since that source is built in over https. Config load now refuses http:// unless the host is localhost or a loopback or private IP address (127.0.0.0/8, ::1, 10/8, 172.16/12, 192.168/16, fc00::/7), so a root on the same host or the LAN still works without TLS. The decision is made on the literal host because nothing is resolved at load time, so a LAN root named by hostname needs its IP address or https. The error says what to change. The new test covers public names and addresses, link-local, 0.0.0.0 and the first address past 172.16/12 (all refused over http, all accepted over https), and the loopback and private forms that stay allowed. It fails on the old check. --- internal/config/config.go | 13 +++++++++++++ internal/config/config_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 3c68ad3..af73030 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ package config import ( "fmt" + "net" "net/url" "regexp" "strings" @@ -414,6 +415,18 @@ func hasJoinedURLProblem(u string) string { return "has no host" case strings.ContainsAny(u, "?#"): return "must not carry a query or fragment; the username and serverId parameters are appended to it" + case p.Scheme == "http" && !plaintextHostOK(p.Hostname()): + return "sends logins in plaintext to a public host, where anyone on the path can answer as any player of this source; use https://, or http:// only for localhost or a loopback or private IP address" } return "" } + +// plaintextHostOK is decided on the literal host because nothing is resolved at load time, +// so a LAN root named by hostname needs its IP address or https. +func plaintextHostOK(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && (ip.IsLoopback() || ip.IsPrivate()) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af44a64..9156585 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -385,6 +385,30 @@ func TestLoadRejectsUnqueryableAuthSourceURL(t *testing.T) { } } +// A source reached over plaintext can be answered by anyone on the path, who can then log in +// as any player of that source. Only a same-host or private-network root may skip TLS, and +// that is decided on the literal host, since nothing is resolved at load time. +func TestLoadRejectsPlaintextPublicAuthSource(t *testing.T) { + load := func(u string) error { + _, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \"a\"\nprefix = \"AA\"\nurl = \""+u+"\"\n")) + return err + } + for _, host := range []string{"ygg.example.net", "203.0.113.9", "ygg.lan", "172.32.0.1", "169.254.1.1", "0.0.0.0", "[2001:db8::1]"} { + u := "http://" + host + "/hasJoined" + if err := load(u); err == nil || !strings.Contains(err.Error(), "https://") { + t.Errorf("url %q: err = %v, want a refusal that asks for https://", u, err) + } + if err := load("https://" + host + "/hasJoined"); err != nil { + t.Errorf("the same host over https must load: %v", err) + } + } + for _, host := range []string{"localhost", "LOCALHOST:8080", "127.0.0.1:8080", "127.1.2.3", "[::1]:8080", "10.0.0.5", "172.16.3.4", "192.168.1.2", "[fd00::1]"} { + if err := load("http://" + host + "/hasJoined"); err != nil { + t.Errorf("a plaintext root on %s must load: %v", host, err) + } + } +} + // TestLoadNanoAcceptsMinimalConfig is the linchpin of the Felis-nano fold: a nano host has no // Postgres and no FQDN, so LoadNano must accept a felis.toml carrying ONLY [[auth_source]] — // the control-plane requirements (database.url, root_domain) that full Load enforces are