// SPDX-License-Identifier: Apache-2.0 package server import ( "context" "fmt" "net" "net/url" "strings" "time" "github.com/X4Applegate/caddyui/internal/caddy" "github.com/X4Applegate/caddyui/internal/models" ) // Upstream SSRF guard (GHSA-r4wm-rgc5-q834, 3rd finding). // // A proxy host's ForwardHost/ForwardPort, its extra upstreams, and its upstream // Host-header override all flow verbatim into Caddy's reverse_proxy `dial` // (see caddy.BuildProxyRoute / client.go). Before v2.52.2 nothing constrained // those values, so any account that can write a proxy host — including a // low-privilege non-admin `user` account (per-row ownership, added in v2.52.1) // — could point an upstream at the Caddy admin API (http://localhost:2019), // the CaddyUI host's own loopback services, or cloud metadata // (169.254.169.254) and read/alter internal state through the proxy. Combined // with the user-settable upstream Host-header override, the admin API's origin // check is defeated too, which is RCE-grade (POST /load rewrites Caddy). // // The guard is role-aware on purpose: proxying to loopback/LAN addresses is the // primary, legitimate use of a reverse-proxy manager, so ADMINS are never // restricted. Only NON-admin accounts are blocked from targeting loopback, // unspecified, and link-local addresses (the SSRF-to-host / metadata vectors) // and the configured Caddy admin endpoint. Private LAN ranges stay allowed so // delegated users can still front their own internal apps. // // This is config-time validation. Caddy dials by the configured host on every // request, so a determined attacker could still use DNS rebinding (resolve to a // public IP at save time, loopback at dial time); defeating that needs // per-dial enforcement Caddy does not expose here. The guard decisively closes // the reported literal-address exploit and best-effort-resolves hostnames. // ipBlockedForNonAdmin reports whether a resolved IP is one a non-admin account // must not be able to reach as a proxy upstream. func ipBlockedForNonAdmin(ip net.IP) bool { if ip == nil { return false } return ip.IsLoopback() || // 127.0.0.0/8, ::1 ip.IsUnspecified() || // 0.0.0.0, :: ip.IsLinkLocalUnicast() || // 169.254.0.0/16 (incl. cloud metadata), fe80::/10 ip.IsLinkLocalMulticast() } // hostFromUpstream extracts the host portion from an upstream value that may be // a bare host, a "host:port" dial string, or a bracketed IPv6 literal. func hostFromUpstream(raw string) string { h := strings.TrimSpace(raw) if h == "" { return "" } if host, _, err := net.SplitHostPort(h); err == nil { return host } h = strings.TrimPrefix(h, "[") h = strings.TrimSuffix(h, "]") return h } // upstreamHostBlockedForNonAdmin classifies a single upstream host string. // adminHost, when non-empty, is the configured Caddy admin endpoint host and is // blocked outright (covers a remote admin endpoint that isn't loopback). func upstreamHostBlockedForNonAdmin(host, adminHost string) bool { set := map[string]bool{} if adminHost != "" { set[strings.ToLower(adminHost)] = true } return upstreamHostBlockedForNonAdminSet(host, set) } // upstreamHostBlockedForNonAdminSet is upstreamHostBlockedForNonAdmin against a // SET of admin endpoint hosts (the primary Caddy plus every registered fleet // node), so a tenant cannot aim at another node's admin API. func upstreamHostBlockedForNonAdminSet(host string, adminHosts map[string]bool) bool { h := strings.ToLower(hostFromUpstream(host)) if h == "" { return false } // Name-based loopback aliases that never resolve through ParseIP. if h == "localhost" || strings.HasSuffix(h, ".localhost") { return true } if adminHosts[h] { return true } if ip := net.ParseIP(h); ip != nil { return ipBlockedForNonAdmin(ip) || extraBlockedIP(ip) } // Hostname: best-effort resolve and block if ANY address is internal. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() addrs, err := net.DefaultResolver.LookupIPAddr(ctx, h) if err != nil { return false // unresolvable → don't block on an IP basis } for _, a := range addrs { if ipBlockedForNonAdmin(a.IP) || extraBlockedIP(a.IP) { return true } } return false } // adminHostFromURL parses the host (no port) out of a Caddy admin URL. Returns // "" for unix sockets or unparseable values. func adminHostFromURL(adminURL string) string { adminURL = strings.TrimSpace(adminURL) if adminURL == "" || strings.HasPrefix(adminURL, "unix") || strings.Contains(adminURL, "unix") { return "" } u, err := url.Parse(adminURL) if err != nil { return "" } return u.Hostname() } // validateProxyUpstreamsForUser returns a non-empty error message when a // non-admin account tries to point any upstream (primary, extra, or Host // override) at a blocked internal address. Admins are unrestricted. func (s *Server) validateProxyUpstreamsForUser(cu *models.User, p *models.ProxyHost) string { if cu != nil && cu.Role == models.RoleAdmin { return "" } if msg := s.certificateRefusal(cu, p.CertificateID); msg != "" { // v2.57.1 return msg } if msg := s.tenantProbeViolation(p); msg != "" { // v2.57.1 return msg } adminHosts := s.adminHostSet() candidates := make([]string, 0, 4) candidates = append(candidates, p.ForwardHost) candidates = append(candidates, p.ExtraUpstreamList()...) if p.UpstreamHostOverride != "" { candidates = append(candidates, p.UpstreamHostOverride) } for _, c := range candidates { if strings.TrimSpace(c) == "" { continue } if why := dialBlockedForTenant(c, adminHosts); why != "" { return fmt.Sprintf("Upstream %q is not allowed: %s. Ask an administrator if this upstream is legitimate.", strings.TrimSpace(c), why) } } if why := tenantTextViolation(p.AdvancedConfig); why != "" { return "Not allowed for a non-admin account: " + why + ". Ask an administrator." } // v2.57.1: also inspect the route CaddyUI would generate for this host, so // fields that become upstreams or handlers indirectly — additional upstream // rules, forward-proxy and forward-auth URLs, custom headers, advanced // config — are held to the same rules. if msg := s.validateTenantRoute(caddy.BuildProxyRoute(*p, nil)); msg != "" { return msg } return "" } // validateTenantRoute is tenantRouteViolation phrased for a form error. func (s *Server) validateTenantRoute(route any) string { if why := s.tenantRouteViolation(route); why != "" { return "Not allowed for a non-admin account: " + why + ". Ask an administrator." } return "" }