internal/server/upstream_guard_test.go
Rama main · réplica de lectura del repositorio original.
// 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)
}
}