From 93793970d82d7c0185c93d3f3076ee6aa9969dbc Mon Sep 17 00:00:00 2001 From: Ayush KAshyap Date: Mon, 20 Apr 2026 19:06:03 +0530 Subject: [PATCH 1/3] http: Prevent HTTP listeners from hijacking admin port (fix #7053) --- context.go | 23 +++++++++++++++++++++++ listeners.go | 36 ++++++++++++++++++++++++++++++++++++ modules/caddyhttp/app.go | 17 +++++++++++++++++ 3 files changed, 76 insertions(+) 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..6f38f1dfc96 100644 --- a/listeners.go +++ b/listeners.go @@ -58,6 +58,42 @@ type NetworkAddress struct { EndPort uint } +func (na NetworkAddress) ConflictsWith(other NetworkAddress) bool { + // 1. Check if networks conflict (e.g., tcp vs tcp4 vs tcp6) + // If one is udp and the other is tcp, they don't conflict. + 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 (isTCP1 && !isTCP2) || (isUDP1 && !isUDP2) { + 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 + // An empty host means "all interfaces" (0.0.0.0), which conflicts with everything. + if na.Host == "" || other.Host == "" { + return true + } + + // Normalize localhost and 127.0.0.1 to be treated as the same + host1 := na.Host + host2 := other.Host + if host1 == "localhost" { + host1 = "127.0.0.1" + } + if host2 == "localhost" { + host2 = "127.0.0.1" + } + + 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/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 673c36d7767..81bf6b78bc6 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -203,6 +203,11 @@ func (app *App) Provision(ctx caddy.Context) error { return err } + 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{} app.Metrics.httpMetrics = &httpMetrics{} @@ -322,6 +327,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 From e9df3304d406794086fb5e604c4feaa10cafbfaf Mon Sep 17 00:00:00 2001 From: Ayush KAshyap Date: Tue, 21 Apr 2026 15:17:40 +0530 Subject: [PATCH 2/3] Enhance ConflictsWith logic and add targeted network tests --- listeners.go | 31 +++++++++++------ listeners_test.go | 73 ++++++++++++++++++++++++++++++++++++++++ modules/caddyhttp/app.go | 4 ++- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/listeners.go b/listeners.go index 6f38f1dfc96..52bc583cab4 100644 --- a/listeners.go +++ b/listeners.go @@ -60,14 +60,22 @@ type NetworkAddress struct { func (na NetworkAddress) ConflictsWith(other NetworkAddress) bool { // 1. Check if networks conflict (e.g., tcp vs tcp4 vs tcp6) - // If one is udp and the other is tcp, they don't conflict. 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 @@ -76,19 +84,22 @@ func (na NetworkAddress) ConflictsWith(other NetworkAddress) bool { } // 3. Check if hosts overlap - // An empty host means "all interfaces" (0.0.0.0), which conflicts with everything. - if na.Host == "" || other.Host == "" { + host1 := na.Host + host2 := other.Host + + // A blank host, 0.0.0.0, or :: means "all interfaces", which conflicts with everything. + isAny1 := host1 == "" || host1 == "0.0.0.0" || host1 == "::" || host1 == "[::]" + isAny2 := host2 == "" || host2 == "0.0.0.0" || host2 == "::" || host2 == "[::]" + if isAny1 || isAny2 { return true } - // Normalize localhost and 127.0.0.1 to be treated as the same - host1 := na.Host - host2 := other.Host - if host1 == "localhost" { - host1 = "127.0.0.1" + // Normalize localhost, 127.0.0.1, and IPv6 loopbacks to be treated as the same + isLoopback := func(h string) bool { + return h == "localhost" || h == "127.0.0.1" || h == "::1" || h == "[::1]" } - if host2 == "localhost" { - host2 = "127.0.0.1" + if isLoopback(host1) && isLoopback(host2) { + return true } return host1 == host2 diff --git a/listeners_test.go b/listeners_test.go index a4cadd3aab1..8a81f0b57d1 100644 --- a/listeners_test.go +++ b/listeners_test.go @@ -652,3 +652,76 @@ 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: true, + }, + { + 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: true, + }, + { + 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, + }, + } + + 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 81bf6b78bc6..46fd37187a7 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -202,7 +202,9 @@ 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) From 44493ea65142b22cfcf126da07b5e35eef3fa522 Mon Sep 17 00:00:00 2001 From: Ayush KAshyap Date: Wed, 22 Apr 2026 10:33:06 +0530 Subject: [PATCH 3/3] fixing Specific vs. Wildcard binding hierarchy --- listeners.go | 11 ++++++----- listeners_test.go | 10 ++++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/listeners.go b/listeners.go index 52bc583cab4..9d4b5fb5c1e 100644 --- a/listeners.go +++ b/listeners.go @@ -87,14 +87,15 @@ func (na NetworkAddress) ConflictsWith(other NetworkAddress) bool { host1 := na.Host host2 := other.Host - // A blank host, 0.0.0.0, or :: means "all interfaces", which conflicts with everything. - isAny1 := host1 == "" || host1 == "0.0.0.0" || host1 == "::" || host1 == "[::]" - isAny2 := host2 == "" || host2 == "0.0.0.0" || host2 == "::" || host2 == "[::]" - if isAny1 || isAny2 { + // 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 localhost, 127.0.0.1, and IPv6 loopbacks to be treated as the same + // 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]" } diff --git a/listeners_test.go b/listeners_test.go index 8a81f0b57d1..bbb3faa8a90 100644 --- a/listeners_test.go +++ b/listeners_test.go @@ -694,13 +694,13 @@ func TestNetworkAddressConflictsWith(t *testing.T) { 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: true, + 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: true, + want: false, }, { name: "Different networks tcp vs udp", @@ -714,6 +714,12 @@ func TestNetworkAddressConflictsWith(t *testing.T) { 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 {