Ctrl K
ComunidadGuías…

internal/server/upstream_guard.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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 ""
}