package cabana import ( "bytes" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" ) // adminRoute is one logical admin route. key is method plus the path relative // to {backend.uri}/api/v1 (D-03); spa entries are the public SPA shell routes // relative to {backend.uri} and are not part of the OpenAPI inventory. // mounted, when set, is the relative ServeMux key that serves the route: // logical routes ServeMux cannot hold side by side share one dispatching // pattern (service.nestedGet). type adminRoute struct { key string public bool spa bool mounted string } // nestedGetRoute is the shared six-segment GET pattern. const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name}" // phase09Routes is the admin surface mounted by service.mount. A handler added // outside this set, or a protected handler missing the backend guard, fails // TestPhase09PermissionMatrix. var phase09Routes = []adminRoute{ {key: "POST /auth/login", public: true}, {key: "POST /auth/refresh", public: true}, {key: "GET /lang", public: true}, // Logout verifies the token itself (exp unchecked, so an expired but // refreshable token can be revoked) and is therefore not behind the guard. {key: "POST /auth/logout", public: true}, {key: "GET /auth/me"}, {key: "GET /navigation"}, {key: "GET /settings"}, {key: "GET /settings/{code}/schema"}, {key: "GET /settings/{code}"}, {key: "PUT /settings/{code}"}, {key: "GET /{vendor}/{plugin}/{controller}/schema/list"}, {key: "GET /{vendor}/{plugin}/{controller}/schema/form"}, {key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"}, {key: "GET /{vendor}/{plugin}/{controller}/partials/{name}"}, {key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute}, {key: "GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", mounted: nestedGetRoute}, {key: "GET /{vendor}/{plugin}/{controller}"}, {key: "POST /{vendor}/{plugin}/{controller}"}, {key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"}, {key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"}, {key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"}, {key: "GET /{vendor}/{plugin}/{controller}/{id}"}, {key: "PUT /{vendor}/{plugin}/{controller}/{id}"}, {key: "DELETE /{vendor}/{plugin}/{controller}/{id}"}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", mounted: nestedGetRoute}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"}, {key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true}, {key: "GET ", public: true, spa: true}, {key: "GET /{path...}", public: true, spa: true}, } // mountedKey is the full mounted route key for an inventory entry. func mountedKey(route adminRoute) string { key := route.key if route.mounted != "" { key = route.mounted } method, rel, _ := strings.Cut(key, " ") if route.spa { return method + " " + DefaultAdminPrefix + rel } return method + " " + adminAPI(rel) } 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] } want := map[string]bool{} for _, route := range phase09Routes { want[mountedKey(route)] = true } if len(got) != len(want) { t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(want), router.routes) } for _, route := range phase09Routes { key := mountedKey(route) mw, ok := got[key] if !ok { t.Fatalf("missing mounted route %s in %v", key, router.routes) } 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}, {"widget-action", (*service).widgetAction}, {"toolbar-action", (*service).toolbarAction}, {"partial", (*service).partial}, {"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}, {"field-options", (*service).fieldOptions}, {"filter-options", (*service).filterOptions}, {"nested-get", (*service).nestedGet}, {"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{}, // Declare every write so the denial under test is the permission. Form: &FormSchema{}, List: &ListSchema{ToolbarButtons: []string{"create", "delete"}}, }, }, 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, adminAPI("/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("segment", "relations") req.SetPathValue("code", "demo") if principal != nil { req = req.WithContext(bouncer.WithUser(req.Context(), principal)) } return req }