diff --git a/context.go b/context.go index 98002727525..5d77955c62a 100644 --- a/context.go +++ b/context.go @@ -553,6 +553,29 @@ func (ctx Context) Storage() certmagic.Storage { return ctx.cfg.storage } +// LocalAdminAddress returns the effective local admin listener +// address for this config context. The bool is false when the +// local admin endpoint is disabled. +func (ctx Context) LocalAdminAddress() (NetworkAddress, bool, error) { + if ctx.cfg == nil { + return NetworkAddress{}, false, nil + } + if ctx.cfg.Admin != nil && ctx.cfg.Admin.Disabled { + return NetworkAddress{}, false, nil + } + + adminListen := DefaultAdminListen + if ctx.cfg.Admin != nil && ctx.cfg.Admin.Listen != "" { + adminListen = ctx.cfg.Admin.Listen + } + + addr, err := parseAdminListenAddr(adminListen, DefaultAdminListen) + if err != nil { + return NetworkAddress{}, false, err + } + return addr, true, nil +} + // Logger returns a logger that is intended for use by the most // recent module associated with the context. Callers should not // pass in any arguments unless they want to associate with a diff --git a/listeners.go b/listeners.go index 84ebaaabae1..9d4b5fb5c1e 100644 --- a/listeners.go +++ b/listeners.go @@ -58,6 +58,54 @@ type NetworkAddress struct { EndPort uint } +func (na NetworkAddress) ConflictsWith(other NetworkAddress) bool { + // 1. Check if networks conflict (e.g., tcp vs tcp4 vs tcp6) + isTCP1 := strings.HasPrefix(na.Network, "tcp") + isTCP2 := strings.HasPrefix(other.Network, "tcp") + isUDP1 := strings.HasPrefix(na.Network, "udp") + isUDP2 := strings.HasPrefix(other.Network, "udp") + + // If one is udp and the other is tcp, they don't conflict. + if (isTCP1 && !isTCP2) || (isUDP1 && !isUDP2) { + return false + } + + // If one is strictly IPv4 and the other is strictly IPv6, they don't conflict. + // (e.g., tcp4 vs tcp6, or udp4 vs udp6) + if (strings.HasSuffix(na.Network, "4") && strings.HasSuffix(other.Network, "6")) || + (strings.HasSuffix(na.Network, "6") && strings.HasSuffix(other.Network, "4")) { + return false + } + + // 2. Check if ports overlap + portsOverlap := na.StartPort <= other.EndPort && na.EndPort >= other.StartPort + if !portsOverlap { + return false + } + + // 3. Check if hosts overlap + host1 := na.Host + host2 := other.Host + + // Normalize catch-all addresses. They ONLY conflict if BOTH are catch-alls. + isCatchAll := func(h string) bool { + return h == "" || h == "0.0.0.0" || h == "::" || h == "[::]" + } + if isCatchAll(host1) && isCatchAll(host2) { + return true + } + + // Normalize loopbacks. They ONLY conflict if BOTH are loopbacks. + isLoopback := func(h string) bool { + return h == "localhost" || h == "127.0.0.1" || h == "::1" || h == "[::1]" + } + if isLoopback(host1) && isLoopback(host2) { + return true + } + + return host1 == host2 +} + // ListenAll calls Listen for all addresses represented by this struct, i.e. all ports in the range. // (If the address doesn't use ports or has 1 port only, then only 1 listener will be created.) // It returns an error if any listener failed to bind, and closes any listeners opened up to that point. diff --git a/listeners_test.go b/listeners_test.go index a4cadd3aab1..bbb3faa8a90 100644 --- a/listeners_test.go +++ b/listeners_test.go @@ -652,3 +652,82 @@ func TestSplitUnixSocketPermissionsBits(t *testing.T) { } } } + +func TestNetworkAddressConflictsWith(t *testing.T) { + tests :=[]struct { + name string + addr1 NetworkAddress + addr2 NetworkAddress + want bool + }{ + { + name: "Exact same address", + addr1: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019}, + want: true, + }, + { + name: "Different ports", + addr1: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2020, EndPort: 2020}, + want: false, + }, + { + name: "Port range overlap", + addr1: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2025}, + addr2: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2020, EndPort: 2020}, + want: true, + }, + { + name: "IPv4 loopback vs localhost", + addr1: NetworkAddress{Network: "tcp", Host: "127.0.0.1", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019}, + want: true, + }, + { + name: "IPv6 loopback vs IPv4 loopback", + addr1: NetworkAddress{Network: "tcp", Host: "::1", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "tcp", Host: "127.0.0.1", StartPort: 2019, EndPort: 2019}, + want: true, + }, + { + name: "Catch-all (empty) vs localhost", + addr1: NetworkAddress{Network: "tcp", Host: "", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019}, + want: false, + }, + { + name: "Catch-all (0.0.0.0) vs 127.0.0.1", + addr1: NetworkAddress{Network: "tcp", Host: "0.0.0.0", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "tcp", Host: "127.0.0.1", StartPort: 2019, EndPort: 2019}, + want: false, + }, + { + name: "Different networks tcp vs udp", + addr1: NetworkAddress{Network: "tcp", Host: "localhost", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "udp", Host: "localhost", StartPort: 2019, EndPort: 2019}, + want: false, + }, + { + name: "Strict IPv4 vs Strict IPv6", + addr1: NetworkAddress{Network: "tcp4", Host: "127.0.0.1", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "tcp6", Host: "::1", StartPort: 2019, EndPort: 2019}, + want: false, + }, + { + name: "Catch-all (0.0.0.0) vs Catch-all (empty)", + addr1: NetworkAddress{Network: "tcp", Host: "0.0.0.0", StartPort: 2019, EndPort: 2019}, + addr2: NetworkAddress{Network: "tcp", Host: "", StartPort: 2019, EndPort: 2019}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.addr1.ConflictsWith(tt.addr2) + if got != tt.want { + t.Errorf("ConflictsWith() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 673c36d7767..46fd37187a7 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -202,6 +202,13 @@ func (app *App) Provision(ctx caddy.Context) error { if err != nil { return err } + // We explicitly only check the local admin address here. Remote admin + // listeners are manually configured, but the local default (2019) is + // highly susceptible to silent hijacking by typical web listener configs. + adminAddr, adminEnabled, err := ctx.LocalAdminAddress() + if err != nil { + return fmt.Errorf("loading admin endpoint listen address: %v", err) + } if app.Metrics != nil { app.Metrics.init = sync.Once{} @@ -322,6 +329,18 @@ func (app *App) Provision(ctx caddy.Context) error { return fmt.Errorf("server %s, listener %d: %v", srvName, i, err) } srv.Listen[i] = lnOut + + if !adminEnabled { + continue + } + + listenAddr, err := caddy.ParseNetworkAddress(lnOut) + if err != nil { + return fmt.Errorf("server %s, listener %d: parsing listener address '%s': %v", srvName, i, lnOut, err) + } + if listenAddr.ConflictsWith(adminAddr) { + return fmt.Errorf("server %s, listener %d: listener address '%s' conflicts with local admin endpoint '%s'", srvName, i, lnOut, adminAddr.String()) + } } // set up each listener modifier