package cabana_test import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "reflect" "strings" "sync" "testing" "git.golem15.com/golem15/summercms/modules/cabana" ) // rosterIDs is the body of a bulk request. func rosterIDs(ids ...uint) string { raw, _ := json.Marshal(map[string]any{"ids": ids}) return string(raw) } // rosterPath is the admin path of one person, with an optional suffix. func rosterPath(id uint, suffix string) string { return fmt.Sprintf("%s/%d%s", rosterPeople, id, suffix) } // rosterPair is the password pair every roster create needs. const rosterPair = `"password":"long-enough-1","password_confirmation":"long-enough-1"` // logCapture keeps the records written to the default logger while it is // installed. type logCapture struct { mu sync.Mutex records []string } func (c *logCapture) Enabled(context.Context, slog.Level) bool { return true } func (c *logCapture) WithAttrs([]slog.Attr) slog.Handler { return c } func (c *logCapture) WithGroup(string) slog.Handler { return c } func (c *logCapture) Handle(_ context.Context, record slog.Record) error { var line strings.Builder line.WriteString(record.Message) record.Attrs(func(attr slog.Attr) bool { fmt.Fprintf(&line, " %s=%v", attr.Key, attr.Value.Any()) return true }) c.mu.Lock() c.records = append(c.records, line.String()) c.mu.Unlock() return nil } // matching returns the captured lines that start with prefix. func (c *logCapture) matching(prefix string) []string { c.mu.Lock() defer c.mu.Unlock() var out []string for _, line := range c.records { if strings.HasPrefix(line, prefix) { out = append(out, line) } } return out } // captureLog installs a capturing default logger for the rest of the test. func captureLog(t *testing.T) *logCapture { t.Helper() capture := &logCapture{} previous := slog.Default() slog.SetDefault(slog.New(capture)) t.Cleanup(func() { slog.SetDefault(previous) }) return capture } // TestPhase121Threats has one subtest per mitigated framework threat of // Phase 12.1, named by its id. Each asserts the protection through the admin // API on the acme.roster fixture, so removing the protection fails the // subtest on an assertion (scripts/check-phase12.1.sh --removal). // // T-12.1-16 (a preview URL in plugin YAML that points at a foreign route) is // mitigated in the SPA and pinned by the vitest case "backstop: mapWinterUrl // maps preview/:id to the preview route" in admin/tests/app/winterUrl.test.ts. // T-12.1-17 (the tag) is a release step and has no code path. func TestPhase121Threats(t *testing.T) { t.Run("T-12.1-01", func(t *testing.T) { env, gdb := newRosterEnv(t) own := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Own"}) foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"}) // Only out-of-scope ids: nothing reaches Run and nothing changes. rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/activate", rosterIDs(foreign), "bearer") if result := rosterBulkResult(t, rec); result.Affected != 0 { t.Fatalf("out-of-scope selection affected %d rows", result.Affected) } // A mixed selection is refused as a whole. rec = env.expect(t, http.StatusConflict, http.MethodPost, rosterPeople+"/bulk/activate", rosterIDs(own, foreign), "bearer") actErrorCode(t, rec.Body.Bytes(), "conflict") if calls := env.spy.takeBulk(); len(calls) != 0 { t.Fatalf("Run was called with %d selections for ids outside the list scope", len(calls)) } if rosterLoad(t, gdb, own).Active || rosterLoad(t, gdb, foreign).Active { t.Fatal("a refused selection changed a row") } }) t.Run("T-12.1-02", func(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"}) // Undeclared names, and names registered in the other namespace only. for _, name := range []string{"missing", "reinstate", "delete", "create"} { env.expect(t, http.StatusNotFound, http.MethodPost, rosterPeople+"/bulk/"+name, rosterIDs(idle), "bearer") } for _, name := range []string{"missing", "archive", "delete", "create"} { env.expect(t, http.StatusNotFound, http.MethodPost, rosterPath(idle, "/actions/"+name), `{}`, "bearer") } // Declared, but the administrator lacks the action's own permission. rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/activate", rosterIDs(idle), "limited") actErrorCode(t, rec.Body.Bytes(), "forbidden") rec = env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "limited") actErrorCode(t, rec.Body.Bytes(), "forbidden") if rosterLoad(t, gdb, idle).Active { t.Fatal("an action ran without its permission") } if calls, one := env.spy.takeBulk(), env.spy.takeRecord(); len(calls) != 0 || len(one) != 0 { t.Fatalf("a refused action reached the plugin: bulk=%d record=%d", len(calls), len(one)) } // The same action is absent from what that administrator is offered. if got := rosterBulkNames(t, env, "limited"); !reflect.DeepEqual(got, []string{"delete", "archive"}) { t.Fatalf("limited admin is offered bulk actions %v", got) } if got := rosterOffered(t, env, idle, "limited"); len(got) != 0 { t.Fatalf("limited admin is offered record actions %v", got) } if got := rosterOffered(t, env, idle, "bearer"); !reflect.DeepEqual(got, []string{"activate"}) { t.Fatalf("full admin is offered record actions %v", got) } }) t.Run("T-12.1-03", func(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"}) for _, rel := range []string{rosterPeople + "/bulk/activate", rosterPath(idle, "/actions/activate")} { body := `{}` if strings.Contains(rel, "/bulk/") { body = rosterIDs(idle) } rec := env.expect(t, http.StatusForbidden, http.MethodPost, rel, body, "cookie-only") actErrorCode(t, rec.Body.Bytes(), "forbidden") if rosterLoad(t, gdb, idle).Active { t.Fatalf("%s ran for a cookie request without X-Requested-With", rel) } } // The same cookie with the header is accepted. env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "cookie") if !rosterLoad(t, gdb, idle).Active { t.Fatal("the cookie request with the header did not run") } }) t.Run("T-12.1-04", func(t *testing.T) { env, gdb := newRosterEnv(t) active := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Active", Active: true}) foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"}) rec := env.expect(t, http.StatusNotFound, http.MethodPost, rosterPath(foreign, "/actions/activate"), `{}`, "bearer") actErrorCode(t, rec.Body.Bytes(), "not_found") rec = env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(active, "/actions/activate"), `{}`, "bearer") actErrorCode(t, rec.Body.Bytes(), "conflict") if calls := env.spy.takeRecord(); len(calls) != 0 { t.Fatalf("Run was called %d times for a record outside the scope or state", len(calls)) } if rosterLoad(t, gdb, foreign).Active { t.Fatal("an out-of-scope record was changed") } }) t.Run("T-12.1-05", func(t *testing.T) { env, gdb := newRosterEnv(t) first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"}) second := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"}) crash := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterCrash}) // The action soft-deletes the first two rows and fails on the last. env.expect(t, http.StatusInternalServerError, http.MethodPost, rosterPeople+"/bulk/archive", rosterIDs(first, second, crash), "bearer") for _, id := range []uint{first, second, crash} { if rosterLoad(t, gdb, id).DeletedAt.Valid { t.Fatalf("row %d kept the write of a bulk action that failed on the last row", id) } } if calls := env.spy.takeBulk(); len(calls) != 1 || len(calls[0].Records) != 3 { t.Fatalf("the action did not run over the three rows: %+v", calls) } }) t.Run("T-12.1-06", func(t *testing.T) { env, gdb := newRosterEnv(t) id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"}) rec := env.expect(t, http.StatusInternalServerError, http.MethodPut, rosterPath(id, ""), `{"name":"Boom"}`, "bearer") if got := rosterError(t, rec); got.Code != "error" || len(got.Details) != 0 { t.Fatalf("500 error = %+v", got) } for _, leak := range []string{"hunter2", "roster database"} { if strings.Contains(rec.Body.String(), leak) { t.Fatalf("the 500 body carries the hook's error text %q: %s", leak, rec.Body.String()) } } crash := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterCrash}) rec = env.expect(t, http.StatusInternalServerError, http.MethodPost, rosterPeople+"/bulk/archive", rosterIDs(crash), "bearer") if strings.Contains(rec.Body.String(), "hunter2") { t.Fatalf("the 500 body of a bulk action carries its error text: %s", rec.Body.String()) } // A ForbiddenError is the one error whose text is the plugin's to show. rec = env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"name":"Reserved"}`, "bearer") if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "You may not rename this person." { t.Fatalf("403 error = %+v", got) } }) t.Run("T-12.1-07", func(t *testing.T) { env, gdb := newRosterEnv(t) odd := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Active: true}) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"}) body, raw := rosterList(t, env, "") if strings.Contains(raw, "starred") { t.Fatalf("a row state outside the fixed set was sent: %s", raw) } if _, ok := body.Meta.RowStates[fmt.Sprint(odd)]; ok { t.Fatalf("a row with only an unknown state is listed: %v", body.Meta.RowStates) } if got := body.Meta.RowStates[fmt.Sprint(idle)]; !reflect.DeepEqual(got, []string{"disabled"}) { t.Fatalf("known state = %v", got) } }) t.Run("T-12.1-08", func(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Secretname", Email: "secret@example.test"}) logs := captureLog(t) env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/activate", rosterIDs(idle), "bearer") banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Secretname", Email: "secret@example.test", Active: true, Banned: true}) env.expect(t, http.StatusOK, http.MethodPost, rosterPath(banned, "/actions/reinstate"), `{}`, "bearer") bulk := logs.matching("cabana: admin bulk action") if len(bulk) != 1 || !strings.Contains(bulk[0], "controller=acme.roster.people") || !strings.Contains(bulk[0], "action=activate") || !strings.Contains(bulk[0], "affected=1") { t.Fatalf("bulk action log lines = %q", bulk) } record := logs.matching("cabana: admin record action") if len(record) != 1 || !strings.Contains(record[0], "action=reinstate") || !strings.Contains(record[0], fmt.Sprintf("record_id=%d", banned)) { t.Fatalf("record action log lines = %q", record) } for _, line := range append(bulk, record...) { if !strings.Contains(line, "admin_id=") || strings.Contains(line, "admin_id=0") { t.Fatalf("log line lacks the admin id: %q", line) } if strings.Contains(line, "Secretname") || strings.Contains(line, "secret@example.test") { t.Fatalf("log line carries record contents: %q", line) } } // A refused request writes no action line. env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/activate", rosterIDs(idle), "limited") if got := logs.matching("cabana: admin bulk action"); len(got) != 1 { t.Fatalf("a refused action was logged as run: %q", got) } }) t.Run("T-12.1-09", func(t *testing.T) { env, gdb := newRosterEnv(t) rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Vic","notify":true,`+rosterPair+`}`, "bearer") for _, name := range []string{"notify", "password"} { if strings.Contains(rec.Body.String(), name) { t.Fatalf("the create response carries the virtual field %s: %s", name, rec.Body.String()) } } created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64) stored := rosterLoad(t, gdb, uint(created)) // The password column holds only what the hook derived; the submitted // text never reached it through Fill. if stored.Password != rosterHash("long-enough-1") { t.Fatalf("a virtual value was bound to the model: password column = %q", stored.Password) } rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPath(uint(created), ""), "", "bearer") if strings.Contains(rec.Body.String(), "notify") || strings.Contains(rec.Body.String(), "password") { t.Fatalf("the show response carries a virtual field: %s", rec.Body.String()) } }) t.Run("T-12.1-10", func(t *testing.T) { env, gdb := newRosterEnv(t) const plain = "s3cret-plain-text" responses := map[string]string{} rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, fmt.Sprintf(`{"name":"Pat","password":%q,"password_confirmation":%q}`, plain, plain), "bearer") responses["create"] = rec.Body.String() created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64) id := uint(created) if rosterLoad(t, gdb, id).Password != rosterHash(plain) { t.Fatal("the password did not reach the hook") } responses["show"] = env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "bearer").Body.String() responses["list"] = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer").Body.String() responses["update"] = env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"name":"Pat B","password":%q,"password_confirmation":%q}`, plain, plain), "bearer").Body.String() responses["mismatch"] = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"password":%q,"password_confirmation":"other-enough-1"}`, plain), "bearer").Body.String() for route, body := range responses { if strings.Contains(body, plain) || strings.Contains(body, "sha256:") { t.Fatalf("the %s response carries the password or its hash: %s", route, body) } if route != "mismatch" && strings.Contains(body, `"password`) { t.Fatalf("the %s response carries a password key: %s", route, body) } } }) t.Run("T-12.1-11", func(t *testing.T) { // The same contract without WritableForeignKey: the field is read-only // and a submitted id is not written. env, gdb := newRosterEnvWith(t, func(p *rosterPlugin) { p.relations = func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract { in[0].WritableForeignKey = false return in } }) team := rosterTeam{Tenant: "acme", Name: "Home"} rosterSeed(t, gdb, &team) person := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ro", Active: true}) _, raw := rosterFormSchema(t, env, "bearer") if !strings.Contains(raw, `"name":"team","type":"relation","label":"Team","nameFrom":"name","emptyOption":"No team","readOnly":true}`) { t.Fatalf("the team field is writable without the opt-in: %s", raw) } env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), fmt.Sprintf(`{"team":%d,"organisation_id":%d}`, team.ID, team.ID), "bearer") if stored := rosterLoad(t, gdb, person); stored.OrganisationID != nil { t.Fatalf("a protected foreign key was written without the opt-in: %d", *stored.OrganisationID) } }) t.Run("T-12.1-12", func(t *testing.T) { env, gdb := newRosterEnv(t) staff, news := rosterTag{Name: "staff"}, rosterTag{Name: "news"} rosterSeed(t, gdb, &staff) rosterSeed(t, gdb, &news) plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true}) member := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Member", Active: true}) rosterSeed(t, gdb, &rosterPersonTag{PersonID: plain, TagID: news.ID}) rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: staff.ID}) refused := func(method, rel, body string) { t.Helper() rec := env.expect(t, http.StatusForbidden, method, rel, body, "limited") rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "tags", "You need an additional permission to change the staff tag.") } // Added on update, removed on update, added on create. refused(http.MethodPut, rosterPath(plain, ""), fmt.Sprintf(`{"name":"Sneaky","tags":[%d,%d]}`, news.ID, staff.ID)) refused(http.MethodPut, rosterPath(member, ""), `{"tags":[]}`) refused(http.MethodPost, rosterPeople, fmt.Sprintf(`{"name":"Smuggled",%s,"tags":[%d]}`, rosterPair, staff.ID)) if got := rosterPivot(t, gdb, plain); !reflect.DeepEqual(got, []uint{news.ID}) { t.Fatalf("a refused save changed the pivot of the plain person: %v", got) } if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID}) { t.Fatalf("a refused save changed the pivot of the member: %v", got) } if rosterLoad(t, gdb, plain).Name != "Plain" { t.Fatal("a refused save wrote another field of the same body") } var smuggled int64 if err := gdb.Unscoped().Model(&rosterPerson{}).Where("name = ?", "Smuggled").Count(&smuggled).Error; err != nil || smuggled != 0 { t.Fatalf("a refused create left %d rows (%v)", smuggled, err) } }) t.Run("T-12.1-13", func(t *testing.T) { env, gdb := newRosterEnv(t) stored := `{"reports.export":1}` id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &stored}) rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"permissions":{"admin.root":1,"reports.export":1}}`, "bearer") rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", "The permissions field contains an unknown permission.") rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":2,"reports.export":1}}`, "bearer") rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", "The permissions field contains an invalid value.") // The limited admin may not change the locked code, in either direction. for _, body := range []string{`{"permissions":{"posts.edit":1}}`, `{"permissions":{"reports.export":-1}}`} { rec = env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), body, "limited") rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", "You cannot change this permission.") } if got := rosterLoad(t, gdb, id).Permissions; got == nil || *got != stored { t.Fatalf("a refused save changed the stored permissions: %v", got) } }) t.Run("T-12.1-14", func(t *testing.T) { env, gdb := newRosterEnv(t) ip := "203.0.113.7" id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Active: true, JoinedIP: &ip}) env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Ada L","joined_ip":"198.51.100.1"}`, "bearer") if got := rosterLoad(t, gdb, id); got.JoinedIP == nil || *got.JoinedIP != ip || got.Name != "Ada L" { t.Fatalf("an update wrote the preview-only field: %+v", got.JoinedIP) } rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2",`+rosterPair+`}`, "bearer") created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64) if got := rosterLoad(t, gdb, uint(created)); got.JoinedIP != nil { t.Fatalf("a create wrote the preview-only field: %q", *got.JoinedIP) } }) t.Run("T-12.1-15", func(t *testing.T) { // A status partial that carries active markup next to its callout. const hostile = `
` + `` + `{{ trans .Data.Title }}` + `a` + `kept text
` env, gdb := newRosterEnvWith(t, func(p *rosterPlugin) { p.fsys = rosterTree(t, map[string]string{"controllers/people/_status.htm": hostile}) }) banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea", Active: true, Banned: true}) rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, banned), "", "bearer") var view cabana.Envelope[cabana.PartialView] if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { t.Fatalf("partial body %s: %v", rec.Body.String(), err) } if len(view.Data.Nodes) != 1 || view.Data.Nodes[0].Tag != "div" { t.Fatalf("nodes = %+v", view.Data.Nodes) } root := view.Data.Nodes[0] if !reflect.DeepEqual(root.Attrs, map[string]string{"class": "summer-callout", "data-tone": "danger"}) { t.Fatalf("root attributes = %v, want only the allowlisted class and data-tone", root.Attrs) } var tags []string for _, child := range root.Children { tags = append(tags, child.Tag) switch child.Tag { case "a": if !reflect.DeepEqual(child.Attrs, map[string]string{"title": "t"}) { t.Fatalf("link attributes = %v, want no href and no handler", child.Attrs) } case "img": if !reflect.DeepEqual(child.Attrs, map[string]string{"alt": "a"}) { t.Fatalf("image attributes = %v, want no foreign src and no handler", child.Attrs) } } } // script and iframe are dropped with their content; the unknown element // is unwrapped to its text. if !reflect.DeepEqual(tags, []string{"a", "img", ""}) { t.Fatalf("child tags = %q", tags) } for _, banned := range []string{"script", "iframe", "alert(1)", "onclick", "onerror", "onmouseover", "javascript:", "evil.example.test", "style", "custom-tag"} { if strings.Contains(rec.Body.String(), banned) { t.Fatalf("the partial response carries %q: %s", banned, rec.Body.String()) } } }) }