Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions listeners.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
79 changes: 79 additions & 0 deletions listeners_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Comment on lines +694 to +698
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is actually allowed though. You should be able to serve a public and private port 2019.

{
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)
}
})
}
}
19 changes: 19 additions & 0 deletions modules/caddyhttp/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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
Expand Down