Files
mcr/internal/webserver/server.go
Kyle Isom 9d7043a594 Block guest accounts from web UI login
The web UI now validates the MCIAS token after login and rejects
accounts with the guest role before setting the session cookie.
This is defense-in-depth alongside the env:restricted MCIAS tag.

The webserver.New() constructor takes a new ValidateFunc parameter
that inspects token roles post-authentication. MCIAS login does not
return roles, so this requires an extra ValidateToken round-trip at
login time (result is cached for 30s).

Security: guest role accounts are denied web UI access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:02:22 -07:00

140 lines
3.8 KiB
Go

// Package webserver implements the MCR web UI server.
//
// It serves HTML pages rendered from Go templates with htmx for
// interactive elements. All data is fetched via gRPC from the main
// mcrsrv API server. Authentication is handled via MCIAS, with session
// tokens stored in secure HttpOnly cookies.
package webserver
import (
"io/fs"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/web"
)
// LoginFunc authenticates a user and returns a bearer token.
type LoginFunc func(username, password string) (token string, expiresIn int, err error)
// ValidateFunc validates a bearer token and returns the user's roles.
type ValidateFunc func(token string) (roles []string, err error)
// Server is the MCR web UI server.
type Server struct {
router chi.Router
templates *templateSet
registry mcrv1.RegistryServiceClient
policy mcrv1.PolicyServiceClient
audit mcrv1.AuditServiceClient
admin mcrv1.AdminServiceClient
loginFn LoginFunc
validateFn ValidateFunc
csrfKey []byte // 32-byte key for HMAC signing
}
// New creates a new web UI server with the given gRPC clients and login function.
func New(
registry mcrv1.RegistryServiceClient,
policy mcrv1.PolicyServiceClient,
audit mcrv1.AuditServiceClient,
admin mcrv1.AdminServiceClient,
loginFn LoginFunc,
validateFn ValidateFunc,
csrfKey []byte,
) (*Server, error) {
tmpl, err := loadTemplates()
if err != nil {
return nil, err
}
s := &Server{
templates: tmpl,
registry: registry,
policy: policy,
audit: audit,
admin: admin,
loginFn: loginFn,
validateFn: validateFn,
csrfKey: csrfKey,
}
s.router = s.buildRouter()
return s, nil
}
// Handler returns the http.Handler for the server.
func (s *Server) Handler() http.Handler {
return s.router
}
// buildRouter sets up the chi router with all routes and middleware.
func (s *Server) buildRouter() chi.Router {
r := chi.NewRouter()
// Global middleware.
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
// Static files (no auth required).
staticFS, err := fs.Sub(web.Content, "static")
if err != nil {
log.Fatalf("webserver: failed to create static sub-filesystem: %v", err)
}
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Public routes (no session required).
r.Get("/login", s.handleLoginPage)
r.Post("/login", s.handleLoginSubmit)
r.Get("/logout", s.handleLogout)
// Protected routes (session required).
r.Group(func(r chi.Router) {
r.Use(s.sessionMiddleware)
r.Get("/", s.handleDashboard)
// Repository routes — name may contain slashes.
r.Get("/repositories", s.handleRepositories)
r.Get("/repositories/*", s.handleRepositoryOrManifest)
// Policy routes (admin — gRPC interceptors enforce this).
r.Get("/policies", s.handlePolicies)
r.Post("/policies", s.handleCreatePolicy)
r.Post("/policies/{id}/toggle", s.handleTogglePolicy)
r.Post("/policies/{id}/delete", s.handleDeletePolicy)
// Audit routes (admin — gRPC interceptors enforce this).
r.Get("/audit", s.handleAudit)
})
return r
}
// handleRepositoryOrManifest dispatches between repository detail and
// manifest detail based on the URL path. This is necessary because
// repository names can contain slashes.
func (s *Server) handleRepositoryOrManifest(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if idx := lastIndex(path, "/manifests/"); idx >= 0 {
s.handleManifestDetail(w, r)
return
}
s.handleRepositoryDetail(w, r)
}
// lastIndex returns the index of the last occurrence of sep in s, or -1.
func lastIndex(s, sep string) int {
for i := len(s) - len(sep); i >= 0; i-- {
if s[i:i+len(sep)] == sep {
return i
}
}
return -1
}