feat(10-01): serve the embedded admin SPA at backend.uri with cookie login
- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
through the openapi-fetch client typed by the generated schema
This commit is contained in:
45
cabana/csrf.go
Normal file
45
cabana/csrf.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestedWithHeader is the custom header the admin SPA sends on every
|
||||
// request. A cross-site form or navigation cannot set it, and a
|
||||
// cross-origin fetch that sets it needs a CORS preflight the admin API
|
||||
// never answers (D-19).
|
||||
requestedWithHeader = "X-Requested-With"
|
||||
requestedWithAjax = "XMLHttpRequest"
|
||||
)
|
||||
|
||||
// requireAjax refuses a state-changing admin request that is not
|
||||
// Bearer-authenticated and does not carry X-Requested-With: XMLHttpRequest.
|
||||
// It runs before the wrapped handler, so a refused request is never decoded,
|
||||
// never looks up a controller and never reaches the database. The response
|
||||
// uses the fixed D-10 code forbidden.
|
||||
func requireAjax(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !csrfSafe(r) {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfSafe(r *http.Request) bool {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
if bearerToken(r) != "" {
|
||||
return true
|
||||
}
|
||||
return isAjax(r)
|
||||
}
|
||||
|
||||
func isAjax(r *http.Request) bool {
|
||||
return strings.TrimSpace(r.Header.Get(requestedWithHeader)) == requestedWithAjax
|
||||
}
|
||||
Reference in New Issue
Block a user