feat(10-01): harden the admin cookie session and prefix boot guards
- refresh and logout read the Bearer header first, then the summer_admin cookie; a cookie refresh rotates the cookie without a token in the body and logout always expires the cookie - backend.cookie_secure (default true) may drop Secure outside production only - activation rejects controller vendor segments api, assets, login, settings - BuildRouter rejects non-cabana routes at or under the admin prefix - SPA single-flights refresh on 401, replays once, and refreshes proactively at 80 percent of expires_in; dist rebuilt - scripts/check-admin-dist.sh rebuilds the SPA and fails on dist drift - tests: TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix, TestPhase10AdminPrefixCollision, boardwalk serving and header tests
This commit is contained in:
132
surf/admin_prefix_test.go
Normal file
132
surf/admin_prefix_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
)
|
||||
|
||||
// TestPhase10AdminPrefixCollision fails boot when a plugin other than cabana
|
||||
// registers a route at or under the admin prefix (backend.uri), which would
|
||||
// otherwise shadow or be shadowed by the admin SPA and API.
|
||||
func TestPhase10AdminPrefixCollision(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
raw bool
|
||||
}{
|
||||
{"exact prefix", http.MethodGet, "/acme-admin", false},
|
||||
{"under prefix", http.MethodPost, "/acme-admin/hook", false},
|
||||
{"raw under api", http.MethodGet, "/acme-admin/api/v1/extra", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app := adminPrefixApp(t)
|
||||
plugins := []party.Plugin{
|
||||
adminPrefixPlugin{},
|
||||
prefixRoutePlugin{id: "acme.intruder", method: tc.method, path: tc.path, raw: tc.raw},
|
||||
}
|
||||
_, err := BuildRouter(app, plugins)
|
||||
if err == nil {
|
||||
t.Fatalf("BuildRouter accepted %s %s under the admin prefix", tc.method, tc.path)
|
||||
}
|
||||
for _, want := range []string{tc.method, tc.path, "acme.intruder", "/acme-admin"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q does not name %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("sibling path is allowed", func(t *testing.T) {
|
||||
app := adminPrefixApp(t)
|
||||
plugins := []party.Plugin{
|
||||
adminPrefixPlugin{},
|
||||
prefixRoutePlugin{id: "acme.neighbour", method: http.MethodGet, path: "/acme-adminx"},
|
||||
}
|
||||
if _, err := BuildRouter(app, plugins); err != nil {
|
||||
t.Fatalf("sibling path rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func adminPrefixApp(t *testing.T) *backpack.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: admin-prefix\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "backend.yaml"), []byte("uri: /acme-admin\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=summercms-test-only-admin-hs256-secret"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
type adminPrefixPlugin struct{}
|
||||
|
||||
func (adminPrefixPlugin) ID() string { return "acme.demo" }
|
||||
func (adminPrefixPlugin) Requires() []string { return nil }
|
||||
func (adminPrefixPlugin) Register(*backpack.App) error { return nil }
|
||||
func (adminPrefixPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (adminPrefixPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{adminPrefixController{}}
|
||||
}
|
||||
func (adminPrefixPlugin) AdminFS() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\n")},
|
||||
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
|
||||
}
|
||||
}
|
||||
|
||||
type adminPrefixController struct{}
|
||||
|
||||
func (adminPrefixController) ID() string { return "acme.demo.widgets" }
|
||||
func (adminPrefixController) ModelName() string { return "Widget" }
|
||||
func (adminPrefixController) ConfigDir() string { return "controllers/widgets" }
|
||||
|
||||
type prefixRoutePlugin struct {
|
||||
id string
|
||||
method string
|
||||
path string
|
||||
raw bool
|
||||
}
|
||||
|
||||
func (p prefixRoutePlugin) ID() string { return p.id }
|
||||
func (p prefixRoutePlugin) Requires() []string { return nil }
|
||||
func (p prefixRoutePlugin) Register(*backpack.App) error { return nil }
|
||||
func (p prefixRoutePlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p prefixRoutePlugin) Routes(r pact.Router) error {
|
||||
h := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
||||
open := r.Group
|
||||
if p.raw {
|
||||
open = r.GroupRaw
|
||||
}
|
||||
open("", nil, func(g pact.Router) {
|
||||
switch p.method {
|
||||
case http.MethodPost:
|
||||
g.Post(p.path, h)
|
||||
default:
|
||||
g.Get(p.path, h)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -529,6 +529,9 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
}
|
||||
r.BindPlugin("summercms.cabana")
|
||||
admin.Mount(r)
|
||||
if err := r.checkAdminPrefix(admin.Prefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
if _, err := r.wrap(rt); err != nil {
|
||||
@@ -538,6 +541,23 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// checkAdminPrefix fails boot when a plugin other than cabana owns a route at
|
||||
// or under the admin prefix: the admin SPA and API own that whole subtree.
|
||||
func (r *Router) checkAdminPrefix(prefix string) error {
|
||||
if prefix == "" {
|
||||
return nil
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
if rt.pluginID == "summercms.cabana" {
|
||||
continue
|
||||
}
|
||||
if rt.path == prefix || strings.HasPrefix(rt.path, prefix+"/") {
|
||||
return fmt.Errorf("surf: route %s %s (plugin %q) is under the admin prefix backend.uri %s", rt.method, rt.path, rt.pluginID, prefix)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredBytes(app *backpack.App, key string) (int64, error) {
|
||||
raw, ok := app.Config.Lookup(key)
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user