Ctrl K
ComunidadGuías…

internal/server/upstream_guard_test.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"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strconv"
	"strings"
	"testing"

	"github.com/X4Applegate/caddyui/internal/auth"
	"github.com/X4Applegate/caddyui/internal/caddy"
	appdb "github.com/X4Applegate/caddyui/internal/db"
	"github.com/X4Applegate/caddyui/internal/models"
	"github.com/go-chi/chi/v5"
)

// TestUpstreamHostBlockedForNonAdmin covers the address classifier that backs
// the SSRF guard reported (3rd finding) in GHSA-r4wm-rgc5-q834.
func TestUpstreamHostBlockedForNonAdmin(t *testing.T) {
	cases := []struct {
		host      string
		adminHost string
		blocked   bool
	}{
		// Loopback / admin-API vectors.
		{"127.0.0.1", "", true},
		{"127.0.0.1:2019", "", true},
		{"localhost", "", true},
		{"localhost:2019", "", true},
		{"app.localhost", "", true},
		{"[::1]", "", true},
		{"::1", "", true},
		{"[::1]:2019", "", true},
		// Unspecified.
		{"0.0.0.0", "", true},
		{"[::]", "", true},
		// Link-local incl. cloud metadata.
		{"169.254.169.254", "", true},
		{"169.254.1.1:80", "", true},
		// Configured remote admin endpoint (not loopback) is blocked by host match.
		{"10.8.0.1", "10.8.0.1", true},
		// Allowed: public and private-LAN upstreams (legitimate delegated use).
		{"203.0.113.10", "", false},
		{"203.0.113.10:8080", "", false},
		{"192.168.1.50", "", false},
		{"10.0.0.5:3000", "", false},
		{"172.16.4.4", "", false},
		{"backend.invalid", "", false},  // unresolvable hostname → not blocked
		{"10.8.0.2", "10.8.0.1", false}, // different host than admin endpoint
		{"", "", false},                 // empty handled by callers
	}
	for _, c := range cases {
		if got := upstreamHostBlockedForNonAdmin(c.host, c.adminHost); got != c.blocked {
			t.Errorf("upstreamHostBlockedForNonAdmin(%q, %q) = %v, want %v", c.host, c.adminHost, got, c.blocked)
		}
	}
}

// TestValidateProxyUpstreamsForUser covers role-awareness and every upstream
// sink (primary host, extra upstreams, Host override).
func TestValidateProxyUpstreamsForUser(t *testing.T) {
	s := &Server{Caddy: caddy.New("http://127.0.0.1:2019", "", "")}
	admin := &models.User{ID: 1, IsAdmin: true, Role: models.RoleAdmin}
	user := &models.User{ID: 2, Role: models.RoleUser}

	// Admins are never restricted, even for the admin API itself.
	if msg := s.validateProxyUpstreamsForUser(admin, &models.ProxyHost{ForwardHost: "127.0.0.1", ForwardPort: 2019}); msg != "" {
		t.Fatalf("admin must be unrestricted, got: %s", msg)
	}

	blocked := []*models.ProxyHost{
		{ForwardHost: "127.0.0.1", ForwardPort: 2019},                                     // primary → admin API
		{ForwardHost: "localhost", ForwardPort: 8080},                                     // primary → loopback name
		{ForwardHost: "backend", ForwardPort: 80, ExtraUpstreams: `["127.0.0.1:2019"]`},   // extra upstream
		{ForwardHost: "backend", ForwardPort: 80, UpstreamHostOverride: "localhost:2019"}, // Host override bypass
		{ForwardHost: "169.254.169.254", ForwardPort: 80},                                 // cloud metadata
	}
	for _, p := range blocked {
		if msg := s.validateProxyUpstreamsForUser(user, p); msg == "" {
			t.Errorf("expected non-admin block for %+v, got allowed", p)
		}
	}

	allowed := []*models.ProxyHost{
		{ForwardHost: "203.0.113.10", ForwardPort: 8080}, // public
		{ForwardHost: "192.168.1.10", ForwardPort: 3000}, // private LAN still allowed
	}
	for _, p := range allowed {
		if msg := s.validateProxyUpstreamsForUser(user, p); msg != "" {
			t.Errorf("expected allow for %+v, got blocked: %s", p, msg)
		}
	}
}

// TestAPIV1ProxyHostSSRFGuard is the end-to-end handler regression: a low-priv
// user-role account must not be able to create OR update a proxy host whose
// upstream targets the Caddy admin API / internal addresses, while admins and
// legitimate public upstreams keep working.
func TestAPIV1ProxyHostSSRFGuard(t *testing.T) {
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })

	// AdminURL points at a closed port so the post-write sync fails instantly
	// and non-fatally (trySyncCaddy logs and moves on).
	if _, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "primary", AdminURL: "http://127.0.0.1:1"}); err != nil {
		t.Fatal(err)
	}
	s := &Server{DB: conn, Caddy: caddy.New("http://127.0.0.1:1", "", "")}

	admin := &models.User{ID: 1, Email: "admin@test", IsAdmin: true, Role: models.RoleAdmin}
	user := &models.User{ID: 2, Email: "user@test", Role: models.RoleUser}

	post := func(u *models.User, body string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
		ctx := context.WithValue(req.Context(), auth.ContextUserKey, u)
		rec := httptest.NewRecorder()
		s.apiV1CreateProxyHost(rec, req.WithContext(ctx))
		return rec
	}
	put := func(u *models.User, id int64, body string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+strconv.FormatInt(id, 10), strings.NewReader(body))
		rctx := chi.NewRouteContext()
		rctx.URLParams.Add("id", strconv.FormatInt(id, 10))
		ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
		ctx = context.WithValue(ctx, auth.ContextUserKey, u)
		rec := httptest.NewRecorder()
		s.apiV1UpdateProxyHost(rec, req.WithContext(ctx))
		return rec
	}

	// 1. user-role create targeting the Caddy admin API → 403, nothing persisted.
	rec := post(user, `{"domains":"evil.test","forward_scheme":"http","forward_host":"127.0.0.1","forward_port":2019,"enabled":true}`)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("SSRF create: expected 403, got %d: %s", rec.Code, rec.Body.String())
	}
	if hosts, _ := models.ListProxyHosts(conn, 1, 1, true, nil); len(hosts) != 0 {
		t.Fatalf("blocked host must not be persisted, found %d", len(hosts))
	}

	// 2. user-role create via extra upstream → 403.
	if rec := post(user, `{"domains":"evil2.test","forward_scheme":"http","forward_host":"backend","forward_port":80,"enabled":true,"extra_upstreams":"[\"127.0.0.1:2019\"]"}`); rec.Code != http.StatusForbidden {
		t.Fatalf("SSRF create via extra_upstreams: expected 403, got %d: %s", rec.Code, rec.Body.String())
	}

	// 3. user-role create with a legitimate public upstream → 201.
	if rec := post(user, `{"domains":"ok.test","forward_scheme":"http","forward_host":"203.0.113.10","forward_port":8080,"enabled":true}`); rec.Code != http.StatusCreated {
		t.Fatalf("legit user create: expected 201, got %d: %s", rec.Code, rec.Body.String())
	}

	// 4. admin create targeting loopback → 201 (admins unrestricted; common
	//    single-admin "proxy to localhost app" case must keep working).
	if rec := post(admin, `{"domains":"adminlocal.test","forward_scheme":"http","forward_host":"127.0.0.1","forward_port":3000,"enabled":true}`); rec.Code != http.StatusCreated {
		t.Fatalf("admin loopback create: expected 201, got %d: %s", rec.Code, rec.Body.String())
	}

	// 5. user edits their legitimate host to target the admin API → 403, unchanged.
	var okID int64
	allHosts, _ := models.ListProxyHosts(conn, 1, 1, true, nil)
	for _, h := range allHosts {
		if h.Domains == "ok.test" {
			okID = h.ID
		}
	}
	if okID == 0 {
		t.Fatal("could not find user's legit host to edit")
	}
	if rec := put(user, okID, `{"forward_host":"127.0.0.1","forward_port":2019}`); rec.Code != http.StatusForbidden {
		t.Fatalf("SSRF update: expected 403, got %d: %s", rec.Code, rec.Body.String())
	}
	after, _ := models.GetProxyHost(conn, okID)
	if after == nil || after.ForwardHost != "203.0.113.10" {
		t.Fatalf("blocked update must not change upstream, got %+v", after)
	}
}