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:
Jakub Zych
2026-09-27 15:34:19 +02:00
parent 5f9353841b
commit dafdb18234
15 changed files with 1283 additions and 18 deletions

View File

@@ -194,19 +194,28 @@ func cookieLoginData(ttl time.Duration) AdminLoginData {
// Max-Age is the refresh window, because refresh accepts an expired access
// token until iat plus refresh_ttl.
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
http.SetCookie(w, s.sessionCookie(token, int(s.refreshTTL/time.Second)))
}
// expireSessionCookie tells the browser to drop the admin cookie.
func (s *service) expireSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, s.sessionCookie("", -1))
}
func (s *service) sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: AdminCookieName,
Value: token,
Value: value,
Path: s.adminPrefix(),
MaxAge: int(s.refreshTTL / time.Second),
MaxAge: maxAge,
HttpOnly: true,
Secure: true,
Secure: !s.insecureCookie,
SameSite: http.SameSiteStrictMode,
})
}
}
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
raw, fromCookie := sessionToken(r)
if raw == "" {
s.logAuth(r, "failed", 0)
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
@@ -219,14 +228,22 @@ func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuth(r, "success", 0)
if fromCookie {
// A cookie-authenticated request never receives a token in its body.
s.writeSessionCookie(w, next)
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
return
}
WriteData(w, http.StatusOK, map[string]string{
"access_token": next,
"token_type": "bearer",
}, map[string]any{})
}
// logout blacklists the presented token's jti and always expires the admin
// cookie, so a browser session ends even when only the Bearer was revoked.
func (s *service) logout(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
raw, _ := sessionToken(r)
_, iat, exp, jti, err := bouncer.VerifyClaimsAudience(raw, s.secret, bouncer.AudienceBackend)
if err != nil || jti == "" {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
@@ -247,9 +264,27 @@ func (s *service) logout(w http.ResponseWriter, r *http.Request) {
id = principal.ID
}
s.logAuth(r, "success", id)
s.expireSessionCookie(w)
WriteData(w, http.StatusOK, map[string]any{"status": "logged_out"}, map[string]any{})
}
// sessionToken returns the admin JWT the same way the backend guard reads it:
// the Authorization Bearer header first, then the summer_admin cookie.
func sessionToken(r *http.Request) (token string, fromCookie bool) {
if raw := bearerToken(r); raw != "" {
return raw, false
}
if r == nil {
return "", false
}
if c, err := r.Cookie(AdminCookieName); err == nil {
if raw := strings.TrimSpace(c.Value); raw != "" {
return raw, true
}
}
return "", false
}
func (s *service) me(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil {
@@ -425,6 +460,22 @@ func adminLoginWindow(app *backpack.App) (int, int) {
return maxAttempts, decayMinutes
}
// adminCookieSecure reads backend.cookie_secure (default true). false drops
// the Secure attribute for plain-http development and is refused in the
// production environment.
func adminCookieSecure(app *backpack.App) (bool, error) {
if app == nil || app.Config == nil || !app.Config.Has("backend.cookie_secure") {
return true, nil
}
if app.Config.Bool("backend.cookie_secure") {
return true, nil
}
if strings.EqualFold(strings.TrimSpace(app.Config.Environment()), "production") {
return false, errors.New("cabana: backend.cookie_secure: false is not allowed in the production environment")
}
return false, nil
}
// adminIssuer is app.url plus the admin API login path. JWT verification does
// not check iss, so tokens minted under an earlier prefix stay valid until
// they expire.

View File

@@ -43,6 +43,9 @@ type service struct {
bl bouncer.BlacklistStore
prefix string
spa http.Handler
// insecureCookie drops Secure from the admin cookie (backend.cookie_secure
// false, development only); the zero value keeps the cookie Secure.
insecureCookie bool
}
// adminPrefix returns the mount path; a zero service uses the default.
@@ -68,6 +71,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if len(items) == 0 {
return nil, nil
}
if err := checkReservedSegments(items); err != nil {
return nil, err
}
secret, err := adminSecret(app)
if err != nil {
return nil, err
@@ -76,6 +82,10 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil {
return nil, err
}
secureCookie, err := adminCookieSecure(app)
if err != nil {
return nil, err
}
reg, err := compileRegistry(items)
if err != nil {
return nil, err
@@ -118,6 +128,8 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
issuer: adminIssuer(app, prefix),
bl: bl,
prefix: prefix,
insecureCookie: !secureCookie,
}
spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound))
if err != nil {

352
cabana/phase10_auth_test.go Normal file
View File

@@ -0,0 +1,352 @@
package cabana_test
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/cabana"
"git.golem15.com/golem15/summercms/compass"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
)
// TestPhase10CookieAuth pins the D-19 session transport: cookie login and
// refresh never put the JWT in a body, Bearer clients keep the Phase 9 body,
// a cookie refresh needs the CSRF header, and logout blacklists the jti and
// expires the cookie.
func TestPhase10CookieAuth(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "p10cookie", "p10cookie@example.test", adminTestPassword, true, false)
creds := map[string]string{"login": "p10cookie", "password": adminTestPassword}
login := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, true)
if login.Code != http.StatusOK {
t.Fatalf("cookie login status=%d body=%s", login.Code, login.Body.String())
}
first := phase10Cookie(t, login, cabana.DefaultAdminPrefix)
phase10AssertCookieBody(t, login, first.Value)
bearer := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, false)
if bearer.Code != http.StatusOK {
t.Fatalf("bearer login status=%d body=%s", bearer.Code, bearer.Body.String())
}
phase10AssertBearerBody(t, bearer)
if got := bearer.Header().Values("Set-Cookie"); len(got) != 0 {
t.Fatalf("bearer login set cookies: %q", got)
}
bearerToken := accessToken(t, bearer.Body.Bytes())
noHeader := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, first, false)
if noHeader.Code != http.StatusForbidden || phase10ErrorCode(t, noHeader) != "forbidden" {
t.Fatalf("cookie refresh without header status=%d body=%s", noHeader.Code, noHeader.Body.String())
}
if got := noHeader.Header().Values("Set-Cookie"); len(got) != 0 {
t.Fatalf("refused refresh set cookies: %q", got)
}
refreshed := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, first, true)
if refreshed.Code != http.StatusOK {
t.Fatalf("cookie refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
}
second := phase10Cookie(t, refreshed, cabana.DefaultAdminPrefix)
if second.Value == first.Value {
t.Fatal("cookie refresh did not rotate the token")
}
phase10AssertCookieBody(t, refreshed, second.Value)
if stale := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, first, true); stale.Code != http.StatusUnauthorized {
t.Fatalf("rotated-out cookie status=%d body=%s", stale.Code, stale.Body.String())
}
if me := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, second, true); me.Code != http.StatusOK {
t.Fatalf("rotated cookie /auth/me status=%d body=%s", me.Code, me.Body.String())
}
bearerRefresh := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), bearerToken, nil)
if bearerRefresh.Code != http.StatusOK {
t.Fatalf("bearer refresh status=%d body=%s", bearerRefresh.Code, bearerRefresh.Body.String())
}
phase10AssertBearerBody(t, bearerRefresh)
if got := bearerRefresh.Header().Values("Set-Cookie"); len(got) != 0 {
t.Fatalf("bearer refresh set cookies: %q", got)
}
logout := phase10Send(t, h, http.MethodPost, adminAPI("/auth/logout"), nil, second, true)
if logout.Code != http.StatusOK {
t.Fatalf("cookie logout status=%d body=%s", logout.Code, logout.Body.String())
}
var expired *http.Cookie
for _, c := range logout.Result().Cookies() {
if c.Name == cabana.AdminCookieName {
expired = c
}
}
if expired == nil || expired.MaxAge >= 0 || expired.Value != "" || expired.Path != cabana.DefaultAdminPrefix {
t.Fatalf("logout cookie = %+v, want an expiring %s with Path %s", expired, cabana.AdminCookieName, cabana.DefaultAdminPrefix)
}
if after := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, second, true); after.Code != http.StatusUnauthorized {
t.Fatalf("logged-out cookie status=%d body=%s", after.Code, after.Body.String())
}
}
// TestPhase10Prefix pins backend.uri normalization and validation, moves the
// whole admin surface with a custom prefix, rejects reserved controller
// vendor segments and refuses an insecure admin cookie in production.
func TestPhase10Prefix(t *testing.T) {
t.Run("normalization", func(t *testing.T) {
for raw, want := range map[string]string{
" /acme-admin/ ": "/acme-admin",
"": "/backend",
"acme": "/acme",
"/a/b_c/": "/a/b_c",
} {
got, err := cabana.AdminPrefix(phase10App(t, "development", map[string]any{"backend.uri": raw}))
if err != nil || got != want {
t.Fatalf("AdminPrefix(%q) = %q, %v; want %q", raw, got, err, want)
}
}
})
t.Run("invalid values fail activation", func(t *testing.T) {
for _, raw := range []string{"/", "/Admin", "/a b", "/../x", "//", "/-x"} {
app := phase10App(t, "development", map[string]any{"backend.uri": raw})
_, err := cabana.Activate(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
if err == nil || !strings.Contains(err.Error(), "backend.uri") {
t.Fatalf("backend.uri %q: err=%v, want an activation error naming backend.uri", raw, err)
}
}
})
t.Run("custom prefix moves the surface", func(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, func(cfg *compass.Config) {
phase10Set(t, cfg, "backend.uri", "/acme-admin")
phase10Set(t, cfg, "app.url", "https://app.test")
})
insertAdmin(t, gdb, "p10prefix", "p10prefix@example.test", adminTestPassword, true, false)
creds := map[string]string{"login": "p10prefix", "password": adminTestPassword}
api := "/acme-admin/api/v1"
login := phase10Send(t, h, http.MethodPost, api+"/auth/login", creds, nil, true)
if login.Code != http.StatusOK {
t.Fatalf("custom prefix login status=%d body=%s", login.Code, login.Body.String())
}
phase10Cookie(t, login, "/acme-admin")
bearer := phase10Send(t, h, http.MethodPost, api+"/auth/login", creds, nil, false)
token := accessToken(t, bearer.Body.Bytes())
if iss, _ := jwtClaims(t, token)["iss"].(string); iss != "https://app.test/acme-admin/api/v1/auth/login" {
t.Fatalf("issuer = %q", iss)
}
if me := getAuth(t, h, api+"/auth/me", token); me.Code != http.StatusOK {
t.Fatalf("custom prefix /auth/me status=%d", me.Code)
}
shell := getAuth(t, h, "/acme-admin", "")
if shell.Code != http.StatusOK || !strings.Contains(shell.Body.String(), `content="/acme-admin"`) {
t.Fatalf("custom prefix shell status=%d body=%s", shell.Code, shell.Body.String())
}
deep := getAuth(t, h, "/acme-admin/acme/demo/widgets", "")
if deep.Code != http.StatusOK || !strings.Contains(deep.Body.String(), `content="/acme-admin"`) {
t.Fatalf("custom prefix deep link status=%d", deep.Code)
}
missing := getAuth(t, h, api+"/nope", "")
if missing.Code != http.StatusNotFound || phase10ErrorCode(t, missing) != "not_found" {
t.Fatalf("custom prefix API miss status=%d body=%s", missing.Code, missing.Body.String())
}
old := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, false)
if old.Code == http.StatusOK {
t.Fatalf("default prefix still answers under a custom backend.uri: %s", old.Body.String())
}
if shell := getAuth(t, h, cabana.DefaultAdminPrefix, ""); shell.Code == http.StatusOK {
t.Fatal("default prefix still serves the SPA under a custom backend.uri")
}
})
t.Run("reserved controller vendor segments", func(t *testing.T) {
for _, vendor := range []string{"api", "assets", "login", "settings"} {
app := phase10App(t, "development", nil)
_, err := cabana.Activate(app, []party.Plugin{reservedPlugin{vendor: vendor}})
if err == nil || !strings.Contains(err.Error(), vendor+".demo.widgets") || !strings.Contains(err.Error(), "reserved") {
t.Fatalf("vendor %q: err=%v, want a reserved-segment activation error", vendor, err)
}
}
})
t.Run("cookie_secure", func(t *testing.T) {
prod := phase10App(t, "production", map[string]any{"backend.cookie_secure": false})
if _, err := cabana.Activate(prod, []party.Plugin{demoPlugin{fsys: demoFS()}}); err == nil || !strings.Contains(err.Error(), "backend.cookie_secure") {
t.Fatalf("production cookie_secure=false: err=%v", err)
}
if _, err := cabana.Activate(phase10App(t, "production", nil), []party.Plugin{demoPlugin{fsys: demoFS()}}); err != nil {
t.Fatalf("production default cookie_secure: %v", err)
}
gdb := adminGorm(t)
h := adminHandler(t, gdb, func(cfg *compass.Config) {
phase10Set(t, cfg, "backend.cookie_secure", false)
})
insertAdmin(t, gdb, "p10insecure", "p10insecure@example.test", adminTestPassword, true, false)
login := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), map[string]string{"login": "p10insecure", "password": adminTestPassword}, nil, true)
if login.Code != http.StatusOK {
t.Fatalf("insecure-cookie login status=%d body=%s", login.Code, login.Body.String())
}
for _, c := range login.Result().Cookies() {
if c.Name == cabana.AdminCookieName && (c.Secure || !c.HttpOnly || c.SameSite != http.SameSiteStrictMode) {
t.Fatalf("development cookie_secure=false cookie = %+v, want HttpOnly SameSite=Strict without Secure", c)
}
}
})
}
func phase10Send(t *testing.T, h http.Handler, method, path string, body any, cookie *http.Cookie, ajax bool) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if ajax {
req.Header.Set("X-Requested-With", "XMLHttpRequest")
}
if cookie != nil {
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func phase10Cookie(t *testing.T, rec *httptest.ResponseRecorder, path string) *http.Cookie {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name != cabana.AdminCookieName {
continue
}
if c.Value == "" || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != path || c.MaxAge <= 0 {
t.Fatalf("session cookie = %+v, want HttpOnly Secure SameSite=Strict Path=%s with a Max-Age", c, path)
}
return c
}
t.Fatalf("no %s cookie; Set-Cookie=%q body=%s", cabana.AdminCookieName, rec.Header().Values("Set-Cookie"), rec.Body.String())
return nil
}
func phase10AssertCookieBody(t *testing.T, rec *httptest.ResponseRecorder, token string) {
t.Helper()
var body struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data["token_type"] != "cookie" {
t.Fatalf("cookie body = %s", rec.Body.String())
}
if n, ok := body.Data["expires_in"].(float64); !ok || n <= 0 {
t.Fatalf("cookie body expires_in = %v", body.Data["expires_in"])
}
if _, ok := body.Data["access_token"]; ok || strings.Contains(rec.Body.String(), token) || strings.Contains(rec.Body.String(), "eyJ") {
t.Fatalf("cookie body carries a token: %s", rec.Body.String())
}
}
func phase10AssertBearerBody(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
var body struct {
Data map[string]any `json:"data"`
Meta map[string]any `json:"meta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
token, _ := body.Data["access_token"].(string)
if len(body.Data) != 2 || token == "" || body.Data["token_type"] != "bearer" || len(body.Meta) != 0 {
t.Fatalf("bearer body = %s, want the Phase 9 {access_token, token_type: bearer} shape", rec.Body.String())
}
}
func phase10ErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("error json: %v body=%s", err, rec.Body.String())
}
return body.Error.Code
}
func phase10Set(t *testing.T, cfg *compass.Config, key string, value any) {
t.Helper()
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
// phase10App is a database-free app for activation checks.
func phase10App(t *testing.T, env string, values map[string]any) *backpack.App {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-phase10\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{
"SUMMER_ENV=" + env,
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
},
})
if err != nil {
t.Fatal(err)
}
for key, value := range values {
phase10Set(t, cfg, key, value)
}
return backpack.New(cfg)
}
// reservedPlugin owns a controller whose vendor segment collides with an SPA
// or API segment under the admin prefix.
type reservedPlugin struct{ vendor string }
func (p reservedPlugin) ID() string { return p.vendor + ".demo" }
func (p reservedPlugin) Requires() []string { return nil }
func (p reservedPlugin) Register(*backpack.App) error { return nil }
func (p reservedPlugin) Boot(*backpack.App) error { return nil }
func (p reservedPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{reservedController{vendor: p.vendor}}
}
func (p reservedPlugin) AdminFS() fs.FS {
return fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte(fmt.Sprintf("list: ~/plugins/%s/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\n", p.vendor))},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
}
}
type reservedController struct{ vendor string }
func (c reservedController) ID() string { return c.vendor + ".demo.widgets" }
func (reservedController) ModelName() string { return "Widget" }
func (reservedController) ConfigDir() string { return "controllers/widgets" }

154
cabana/phase10_csrf_test.go Normal file
View File

@@ -0,0 +1,154 @@
package cabana
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
)
// TestPhase10CSRF walks every state-changing route service.mount registers.
// Except login, a request carrying only the admin cookie and no
// X-Requested-With header is refused with 403 forbidden before the handler
// runs: the body spy is never read and the service, which has no database,
// never answers with a database error. The same call with the header or with
// a Bearer token reaches the handler.
func TestPhase10CSRF(t *testing.T) {
router := &handlerRouter{handlers: map[string]http.HandlerFunc{}}
svc := phase09DeniedService()
svc.mount(router)
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
login := http.MethodPost + " " + adminAPI("/auth/login")
unsafe := 0
for _, key := range router.order {
method, path, _ := strings.Cut(key, " ")
if method != http.MethodPost && method != http.MethodPut && method != http.MethodDelete {
continue
}
if key == login {
continue
}
unsafe++
handler := router.handlers[key]
t.Run(key, func(t *testing.T) {
refused, spy := csrfRequest(method, path, super)
refused.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only-session"})
rec := httptest.NewRecorder()
handler(rec, refused)
if rec.Code != http.StatusForbidden {
t.Fatalf("cookie-only status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
if spy.reads != 0 {
t.Fatalf("refused request body was read %d times", spy.reads)
}
withHeader, _ := csrfRequest(method, path, super)
withHeader.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only-session"})
withHeader.Header.Set("X-Requested-With", "XMLHttpRequest")
rec = httptest.NewRecorder()
handler(rec, withHeader)
if rec.Code == http.StatusForbidden {
t.Fatalf("request with X-Requested-With was refused: %s", rec.Body.String())
}
withBearer, _ := csrfRequest(method, path, super)
withBearer.Header.Set("Authorization", "Bearer not-a-real-token")
rec = httptest.NewRecorder()
handler(rec, withBearer)
if rec.Code == http.StatusForbidden {
t.Fatalf("Bearer request was refused: %s", rec.Body.String())
}
})
}
// refresh, logout, settings put, create, bulk-delete, update, delete, link, unlink
if unsafe != 9 {
t.Fatalf("walked %d state-changing routes, want 9: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]
req, _ := csrfRequest(http.MethodPost, adminAPI("/auth/login"), nil)
rec := httptest.NewRecorder()
loginHandler(rec, req)
if rec.Code == http.StatusForbidden {
t.Fatalf("login without the header was refused: %s", rec.Body.String())
}
}
type readSpy struct {
r io.Reader
reads int
}
func (s *readSpy) Read(p []byte) (int, error) {
s.reads++
return s.r.Read(p)
}
func csrfRequest(method, path string, principal *bouncer.Principal) (*http.Request, *readSpy) {
spy := &readSpy{r: strings.NewReader(`{"ids":[1],"name":"csrf"}`)}
req := httptest.NewRequest(method, path, spy)
req.Header.Set("Content-Type", "application/json")
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, spy
}
// handlerRouter records the handler mounted for every route, so tests can
// call exactly what service.mount registered.
type handlerRouter struct {
prefix string
handlers map[string]http.HandlerFunc
order []string
}
func (h *handlerRouter) Group(prefix string, middleware []string, fn func(pact.Router)) {
h.GroupRaw(prefix, middleware, fn)
}
func (h *handlerRouter) GroupRaw(prefix string, _ []string, fn func(pact.Router)) {
child := &handlerRouter{prefix: h.prefix + prefix, handlers: h.handlers}
fn(child)
h.order = append(h.order, child.order...)
}
func (h *handlerRouter) Get(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodGet, path, fn)
}
func (h *handlerRouter) Post(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodPost, path, fn)
}
func (h *handlerRouter) Put(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodPut, path, fn)
}
func (h *handlerRouter) Patch(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodPatch, path, fn)
}
func (h *handlerRouter) Delete(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodDelete, path, fn)
}
func (h *handlerRouter) Where(string, string) {}
func (h *handlerRouter) WhereIn(string, ...string) {}
func (h *handlerRouter) add(method, path string, fn http.HandlerFunc) {
key := method + " " + h.prefix + path
h.handlers[key] = fn
h.order = append(h.order, key)
}

View File

@@ -34,6 +34,22 @@ func collectControllers(plugins []party.Plugin) ([]controllerRef, error) {
return out, nil
}
// reservedVendorSegments are the first path segments under the admin prefix
// that the SPA and API own. A controller ID maps to /{vendor}/{plugin}/..., so
// a vendor with one of these names would collide with them.
var reservedVendorSegments = map[string]bool{"api": true, "assets": true, "login": true, "settings": true}
func checkReservedSegments(items []controllerRef) error {
for _, item := range items {
id := item.ctl.ID()
vendor, _, _ := strings.Cut(id, ".")
if reservedVendorSegments[vendor] {
return fmt.Errorf("cabana: controller %s uses the reserved admin path segment %q (reserved: api, assets, login, settings)", id, vendor)
}
}
return nil
}
func compileRegistry(items []controllerRef) (*Registry, error) {
byID := make(map[string]*CompiledController, len(items))
for _, item := range items {