diff --git a/bouncer/backend_guard_test.go b/bouncer/backend_guard_test.go new file mode 100644 index 0000000..c1f03c3 --- /dev/null +++ b/bouncer/backend_guard_test.go @@ -0,0 +1,171 @@ +package bouncer + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestPhase09GuardIsolation is the phase gate for token crossover. Each +// subtest fails closed when a frontend and backend credential can be used +// on the other guard, including same-secret audience checks, distinct +// secrets, refresh, blacklist, and password-reset cutoff. +func TestPhase09GuardIsolation(t *testing.T) { + const ( + userSecret = "frontend-secret" + backendSecret = "backend-secret" + sharedSecret = "same-secret-for-both-guards" + issuer = "https://app.test" + ) + frontendUsers := memUsers{byID: map[uint]*Principal{1: {ID: 1}}} + backendUsers := memUsers{byID: map[uint]*Principal{2: {ID: 2, Backend: true}}} + + userTok, _, err := MintAudience(sharedSecret, "1", issuer, time.Hour, AudienceUser) + if err != nil { + t.Fatal(err) + } + backendTok, _, err := MintAudience(sharedSecret, "2", issuer, time.Hour, AudienceBackend) + if err != nil { + t.Fatal(err) + } + frontend := NewJWTGuard(sharedSecret, frontendUsers, nil) + backend := NewBackendJWTGuard(sharedSecret, backendUsers, nil, nil) + + t.Run("own audience", func(t *testing.T) { + principal, err := frontend.Authenticate(withBearer(userTok)) + if err != nil || principal == nil || principal.ID != 1 { + t.Fatalf("frontend guard rejected its own token: %v %+v", err, principal) + } + principal, err = backend.Authenticate(withBearer(backendTok)) + if err != nil || principal == nil || principal.ID != 2 || !principal.Backend { + t.Fatalf("backend guard rejected its own token: %v %+v", err, principal) + } + }) + + t.Run("audience crossover same secret", func(t *testing.T) { + 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") + } + }) + + t.Run("distinct secrets and registries", func(t *testing.T) { + userOnly, _, err := MintAudience(userSecret, "1", issuer, time.Hour, AudienceUser) + if err != nil { + t.Fatal(err) + } + adminOnly, _, err := MintAudience(backendSecret, "2", issuer, time.Hour, AudienceBackend) + if err != nil { + t.Fatal(err) + } + front := NewJWTGuard(userSecret, frontendUsers, nil) + back := NewBackendJWTGuard(backendSecret, backendUsers, nil, nil) + if principal, err := back.Authenticate(withBearer(userOnly)); err == nil || principal != nil { + t.Fatal("backend guard accepted a frontend token signed with the frontend secret") + } + if principal, err := front.Authenticate(withBearer(adminOnly)); err == nil || principal != nil { + t.Fatal("frontend guard accepted a backend token signed with the backend secret") + } + if principal, err := back.Authenticate(withBearer(adminOnly)); err != nil || principal == nil || principal.ID != 2 { + t.Fatalf("backend registry principal = %+v err=%v", principal, err) + } + if principal, err := back.Authenticate(withBearer(mustMint(t, backendSecret, "1", AudienceBackend))); err == nil || principal != nil { + t.Fatal("backend guard resolved a subject from the frontend user registry") + } + reg := NewRegistry() + if err := reg.Register("golem15.user", "jwt", front); err != nil { + t.Fatal(err) + } + if err := reg.Register("summercms.cabana", "backend", back); err != nil { + t.Fatal(err) + } + mw, err := reg.Middleware("backend") + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("backend middleware ran the handler for a frontend token") + })).ServeHTTP(rec, withBearer(userOnly)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("crossover status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + + t.Run("malformed and wrong audience", func(t *testing.T) { + if _, err := backend.Authenticate(withBearer("not-a-jwt")); err == nil { + t.Fatal("malformed token was accepted") + } + other, _, err := MintAudience(sharedSecret, "2", issuer, time.Hour, "frontend") + if err != nil { + t.Fatal(err) + } + if principal, err := backend.Authenticate(withBearer(other)); err == nil || principal != nil { + t.Fatal("backend guard accepted an unexpected audience") + } + }) + + t.Run("blacklist and reset cutoff", func(t *testing.T) { + bl := NewMemoryBlacklist() + revoked, jti, err := MintAudience(backendSecret, "2", issuer, time.Hour, AudienceBackend) + if err != nil { + t.Fatal(err) + } + if err := bl.Add(t.Context(), jti, time.Now().Add(time.Hour), time.Now().Add(-time.Second)); err != nil { + t.Fatal(err) + } + guard := NewBackendJWTGuard(backendSecret, backendUsers, bl, nil) + if principal, err := guard.Authenticate(withBearer(revoked)); err == nil || principal != nil { + t.Fatal("blacklisted backend token was accepted") + } + if _, err := RefreshAudience(backendSecret, revoked, AudienceBackend, 2*time.Hour, bl, time.Minute, issuer); err == nil { + t.Fatal("blacklisted backend token was refreshed") + } + cutoff := memUsers{byID: map[uint]*Principal{2: { + ID: 2, Backend: true, TokensValidAfter: time.Now().Add(time.Minute), + }}} + stale, _, err := MintAudience(backendSecret, "2", issuer, time.Hour, AudienceBackend) + if err != nil { + t.Fatal(err) + } + if principal, err := NewBackendJWTGuard(backendSecret, cutoff, nil, nil).Authenticate(withBearer(stale)); err == nil || principal != nil { + t.Fatal("token issued before the password-reset cutoff was accepted") + } + }) + + t.Run("refresh does not cross audiences", func(t *testing.T) { + access, _, err := MintAudience(userSecret, "1", issuer, time.Hour, AudienceUser) + if err != nil { + t.Fatal(err) + } + admin, _, err := MintAudience(backendSecret, "2", issuer, time.Hour, AudienceBackend) + if err != nil { + t.Fatal(err) + } + if _, err := RefreshAudience(userSecret, access, AudienceBackend, 2*time.Hour, nil, time.Minute, issuer); err == nil { + t.Fatal("frontend token refreshed into the backend audience") + } + if _, err := RefreshAudience(backendSecret, admin, AudienceUser, 2*time.Hour, nil, time.Minute, issuer); err == nil { + t.Fatal("backend token refreshed into the frontend audience") + } + if _, err := RefreshAudience(userSecret, admin, AudienceBackend, 2*time.Hour, nil, time.Minute, issuer); err == nil { + t.Fatal("backend token refreshed with the frontend secret") + } + next, err := RefreshAudience(backendSecret, admin, AudienceBackend, 2*time.Hour, nil, time.Minute, issuer) + if err != nil || next == "" { + t.Fatalf("backend refresh failed: %v", err) + } + }) +} + +func mustMint(t *testing.T, secret, sub, audience string) string { + t.Helper() + token, _, err := MintAudience(secret, sub, "https://app.test", time.Hour, audience) + if err != nil { + t.Fatal(err) + } + return token +} diff --git a/cabana/security_coverage_test.go b/cabana/security_coverage_test.go new file mode 100644 index 0000000..ae01dcc --- /dev/null +++ b/cabana/security_coverage_test.go @@ -0,0 +1,271 @@ +package cabana + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/pact" +) + +// phase09Routes is the D-09 admin surface mounted by service.mount. +// A handler added outside this set, or a protected handler missing the +// backend guard, fails TestPhase09PermissionMatrix. +var phase09Routes = []struct { + key string + public bool +}{ + {"POST /_admin/api/v1/auth/login", true}, + {"POST /_admin/api/v1/auth/refresh", true}, + {"POST /_admin/api/v1/auth/logout", false}, + {"GET /_admin/api/v1/auth/me", false}, + {"GET /_admin/api/v1/navigation", false}, + {"GET /_admin/api/v1/settings", false}, + {"GET /_admin/api/v1/settings/{code}/schema", false}, + {"GET /_admin/api/v1/settings/{code}", false}, + {"PUT /_admin/api/v1/settings/{code}", false}, + {"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list", false}, + {"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form", false}, + {"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name}", false}, + {"GET /_admin/api/v1/{vendor}/{plugin}/{controller}", false}, + {"POST /_admin/api/v1/{vendor}/{plugin}/{controller}", false}, + {"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete", false}, + {"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false}, + {"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false}, + {"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false}, + {"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}", false}, + {"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false}, + {"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false}, + {"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false}, +} + +func TestPhase09PermissionMatrix(t *testing.T) { + router := &captureRouter{} + (&service{}).mount(router) + got := map[string][]string{} + for _, key := range router.routes { + if _, exists := got[key]; exists { + t.Fatalf("route %s registered more than once", key) + } + got[key] = router.middleware[key] + } + if len(got) != len(phase09Routes) { + t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes) + } + for _, route := range phase09Routes { + mw, ok := got[route.key] + if !ok { + t.Fatalf("missing mounted route %s", route.key) + } + hasBackend := false + for _, name := range mw { + if name == "backend" { + hasBackend = true + } + } + if route.public && hasBackend { + t.Fatalf("%s is a public auth route but carries the backend guard", route.key) + } + if !route.public && !hasBackend { + t.Fatalf("%s is missing the backend guard: %v", route.key, mw) + } + } + + svc := phase09DeniedService() + denied := &bouncer.Principal{ID: 4, Backend: true} + frontend := &bouncer.Principal{ID: 4, Backend: false, PermissionGrants: map[string]bool{"acme.demo.access": true}} + for _, call := range phase09ProtectedCalls() { + t.Run("denied "+call.name, func(t *testing.T) { + rec := httptest.NewRecorder() + call.fn(svc, rec, phase09Request(denied)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + assertErrorCode(t, rec.Body.Bytes(), "forbidden") + }) + t.Run("frontend "+call.name, func(t *testing.T) { + rec := httptest.NewRecorder() + call.fn(svc, rec, phase09Request(frontend)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + assertErrorCode(t, rec.Body.Bytes(), "unauthenticated") + }) + } + + for _, call := range []phase09Call{ + {"navigation", (*service).navigation}, + {"settings-list", (*service).settingsList}, + } { + t.Run("filtered "+call.name, func(t *testing.T) { + rec := httptest.NewRecorder() + call.fn(svc, rec, phase09Request(denied)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"data":[]`) { + t.Fatalf("permissionless metadata was not an empty list: %s", rec.Body.String()) + } + }) + } +} + +func TestPhase09SecurityCoverage(t *testing.T) { + t.Run("mass assignment", func(t *testing.T) { + cc := &CompiledController{Writable: []WritableField{ + {Name: "name", FillKey: "name"}, + {Name: "password", FillKey: "password"}, + {Name: "role_id", FillKey: "role_id"}, + }} + got := ProjectWritableFields(cc, map[string]any{ + "name": "Ada", + "Name": "Case", + "password": "hunter2", + "role_id": 1, + "extra": "nope", + "nested": map[string]any{"id": 1}, + }) + if len(got) != 1 || got["name"] != "Ada" { + t.Fatalf("projected = %#v, want only name", got) + } + }) + + t.Run("identifier injection", func(t *testing.T) { + for _, raw := range []string{"", "1;drop", "1 OR 1", "../1", "-1", "1.5", "0x10"} { + req := phase09Request(nil) + req.SetPathValue("id", raw) + if _, err := pathID(req); err == nil { + t.Fatalf("path id %q was accepted", raw) + } + } + req := phase09Request(nil) + req.SetPathValue("id", "15") + id, err := pathID(req) + if err != nil || id != 15 { + t.Fatalf("id=%d err=%v", id, err) + } + }) + + t.Run("auth log redaction", func(t *testing.T) { + const secret = "summercms-test-only-admin-hs256-secret" + const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature" + var buf bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(previous) }) + req := phase09Request(nil) + req.Header.Set("Authorization", "Bearer "+token) + req.URL.RawQuery = "password=" + secret + (&service{}).logAuth(req, "denied", 7) + logged := buf.String() + for _, leak := range []string{secret, token, "eyJ", "hunter2", "password="} { + if strings.Contains(logged, leak) { + t.Fatalf("auth log contains %q: %s", leak, logged) + } + } + if !strings.Contains(logged, `"outcome":"denied"`) || !strings.Contains(logged, `"admin_id":7`) { + t.Fatalf("auth log dropped the outcome: %s", logged) + } + }) + + t.Run("pivot body", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/relations/editors/link", strings.NewReader(`{"ids":[1],"role":"developer"}`)) + req.Header.Set("Content-Type", "application/json") + if _, err := decodeRelationMutation(req); err == nil { + t.Fatal("forged pivot field was accepted") + } + }) + + t.Run("hook failure rolls back", func(t *testing.T) { + svc, _, cc, db, hooks := hookFixture(t) + hooks.fail = "form_before_create" + ctx := principalCtx(hooks, superUser()) + if _, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "password": "hunter2"}}); err == nil { + t.Fatal("failing before-create hook was ignored") + } + if n := countCrud(t, db); n != 0 { + t.Fatalf("rows=%d after rejected create", n) + } + }) + + t.Run("permission before list query", func(t *testing.T) { + listSvc, _ := newListService(t) + locked := *listSvc + current := locked.reg.byID["acme.demo.widgets"] + locked.reg = &Registry{byID: map[string]*CompiledController{ + "acme.demo.widgets": { + Controller: queryController{perms: []string{"acme.demo.access"}}, + List: current.List, + }, + }} + rec := httptest.NewRecorder() + req := phase09Request(&bouncer.Principal{ID: 4, Backend: true}) + req.SetPathValue("vendor", "acme") + req.SetPathValue("plugin", "demo") + req.SetPathValue("controller", "widgets") + req.URL.RawQuery = "sort=name%3Bdrop" + locked.list(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "drop") { + t.Fatalf("denial body echoed the identifier: %s", rec.Body.String()) + } + }) +} + +type phase09Call struct { + name string + fn func(*service, http.ResponseWriter, *http.Request) +} + +func phase09ProtectedCalls() []phase09Call { + return []phase09Call{ + {"list", (*service).list}, + {"create", (*service).create}, + {"bulk-delete", (*service).bulkDelete}, + {"show", (*service).show}, + {"update", (*service).update}, + {"delete", (*service).deleteRecord}, + {"list-schema", (*service).listSchema}, + {"form-schema", (*service).formSchema}, + {"relation-schema", (*service).relationSchema}, + {"relation-linked", (*service).relationLinked}, + {"relation-candidates", (*service).relationCandidates}, + {"relation-link", (*service).relationLink}, + {"relation-unlink", (*service).relationUnlink}, + {"settings-schema", (*service).settingsSchema}, + {"settings-get", (*service).settingsGet}, + {"settings-put", (*service).settingsPut}, + } +} + +func phase09DeniedService() *service { + controller := orderController{perms: []string{"acme.demo.access"}} + return &service{reg: &Registry{ + byID: map[string]*CompiledController{ + "acme.demo.widgets": {Controller: controller, Relations: map[string]*CompiledRelation{}}, + }, + settings: map[string]*CompiledSetting{ + "demo": {Item: pact.SettingsItem{Code: "demo", Permissions: []string{"acme.demo.manage_settings"}}}, + }, + }} +} + +func phase09Request(principal *bouncer.Principal) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/_admin/api/v1/acme/demo/widgets/1/relations/editors/link", strings.NewReader(`{}`)) + req.SetPathValue("vendor", "acme") + req.SetPathValue("plugin", "demo") + req.SetPathValue("controller", "widgets") + req.SetPathValue("id", "1") + req.SetPathValue("name", "editors") + req.SetPathValue("code", "demo") + if principal != nil { + req = req.WithContext(bouncer.WithUser(req.Context(), principal)) + } + return req +} diff --git a/lagoon/backend_admin_migrations_test.go b/lagoon/backend_admin_migrations_test.go index 44bbce9..1db9c35 100644 --- a/lagoon/backend_admin_migrations_test.go +++ b/lagoon/backend_admin_migrations_test.go @@ -12,6 +12,77 @@ import ( "gorm.io/gorm" ) +func TestPhase09MigrationsFreshRollback(t *testing.T) { + assertNoAutoMigrate(t) + db, _ := dedicatedDB(t, "phase09_fresh") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + plugin := migPlugin{ + id: "demo.keep", + migrations: []*gormigrate.Migration{{ + ID: "202609240010_keep", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE phase09_keep (id BIGSERIAL PRIMARY KEY)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS phase09_keep`).Error + }, + }}, + } + if err := Migrate(gdb, []party.Plugin{plugin}); err != nil { + t.Fatal(err) + } + if !gdb.Migrator().HasTable("system_files") || !gdb.Migrator().HasTable("backend_users") || !gdb.Migrator().HasTable("phase09_keep") { + t.Fatal("fresh migrate did not keep framework, admin, and plugin tables together") + } + before := systemRoles(t, gdb) + if len(before) != 2 { + t.Fatalf("seeded roles = %v", before) + } + if err := Migrate(gdb, []party.Plugin{plugin}); err != nil { + t.Fatalf("repeated migrate: %v", err) + } + if after := systemRoles(t, gdb); len(after) != 2 || after["developer"].id != before["developer"].id || after["publisher"].id != before["publisher"].id { + t.Fatalf("roles changed on repeat: before=%v after=%v", before, after) + } + admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations) + if err != nil { + t.Fatal(err) + } + if err := admin.RollbackLast(); err != nil { + t.Fatal(err) + } + for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist"} { + if gdb.Migrator().HasTable(name) { + t.Fatalf("%s survived admin rollback", name) + } + } + if !gdb.Migrator().HasTable("system_files") || !gdb.Migrator().HasTable("phase09_keep") { + t.Fatal("admin rollback removed a framework or plugin table") + } + var attachIDs, pluginIDs []string + if err := gdb.Table("summer_migrations_summercms_attach").Pluck("id", &attachIDs).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Table("summer_migrations_demo_keep").Pluck("id", &pluginIDs).Error; err != nil { + t.Fatal(err) + } + if strings.Join(attachIDs, ",") == "" || strings.Join(pluginIDs, ",") != "202609240010_keep" { + t.Fatalf("histories attach=%v plugin=%v", attachIDs, pluginIDs) + } + if err := Migrate(gdb, []party.Plugin{plugin}); err != nil { + t.Fatalf("migrate after rollback: %v", err) + } + if !gdb.Migrator().HasTable("backend_users") { + t.Fatal("admin tables were not recreated") + } + if again := systemRoles(t, gdb); len(again) != 2 { + t.Fatalf("roles after recreate = %v", again) + } +} + func TestBackendAdminMigration(t *testing.T) { assertNoAutoMigrate(t) db, _ := dedicatedDB(t, "lagoon_admin_mig")