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.
This commit is contained in:
flyemoji committed 2026-09-22 14:24:41 +09:00
1 parent e9f74f3f0f
commit 99c31c1d4e
2 files changed
+37

No files matched your search

+13
View File
@@ -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())
}
+24
View File
@@ -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