internal/server/api_v1_ownership_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"
appdb "github.com/X4Applegate/caddyui/internal/db"
"github.com/X4Applegate/caddyui/internal/models"
"github.com/go-chi/chi/v5"
)
// TestAPIV1PerRowOwnershipEnforced is the regression test for
// GHSA-r4wm-rgc5-q834: the REST JSON API v1 must enforce per-row ownership so
// that a lowest-privilege role cannot read another tenant's TLS private keys
// and a "user"-role account cannot touch another tenant's routing config.
func TestAPIV1PerRowOwnershipEnforced(t *testing.T) {
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
serverID, err := models.CreateCaddyServer(conn, &models.CaddyServer{
Name: "primary", AdminURL: "http://primary.invalid:2019",
})
if err != nil {
t.Fatal(err)
}
const user2ID = int64(2)
admin := &models.User{ID: 1, Email: "admin@example.com", IsAdmin: true, Role: models.RoleAdmin}
user2 := &models.User{ID: user2ID, Email: "user2@example.com", Role: models.RoleUser}
viewer := &models.User{ID: 3, Email: "viewer@example.com", Role: models.RoleView}
// Admin-owned resources (ownerID 0 persists as NULL owner).
certAdmin, err := models.CreateCertificate(conn, serverID, 0, &models.Certificate{
Name: "AdminSecretCert", Source: "pem",
CertPEM: "-----BEGIN CERTIFICATE-----\nADMIN\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nSUPERSECRETADMINKEY\n-----END PRIVATE KEY-----",
})
if err != nil {
t.Fatal(err)
}
rrAdmin, err := models.CreateRawRoute(conn, serverID, 0, &models.RawRoute{Label: "admin-rr", JSONData: "{}", Enabled: true})
if err != nil {
t.Fatal(err)
}
rhAdmin, err := models.CreateRedirectionHost(conn, serverID, 0, &models.RedirectionHost{Domains: "admin.test", ForwardDomain: "dest.test", ForwardScheme: "auto", ForwardHTTPCode: 301, Enabled: true})
if err != nil {
t.Fatal(err)
}
phAdmin, err := models.CreateProxyHost(conn, serverID, 0, &models.ProxyHost{Domains: "admin.test", ForwardScheme: "http", ForwardHost: "backend", ForwardPort: 8080, Enabled: true})
if err != nil {
t.Fatal(err)
}
// A resource that user2 legitimately owns, for the positive control.
rrUser2, err := models.CreateRawRoute(conn, serverID, user2ID, &models.RawRoute{Label: "user2-rr", JSONData: "{}", Enabled: true})
if err != nil {
t.Fatal(err)
}
s := &Server{DB: conn}
call := func(method, id string, user *models.User, handler http.HandlerFunc) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/api/v1/x/"+id, strings.NewReader("{}"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", id)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, auth.ContextUserKey, user)
rec := httptest.NewRecorder()
handler(rec, req.WithContext(ctx))
return rec
}
id := func(v int64) string { return strconv.FormatInt(v, 10) }
// ── Finding 1: private-key disclosure to the lowest-privilege role ──────────
t.Run("viewer cannot read admin certificate private key", func(t *testing.T) {
rec := call(http.MethodGet, id(certAdmin), viewer, s.apiV1GetCertificate)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "SUPERSECRETADMINKEY") {
t.Fatalf("private key leaked to viewer: %s", rec.Body.String())
}
})
t.Run("admin can read own certificate", func(t *testing.T) {
rec := call(http.MethodGet, id(certAdmin), admin, s.apiV1GetCertificate)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for admin, got %d: %s", rec.Code, rec.Body.String())
}
})
// ── Finding 2: cross-tenant read/write/delete/toggle by a user-role account ─
forbidden := []struct {
name string
method string
id int64
handler http.HandlerFunc
}{
{"get raw-route", http.MethodGet, rrAdmin, s.apiV1GetRawRoute},
{"update raw-route", http.MethodPut, rrAdmin, s.apiV1UpdateRawRoute},
{"delete raw-route", http.MethodDelete, rrAdmin, s.apiV1DeleteRawRoute},
{"toggle raw-route", http.MethodPost, rrAdmin, s.apiV1ToggleRawRoute},
{"get redirection-host", http.MethodGet, rhAdmin, s.apiV1GetRedirectionHost},
{"update redirection-host", http.MethodPut, rhAdmin, s.apiV1UpdateRedirectionHost},
{"delete redirection-host", http.MethodDelete, rhAdmin, s.apiV1DeleteRedirectionHost},
{"toggle redirection-host", http.MethodPost, rhAdmin, s.apiV1ToggleRedirectionHost},
{"toggle proxy-host", http.MethodPost, phAdmin, s.apiV1ToggleProxyHost},
{"maintenance proxy-host", http.MethodPost, phAdmin, s.apiV1ToggleMaintenanceProxyHost},
{"get certificate", http.MethodGet, certAdmin, s.apiV1GetCertificate},
{"update certificate", http.MethodPut, certAdmin, s.apiV1UpdateCertificate},
{"delete certificate", http.MethodDelete, certAdmin, s.apiV1DeleteCertificate},
}
for _, tc := range forbidden {
t.Run("user2 forbidden: "+tc.name, func(t *testing.T) {
rec := call(tc.method, id(tc.id), user2, tc.handler)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for %s, got %d: %s", tc.name, rec.Code, rec.Body.String())
}
})
}
// The admin's raw route must still exist after user2's blocked DELETE.
if got, _ := models.GetRawRoute(conn, rrAdmin); got == nil {
t.Fatal("admin raw route was deleted by an unauthorized user")
}
// ── Positive control: user2 may access the row it actually owns ─────────────
t.Run("user2 can read own raw-route", func(t *testing.T) {
rec := call(http.MethodGet, id(rrUser2), user2, s.apiV1GetRawRoute)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for owner, got %d: %s", rec.Code, rec.Body.String())
}
})
}