internal/server/security_tenant_guard_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"database/sql"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/X4Applegate/caddyui/internal/models"
)
// Tenant route guard (v2.57.1). Verified against a real Caddy 2.11: a response
// header or body of "{env.SECRET}" / "{file./path}" is expanded at request time
// and served to whoever requests the route, so a non-admin who can set any such
// string can read the Caddy process environment (where the shipped compose puts
// the DNS API token) and any file the Caddy container can read.
func route(handlers ...map[string]any) map[string]any {
hs := make([]any, len(handlers))
for i, h := range handlers {
hs[i] = h
}
return map[string]any{"match": []any{map[string]any{"host": []any{"t.example.test"}}}, "handle": hs}
}
func rp(dial string, extra map[string]any) map[string]any {
h := map[string]any{"handler": "reverse_proxy", "upstreams": []any{map[string]any{"dial": dial}}}
for k, v := range extra {
h[k] = v
}
return h
}
func hostOverride(host string) map[string]any {
return map[string]any{"request": map[string]any{"set": map[string]any{"Host": []any{host}}}}
}
func TestTenantRouteViolation(t *testing.T) {
e := newSecEnv(t)
// A second registered Caddy node: its admin API must be off limits too, not
// just the primary's.
if _, err := models.CreateCaddyServer(e.db, &models.CaddyServer{Name: "edge", AdminURL: "http://10.8.0.2:2019", Type: models.CaddyServerTypeManaged}); err != nil {
t.Fatal(err)
}
bad := map[string]map[string]any{
"env placeholder in a response header": route(map[string]any{"handler": "static_response", "headers": map[string]any{"X-T": []any{"{env.CF_API_TOKEN}"}}}),
"file placeholder in a body": route(map[string]any{"handler": "static_response", "body": "x {file./data/caddy/pki/authorities/local/root.key}"}),
"spaced env placeholder": route(map[string]any{"handler": "static_response", "body": "{ env.X }"}),
"adapt-time $ placeholder": route(map[string]any{"handler": "static_response", "body": "{$SECRET}"}),
"upper-case placeholder": route(map[string]any{"handler": "static_response", "body": "{ENV.X}"}),
"file_server handler": route(map[string]any{"handler": "file_server", "root": "/"}),
"templates handler": route(map[string]any{"handler": "templates"}),
"acme_server handler": route(map[string]any{"handler": "acme_server"}),
"loopback admin API": route(rp("127.0.0.1:2019", nil)),
"localhost": route(rp("localhost:2019", nil)),
"tcp/ prefixed loopback": route(rp("tcp/127.0.0.1:2019", nil)),
"unix socket": route(rp("unix//run/caddy/admin.sock", nil)),
"placeholder dial": route(rp("{http.request.header.X-Target}:80", nil)),
"cloud metadata": route(rp("169.254.169.254:80", nil)),
"alibaba metadata": route(rp("100.100.100.200:80", nil)),
"azure wireserver": route(rp("168.63.129.16:80", nil)),
"ipv6 loopback": route(rp("[::1]:80", nil)),
"ipv4-mapped loopback": route(rp("[::ffff:127.0.0.1]:80", nil)),
"integer ip": route(rp("2130706433:80", nil)),
"hex ip": route(rp("0x7f000001:80", nil)),
"another fleet node's admin host": route(rp("10.8.0.2:80", nil)),
"host override to the admin API": route(rp("203.0.113.5:80", map[string]any{"headers": hostOverride("127.0.0.1:2019")})),
"dynamic upstreams": route(map[string]any{"handler": "reverse_proxy", "dynamic_upstreams": map[string]any{"source": "a", "name": "x"}}),
"upstream forward proxy to loopback": route(rp("203.0.113.5:80", map[string]any{
"transport": map[string]any{"protocol": "http", "network_proxy": map[string]any{"from": "url", "url": "http://127.0.0.1:3128"}}})),
"internal upstream hidden in a subroute": route(map[string]any{"handler": "subroute", "routes": []any{
map[string]any{"handle": []any{rp("127.0.0.1:2019", nil)}}}}),
"internal upstream hidden in handle_response": route(rp("203.0.113.5:80", map[string]any{"handle_response": []any{
map[string]any{"routes": []any{map[string]any{"handle": []any{rp("127.0.0.1:2019", nil)}}}}}})),
}
for name, rt := range bad {
if why := e.s.tenantRouteViolation(rt); why == "" {
t.Errorf("%s: a non-admin's route was allowed", name)
}
}
good := map[string]map[string]any{
"public upstream": route(rp("app.example.com:443", nil)),
"private LAN upstream": route(rp("10.0.0.5:8080", nil)),
"docker service name": route(rp("my-app:3000", nil)),
"tcp/ prefixed LAN upstream": route(rp("tcp/10.0.0.5:8080", nil)),
"ordinary host override": route(rp("10.0.0.5:8080", map[string]any{"headers": hostOverride("app.example.com")})),
"request placeholders are fine": route(map[string]any{"handler": "static_response", "headers": map[string]any{"X-Host": []any{"{http.request.host}"}}, "body": "{http.request.uri.path}"}),
"plain static response": route(map[string]any{"handler": "static_response", "body": "ok"}),
"a second LAN host that is no node": route(rp("10.8.0.3:80", nil)),
}
for name, rt := range good {
if why := e.s.tenantRouteViolation(rt); why != "" {
t.Errorf("%s: wrongly refused: %s", name, why)
}
}
}
func TestNeutraliseTenantPlaceholders(t *testing.T) {
r := route(map[string]any{
"handler": "static_response",
"headers": map[string]any{"X-A": []any{"{env.SECRET}", "{http.request.host}"}},
"body": "{file./etc/passwd} and { ENV.X } and {http.request.uri}",
})
out, changed := neutraliseTenantPlaceholders(r)
if !changed {
t.Fatal("nothing was neutralised")
}
b, _ := json.Marshal(out)
body := string(b)
for _, bad := range []string{"{env.", "{file.", "{ ENV.", "{ env."} {
if strings.Contains(body, bad) {
t.Errorf("neutralised route still contains %q: %s", bad, body)
}
}
if !strings.Contains(body, "{http.request.host}") || !strings.Contains(body, "{http.request.uri}") {
t.Errorf("request placeholders must be left alone: %s", body)
}
if again, changedAgain := neutraliseTenantPlaceholders(out); changedAgain {
t.Errorf("neutralising twice changed it again: %v", again)
}
}
func TestTenantTextViolation(t *testing.T) {
bad := map[string]string{
"env substitution": "example.com {\n\trespond \"{$CF_API_TOKEN}\"\n}",
"runtime env placeholder": "example.com {\n\theader X-T {env.CF_API_TOKEN}\n}",
"file placeholder": "example.com {\n\trespond {file./etc/passwd}\n}",
"import of an absolute path": "import /data/caddy/certificates/x.key",
"import of a glob": "example.com {\n\timport /etc/*.conf\n}",
"import with an extension": "import secrets.env",
"import inside a block": "example.com { import /etc/shadow }",
}
for name, txt := range bad {
if tenantTextViolation(txt) == "" {
t.Errorf("%s: allowed", name)
}
}
good := map[string]string{
"plain": "example.com {\n\treverse_proxy 10.0.0.5:8080\n}",
"import a snippet by name": "example.com {\n\timport my_snippet\n}",
"snippet with dashes": "example.com {\n\timport secure-headers\n}",
"snippet definition": "(common) {\n\theader X-A 1\n}\nexample.com {\n\timport common\n}",
"request placeholders": "example.com {\n\theader X-Host {http.request.host}\n}",
}
for name, txt := range good {
if why := tenantTextViolation(txt); why != "" {
t.Errorf("%s: refused: %s", name, why)
}
}
}
// --- sync-time enforcement ------------------------------------------------
// A row owned by a non-admin is neutralised or skipped when the live config is
// built, whichever save path (UI, REST, import, AI tool, clone) stored it — so
// a bad row that is already in the database can never reach Caddy.
func TestSyncTimeEnforcementOnlyAppliesToNonAdminOwnedRows(t *testing.T) {
e := newSecEnv(t)
hdr := `{"X-Leak":"{env.CF_API_TOKEN}"}`
mk := func(owner int64) models.ProxyHost {
return models.ProxyHost{
Domains: "h.example.test", ForwardScheme: "http", ForwardHost: "10.0.0.5", ForwardPort: 8080,
Enabled: true, CustomRespHeaders: hdr, OwnerID: ownerNull(owner),
}
}
build := func(p models.ProxyHost, rr models.RawRoute, rd models.RedirectionHost) string {
routes := e.s.buildMergedRoutes([]models.ProxyHost{p}, []models.RedirectionHost{rd}, []models.RawRoute{rr})
b, _ := json.Marshal(routes)
return string(b)
}
rawJSON := `{"match":[{"host":["raw.example.test"]}],"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"127.0.0.1:2019"}]}]}`
rd := func(owner int64) models.RedirectionHost {
return models.RedirectionHost{Domains: "r.example.test", ForwardScheme: "https", ForwardDomain: "{file./etc/passwd}.evil.test", ForwardHTTPCode: 301, Enabled: true, OwnerID: ownerNull(owner)}
}
raw := func(owner int64) models.RawRoute {
return models.RawRoute{Label: "raw", JSONData: rawJSON, Enabled: true, OwnerID: ownerNull(owner)}
}
// Owned by alice: neutralised / skipped.
out := build(mk(e.ids["alice"]), raw(e.ids["alice"]), rd(e.ids["alice"]))
if strings.Contains(out, "{env.") || strings.Contains(out, "{file.") {
t.Errorf("a non-admin-owned route still carries a process-reading placeholder:\n%s", out)
}
if !strings.Contains(out, "{blocked-env.") {
t.Errorf("expected the header placeholder to be neutralised, not dropped:\n%s", out)
}
if strings.Contains(out, "127.0.0.1:2019") || strings.Contains(out, "raw.example.test") {
t.Errorf("a non-admin-owned route to the Caddy admin API was deployed:\n%s", out)
}
// Owned by the admin (owner NULL): untouched, including legitimate use of
// {env.…} in the admin's own config.
out = build(mk(0), raw(0), rd(0))
if !strings.Contains(out, "{env.CF_API_TOKEN}") || !strings.Contains(out, "127.0.0.1:2019") || !strings.Contains(out, "{file./etc/passwd}") {
t.Errorf("an admin-owned route was altered:\n%s", out)
}
}
// --- through the real router ---------------------------------------------
// Caddy substitutes {$ENV} while ADAPTING and its errors echo parsed text, so a
// non-admin's Caddyfile text must be refused before it is sent to Caddy at all.
func TestNonAdminCaddyfileTextNeverReachesAdapt(t *testing.T) {
e := newSecEnv(t)
form := url.Values{
"label": {"x"},
"caddyfile_src": {"x.example.test {\n\trespond \"{$CF_API_TOKEN}\"\n}"},
"enabled": {"on"},
}
rec := e.do(t, "alice", http.MethodPost, "/raw-routes", form)
if rec.Code == http.StatusSeeOther {
t.Fatalf("the route was accepted (redirect to %s)", rec.Header().Get("Location"))
}
if !strings.Contains(rec.Body.String(), "admin-only") {
t.Errorf("the user should be told why: %s", excerpt(rec.Body.String(), "Not allowed"))
}
e.mu.Lock()
defer e.mu.Unlock()
for _, rq := range e.reqs {
if strings.Contains(rq, "/adapt") && strings.Contains(rq, "CF_API_TOKEN") {
t.Errorf("the risky text was sent to Caddy's /adapt: %s", rq)
}
}
}
func TestNonAdminCannotSaveRawRoutesAimedAtTheAdminAPI(t *testing.T) {
e := newSecEnv(t)
bad := `{"match":[{"host":["raw.example.test"]}],"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"127.0.0.1:2019"}],"headers":{"request":{"set":{"Host":["127.0.0.1:2019"]}}}}]}`
// UI form
rec := e.do(t, "alice", http.MethodPost, "/raw-routes", url.Values{"label": {"x"}, "json_data": {bad}, "enabled": {"on"}})
if rec.Code == http.StatusSeeOther {
t.Errorf("UI: the route was accepted")
}
// REST API
rec = e.do(t, "alice", http.MethodPost, "/api/v1/raw-routes", url.Values{})
_ = rec
body, _ := json.Marshal(map[string]any{"label": "x", "json_data": bad, "enabled": true})
req := httptest.NewRequest(http.MethodPost, "/api/v1/raw-routes", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
e.attachSession(req, "alice")
rr := httptest.NewRecorder()
e.h.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("REST: status %d, want 400: %s", rr.Code, rr.Body.String())
}
if rows, _ := models.ListRawRoutes(e.db, 1, 0, true, nil); len(rows) != 0 {
t.Errorf("%d raw route(s) were stored", len(rows))
}
// The admin may.
rec = e.do(t, "admin", http.MethodPost, "/raw-routes", url.Values{"label": {"x"}, "json_data": {bad}, "enabled": {"on"}})
if rec.Code != http.StatusSeeOther {
t.Errorf("admin: status %d, want a redirect", rec.Code)
}
}
func TestNonAdminProxyHostCannotLeakProcessSecretsThroughHeadersOrAdvancedConfig(t *testing.T) {
e := newSecEnv(t)
n := 0
base := func() url.Values {
n++
return url.Values{"domains": {"leak" + strconv.Itoa(n) + ".example.test"}, "forward_scheme": {"http"}, "forward_host": {"10.0.0.5"}, "forward_port": {"8080"}, "enabled": {"on"}}
}
stored := func() int {
hosts, _ := models.ListProxyHosts(e.db, 1, 0, true, nil)
return len(hosts)
}
// A custom response header carrying an env placeholder.
f := base()
f["header_resp_key"], f["header_resp_val"] = []string{"X-Leak"}, []string{"{env.CF_API_TOKEN}"}
if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", f); rec.Code == http.StatusSeeOther {
t.Error("a response header reading {env.…} was accepted")
}
// A custom request header with a file placeholder.
f = base()
f["header_req_key"], f["header_req_val"] = []string{"X-Leak"}, []string{"{file./data/caddy/x}"}
if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", f); rec.Code == http.StatusSeeOther {
t.Error("a request header reading {file.…} was accepted")
}
// Advanced config reading the environment at adapt time.
f = base()
f.Set("advanced_config", "respond \"{$CF_API_TOKEN}\"")
if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", f); rec.Code == http.StatusSeeOther {
t.Error("advanced config using {$ENV} was accepted")
}
// An upstream that is the Caddy admin API itself.
f = base()
f.Set("forward_host", "127.0.0.1")
f.Set("forward_port", "2019")
if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", f); rec.Code == http.StatusSeeOther {
t.Error("an upstream of 127.0.0.1:2019 was accepted")
}
if got := stored(); got != 0 {
t.Fatalf("%d host(s) were stored by the rejected requests", got)
}
// An ordinary host still works for the same user, and so does the admin's
// own use of a placeholder.
if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", base()); rec.Code != http.StatusSeeOther {
t.Errorf("a plain host was refused: %d %s", rec.Code, excerpt(rec.Body.String(), "Not allowed"))
}
f = base()
f["header_resp_key"], f["header_resp_val"] = []string{"X-Mine"}, []string{"{env.MY_OWN}"}
if rec := e.do(t, "admin", http.MethodPost, "/proxy-hosts", f); rec.Code != http.StatusSeeOther {
t.Errorf("the admin's own {env.…} header was refused: %d", rec.Code)
}
}
func TestHelperEndpointsNeedWriteAccessAndTheUpstreamGuard(t *testing.T) {
e := newSecEnv(t)
// An internal listener the CaddyUI process could reach.
ln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer ln.Close()
host, port, _ := net.SplitHostPort(strings.TrimPrefix(ln.URL, "http://"))
form := url.Values{"host": {host}, "port": {port}, "scheme": {"http"}}
if rec := e.do(t, "viewer", http.MethodPost, "/api/proxy-hosts/test-upstream", form); rec.Code != http.StatusForbidden {
t.Errorf("viewer test-upstream -> %d, want 403", rec.Code)
}
rec := e.do(t, "alice", http.MethodPost, "/api/proxy-hosts/test-upstream", form)
if !strings.Contains(rec.Body.String(), `"ok":false`) || !strings.Contains(rec.Body.String(), "Not allowed") {
t.Errorf("a non-admin probing a loopback listener got: %s", rec.Body.String())
}
rec = e.do(t, "admin", http.MethodPost, "/api/proxy-hosts/test-upstream", form)
if !strings.Contains(rec.Body.String(), `"ok":true`) {
t.Errorf("the admin's probe of a reachable listener failed: %s", rec.Body.String())
}
for _, p := range []string{"/api/proxy-hosts/preview", "/api/raw-routes/validate"} {
if rec := e.do(t, "viewer", http.MethodPost, p, url.Values{}); rec.Code != http.StatusForbidden {
t.Errorf("viewer %s -> %d, want 403", p, rec.Code)
}
}
}
func ownerNull(id int64) (n sql.NullInt64) {
if id > 0 {
n.Valid, n.Int64 = true, id
}
return n
}
var _ = strconv.Itoa
// CaddyUI's own probes (health monitor, app monitor, post-sync expectations,
// certificate probe) build URLs from a host's first domain and a custom monitor
// path/method. A customer could set the domain to "10.8.0.2:2019" with a custom
// POST check and have CaddyUI send that request to the internal address every
// minute.
func TestNonAdminProxyHostDomainsAndMonitorsCannotAimProbesAtInternalAddresses(t *testing.T) {
e := newSecEnv(t)
n := 0
form := func(mod func(url.Values)) url.Values {
n++
f := url.Values{"domains": {"probe" + strconv.Itoa(n) + ".example.test"}, "forward_scheme": {"http"},
"forward_host": {"10.0.0.5"}, "forward_port": {"8080"}, "enabled": {"on"}}
mod(f)
return f
}
refused := map[string]func(url.Values){
"domain with a port": func(f url.Values) { f.Set("domains", "10.8.0.2:2019") },
"domain with a path": func(f url.Values) { f.Set("domains", "x.example.test/admin") },
"loopback IP as the domain": func(f url.Values) { f.Set("domains", "127.0.0.1") },
"metadata IP as the domain": func(f url.Values) { f.Set("domains", "169.254.169.254") },
"placeholder in the domain": func(f url.Values) { f.Set("domains", "{env.X}.example.test") },
"custom POST health check": func(f url.Values) { f.Set("monitor_mode", "custom"); f.Set("monitor_method", "POST") },
"custom DELETE health check": func(f url.Values) { f.Set("monitor_mode", "custom"); f.Set("monitor_method", "DELETE") },
"health-check path with a space": func(f url.Values) { f.Set("monitor_mode", "custom"); f.Set("monitor_path", "/a b") },
}
for name, mod := range refused {
if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", form(mod)); rec.Code == http.StatusSeeOther {
t.Errorf("%s was accepted", name)
}
}
if hosts, _ := models.ListProxyHosts(e.db, 1, 0, true, nil); len(hosts) != 0 {
t.Fatalf("%d host(s) were stored by the refused requests", len(hosts))
}
allowed := map[string]func(url.Values){
"plain host": func(f url.Values) {},
"wildcard host": func(f url.Values) { f.Set("domains", "*.wild.example.test") },
"custom GET check": func(f url.Values) {
f.Set("monitor_mode", "custom")
f.Set("monitor_method", "GET")
f.Set("monitor_path", "/healthz?x=1")
},
"custom HEAD check": func(f url.Values) { f.Set("monitor_mode", "custom"); f.Set("monitor_method", "HEAD") },
"single-label hostname": func(f url.Values) { f.Set("domains", "intranet") },
}
for name, mod := range allowed {
if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", form(mod)); rec.Code != http.StatusSeeOther {
t.Errorf("%s was refused: %d %s", name, rec.Code, excerpt(rec.Body.String(), "Not allowed"))
}
}
// The admin keeps full flexibility, including a custom POST check.
if rec := e.do(t, "admin", http.MethodPost, "/proxy-hosts", form(func(f url.Values) {
f.Set("monitor_mode", "custom")
f.Set("monitor_method", "POST")
})); rec.Code != http.StatusSeeOther {
t.Errorf("the admin's custom POST health check was refused: %d", rec.Code)
}
}
// A handler nested inside an Advanced-config block is invisible to the
// top-level directive scan; the save-time check now looks at what the text
// adapted to, so a non-admin is told why instead of having the route skipped.
func TestNonAdminAdvancedConfigIsCheckedAfterAdapting(t *testing.T) {
e := newSecEnv(t)
// Make the fake Caddy adapt the Advanced config into a file_server handler,
// the way a nested `handle { root * / ; file_server browse }` would.
e.mu.Lock()
e.adaptResponse = `{"result":{"apps":{"http":{"servers":{"srv0":{"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"file_server","root":"/","browse":{}}]}]}]}]}}}}}}`
e.mu.Unlock()
form := url.Values{"domains": {"adv.example.test"}, "forward_scheme": {"http"}, "forward_host": {"10.0.0.5"}, "forward_port": {"8080"},
"enabled": {"on"}, "advanced_config": {"handle {\n\troot * /\n\tfile_server browse\n}"}}
if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", form); rec.Code == http.StatusSeeOther {
t.Error("a non-admin saved Advanced config that adapts to a file_server")
} else if !strings.Contains(rec.Body.String(), "file_server") {
t.Errorf("the user should be told which handler is not allowed: %s", excerpt(rec.Body.String(), "Not allowed"))
}
form.Set("domains", "adv2.example.test")
if rec := e.do(t, "admin", http.MethodPost, "/proxy-hosts", form); rec.Code != http.StatusSeeOther {
t.Errorf("the admin's Advanced config was refused: %d", rec.Code)
}
}
// Some paths assign a row to an administrator's own user id (the JSON import
// gives the row to whoever imports). Those rows are the admin's, not a
// tenant's: the guard must not restrict them — doing so would silently drop an
// admin's own routes on the next sync.
func TestTenantGuardTreatsRowsOwnedByAnAdministratorAsAdminOwned(t *testing.T) {
e := newSecEnv(t)
leak := `{"X-Leak":"{env.MY_OWN}"}`
mk := func(owner int64) models.ProxyHost {
return models.ProxyHost{Domains: "h.example.test", ForwardScheme: "http", ForwardHost: "127.0.0.1", ForwardPort: 8080,
Enabled: true, CustomRespHeaders: leak, OwnerID: ownerNull(owner)}
}
build := func(p models.ProxyHost) string {
b, _ := json.Marshal(e.s.buildMergedRoutes([]models.ProxyHost{p}, nil, nil))
return string(b)
}
if out := build(mk(e.ids["admin"])); !strings.Contains(out, "{env.MY_OWN}") || !strings.Contains(out, "127.0.0.1") {
t.Errorf("a row owned by the admin's own user id was restricted:\n%s", out)
}
if out := build(mk(e.ids["alice"])); strings.Contains(out, "{env.MY_OWN}") {
t.Errorf("a customer-owned row kept its placeholder:\n%s", out)
}
// An owner that no longer exists cannot be vouched for.
if out := build(mk(987654)); strings.Contains(out, "{env.MY_OWN}") {
t.Errorf("a row with a deleted owner was trusted:\n%s", out)
}
}