fix UI privilege escalation vulnerability

- Add requireAdminRole middleware to web UI that checks
  claims.HasRole("admin") and returns 403 if absent
- Apply middleware to all admin routes (accounts, policies,
  audit, dashboard, credentials)
- Remove redundant inline admin check from handleAdminResetPassword
- Profile routes correctly require only authentication, not admin

Security: The admin/adminGet middleware wrappers only called
requireCookieAuth (JWT validation) but never verified the admin
role. Any authenticated user could access admin endpoints
including role assignment. Fixed by inserting requireAdminRole
into the middleware chain for all admin routes.
This commit is contained in:
2026-03-12 21:59:02 -07:00
parent d9c904b0f4
commit 8d9d9da6f5
2 changed files with 31 additions and 25 deletions

View File

@@ -914,26 +914,8 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
// storage. The plaintext is never logged or included in any response.
// Audit event EventPasswordChanged is recorded on success.
func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
// Security: enforce admin role; requireCookieAuth only validates the token,
// it does not check roles. A non-admin with a valid session must not be
// able to reset arbitrary accounts' passwords.
callerClaims := claimsFromContext(r.Context())
if callerClaims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
isAdmin := false
for _, role := range callerClaims.Roles {
if role == "admin" {
isAdmin = true
break
}
}
if !isAdmin {
u.renderError(w, r, http.StatusForbidden, "admin role required")
return
}
// Security: admin role is enforced by the requireAdminRole middleware in
// the route registration (ui.go); no inline check needed here.
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")

View File

@@ -301,12 +301,17 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.HandleFunc("POST /logout", u.handleLogout)
// Protected routes.
auth := u.requireCookieAuth
//
// Security: three distinct access levels:
// - authed: any valid session cookie (authenticated user)
// - admin: authed + admin role in JWT claims (mutating admin ops)
// - adminGet: authed + admin role (read-only admin pages, no CSRF)
authed := u.requireCookieAuth
admin := func(h http.HandlerFunc) http.Handler {
return auth(u.requireCSRF(http.HandlerFunc(h)))
return authed(u.requireAdminRole(u.requireCSRF(http.HandlerFunc(h))))
}
adminGet := func(h http.HandlerFunc) http.Handler {
return auth(http.HandlerFunc(h))
return authed(u.requireAdminRole(http.HandlerFunc(h)))
}
uiMux.Handle("GET /dashboard", adminGet(u.handleDashboard))
@@ -335,8 +340,8 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
// Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", adminGet(u.handleProfilePage))
uiMux.Handle("PUT /profile/password", auth(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -405,6 +410,25 @@ func (u *UIServer) requireCSRF(next http.Handler) http.Handler {
})
}
// requireAdminRole checks that the authenticated user holds the "admin" role.
// Must be placed after requireCookieAuth in the middleware chain so that
// claims are available in the context.
//
// Security: This is the authoritative server-side check that prevents
// non-admin users from accessing admin-only UI endpoints. The JWT claims
// are populated from the database at login/renewal and signed with the
// server's Ed25519 private key, so they cannot be forged client-side.
func (u *UIServer) requireAdminRole(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := claimsFromContext(r.Context())
if claims == nil || !claims.HasRole("admin") {
u.renderError(w, r, http.StatusForbidden, "admin role required")
return
}
next.ServeHTTP(w, r)
})
}
// ---- Helpers ----
// isHTMX reports whether the request was initiated by HTMX.