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

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.
parent e9f74f3f
Loading
Loading
Loading
Loading
+13 −0
Changes for internal/config/config.go: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/config/config_test.go: 24 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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