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>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -93,6 +94,31 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the token to check roles. Guest accounts are not
|
||||
// permitted to use the web interface.
|
||||
roles, err := s.validateFn(token)
|
||||
if err != nil {
|
||||
log.Printf("login token validation failed for user %q: %v", username, err)
|
||||
csrf := s.generateCSRFToken(w)
|
||||
s.templates.render(w, "login", map[string]any{
|
||||
"Error": "Login failed. Please try again.",
|
||||
"CSRFToken": csrf,
|
||||
"Session": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(roles, "guest") {
|
||||
log.Printf("login denied for guest user %q", username)
|
||||
csrf := s.generateCSRFToken(w)
|
||||
s.templates.render(w, "login", map[string]any{
|
||||
"Error": "Guest accounts are not permitted to access the web interface.",
|
||||
"CSRFToken": csrf,
|
||||
"Session": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "mcr_session",
|
||||
Value: token,
|
||||
|
||||
@@ -21,16 +21,20 @@ import (
|
||||
// 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
|
||||
csrfKey []byte // 32-byte key for HMAC signing
|
||||
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.
|
||||
@@ -40,6 +44,7 @@ func New(
|
||||
audit mcrv1.AuditServiceClient,
|
||||
admin mcrv1.AdminServiceClient,
|
||||
loginFn LoginFunc,
|
||||
validateFn ValidateFunc,
|
||||
csrfKey []byte,
|
||||
) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
@@ -48,13 +53,14 @@ func New(
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
templates: tmpl,
|
||||
registry: registry,
|
||||
policy: policy,
|
||||
audit: audit,
|
||||
admin: admin,
|
||||
loginFn: loginFn,
|
||||
csrfKey: csrfKey,
|
||||
templates: tmpl,
|
||||
registry: registry,
|
||||
policy: policy,
|
||||
audit: audit,
|
||||
admin: admin,
|
||||
loginFn: loginFn,
|
||||
validateFn: validateFn,
|
||||
csrfKey: csrfKey,
|
||||
}
|
||||
|
||||
s.router = s.buildRouter()
|
||||
|
||||
@@ -183,15 +183,35 @@ func setupTestEnv(t *testing.T) *testEnv {
|
||||
if username == "admin" && password == "secret" {
|
||||
return "test-token-12345", 3600, nil
|
||||
}
|
||||
if username == "guest" && password == "secret" {
|
||||
return "test-token-guest", 3600, nil
|
||||
}
|
||||
if username == "user" && password == "secret" {
|
||||
return "test-token-user", 3600, nil
|
||||
}
|
||||
return "", 0, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
validateFn := func(token string) ([]string, error) {
|
||||
switch token {
|
||||
case "test-token-12345":
|
||||
return []string{"admin"}, nil
|
||||
case "test-token-guest":
|
||||
return []string{"guest"}, nil
|
||||
case "test-token-user":
|
||||
return []string{"user"}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
srv, err := New(
|
||||
mcrv1.NewRegistryServiceClient(conn),
|
||||
mcrv1.NewPolicyServiceClient(conn),
|
||||
mcrv1.NewAuditServiceClient(conn),
|
||||
mcrv1.NewAdminServiceClient(conn),
|
||||
loginFn,
|
||||
validateFn,
|
||||
csrfKey,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -543,6 +563,59 @@ func TestTruncate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginDeniesGuest(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
defer env.close()
|
||||
|
||||
// Get CSRF token.
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
env.server.Handler().ServeHTTP(getRec, getReq)
|
||||
|
||||
var csrfCookie *http.Cookie
|
||||
for _, c := range getRec.Result().Cookies() {
|
||||
if c.Name == "csrf_token" {
|
||||
csrfCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if csrfCookie == nil {
|
||||
t.Fatal("no csrf_token cookie")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(csrfCookie.Value, ".", 2)
|
||||
csrfToken := parts[0]
|
||||
|
||||
form := url.Values{
|
||||
"username": {"guest"},
|
||||
"password": {"secret"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
|
||||
postReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
postReq.AddCookie(csrfCookie)
|
||||
postRec := httptest.NewRecorder()
|
||||
|
||||
env.server.Handler().ServeHTTP(postRec, postReq)
|
||||
|
||||
if postRec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /login as guest: status %d, want %d", postRec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := postRec.Body.String()
|
||||
if !strings.Contains(body, "Guest accounts are not permitted") {
|
||||
t.Error("response does not contain guest denial message")
|
||||
}
|
||||
|
||||
// Verify no session cookie was set.
|
||||
for _, c := range postRec.Result().Cookies() {
|
||||
if c.Name == "mcr_session" {
|
||||
t.Error("session cookie should not be set for guest login")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccessSetsCookie(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
defer env.close()
|
||||
|
||||
Reference in New Issue
Block a user