diff --git a/bouncer/audience_test.go b/bouncer/audience_test.go new file mode 100644 index 0000000..bb3ffe0 --- /dev/null +++ b/bouncer/audience_test.go @@ -0,0 +1,42 @@ +package bouncer + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestAudienceCrossover(t *testing.T) { + const secret = "same-secret-for-both-guards" + users := memUsers{byID: map[uint]*Principal{1: {ID: 1}}} + userTok, _, err := MintAudience(secret, "1", "https://app.test/login", time.Hour, AudienceUser) + if err != nil { + t.Fatal(err) + } + backendTok, _, err := MintAudience(secret, "1", "https://app.test/_admin/api/v1/auth/login", time.Hour, AudienceBackend) + if err != nil { + t.Fatal(err) + } + frontend := NewJWTGuard(secret, users, nil) + backend := NewBackendJWTGuard(secret, users, nil, nil) + + if principal, err := backend.Authenticate(withBearer(backendTok)); err != nil || principal == nil || principal.ID != 1 { + t.Fatalf("backend guard rejected its own audience: %v", err) + } + if principal, err := frontend.Authenticate(withBearer(userTok)); err != nil || principal == nil || principal.ID != 1 { + t.Fatalf("frontend guard rejected its own audience: %v", err) + } + if principal, err := backend.Authenticate(withBearer(userTok)); err == nil || principal != nil { + t.Fatal("backend guard accepted a frontend-audience token signed with the same secret") + } + if principal, err := frontend.Authenticate(withBearer(backendTok)); err == nil || principal != nil { + t.Fatal("frontend guard accepted a backend-audience token signed with the same secret") + } +} + +func withBearer(token string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + return req +} diff --git a/cabana/security_test.go b/cabana/security_test.go new file mode 100644 index 0000000..a89f0a8 --- /dev/null +++ b/cabana/security_test.go @@ -0,0 +1,105 @@ +package cabana + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/pact" +) + +type orderController struct { + perms []string +} + +func (orderController) ID() string { return "acme.demo.widgets" } +func (orderController) ModelName() string { return "Widget" } +func (orderController) ConfigDir() string { return "controllers/widgets" } +func (c orderController) RequiredPermissions() []string { + return c.perms +} + +func TestAuthorizationOrder(t *testing.T) { + svc := &service{reg: &Registry{byID: map[string]*CompiledController{ + "acme.demo.widgets": { + Controller: orderController{perms: []string{"acme.demo.access"}}, + List: &ListSchema{RecordsPerPage: 20, Columns: []ListColumn{}}, + }, + }}} + t.Run("permission before schema", func(t *testing.T) { + called := 0 + rec := httptest.NewRecorder() + req := controllerRequest(&bouncer.Principal{ID: 4}) + svc.protect(rec, req, func(*CompiledController) { called++ }) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if called != 0 { + t.Fatal("schema or database callback ran before permission denial") + } + assertErrorCode(t, rec.Body.Bytes(), "forbidden") + }) + t.Run("superuser reaches handler", func(t *testing.T) { + called := 0 + rec := httptest.NewRecorder() + req := controllerRequest(&bouncer.Principal{ID: 1, IsSuperuser: true}) + svc.protect(rec, req, func(*CompiledController) { called++ }) + if called != 1 { + t.Fatalf("superuser callback count=%d, want 1", called) + } + }) + t.Run("unknown controller is not queried", func(t *testing.T) { + called := 0 + rec := httptest.NewRecorder() + req := controllerRequest(&bouncer.Principal{ID: 1, IsSuperuser: true}) + req.SetPathValue("controller", "missing") + svc.protect(rec, req, func(*CompiledController) { called++ }) + if rec.Code != http.StatusNotFound || called != 0 { + t.Fatalf("status=%d called=%d", rec.Code, called) + } + }) +} + +func TestSecretRedaction(t *testing.T) { + const secret = "summercms-test-only-admin-hs256-secret" + const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature" + rec := httptest.NewRecorder() + writeUnauthenticated(rec, errors.New(secret+" "+token)) + body := rec.Body.String() + if strings.Contains(body, secret) || strings.Contains(body, token) || strings.Contains(body, "eyJ") { + t.Fatalf("unauthorized body leaked credential material: %s", body) + } + assertErrorCode(t, rec.Body.Bytes(), "unauthenticated") +} + +func controllerRequest(principal *bouncer.Principal) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/acme/demo/widgets", nil) + req.SetPathValue("vendor", "acme") + req.SetPathValue("plugin", "demo") + req.SetPathValue("controller", "widgets") + if principal != nil { + req = req.WithContext(bouncer.WithUser(req.Context(), principal)) + } + return req +} + +func assertErrorCode(t *testing.T, raw []byte, code string) { + t.Helper() + var body struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal(err) + } + if body.Error.Code != code { + t.Fatalf("error code=%q, want %s; body %s", body.Error.Code, code, raw) + } +} + +var _ pact.AdminPermissioned = orderController{}