diff --git a/.github/codeql/codeql-suppressions.yml b/.github/codeql/codeql-suppressions.yml index b7766363b..0ab6be581 100644 --- a/.github/codeql/codeql-suppressions.yml +++ b/.github/codeql/codeql-suppressions.yml @@ -48,7 +48,9 @@ suppressions: - rule_id: go/log-injection path: backend/internal/api/handlers/remote_server_handler.go - line: 142 + line_range: + start: 146 + end: 150 reason: > False positive. The logged value is the goroutine's `id uint` parameter (bound from `server.ID`, a GORM-assigned numeric primary @@ -67,6 +69,14 @@ suppressions: this entry is the documented fallback for when a local SARIF scan does not populate `result.suppressions` (see the go/cookie-secure-not-set entry above for the same limitation). + Extended 2026-09-09: the management-API authorization hardening + (fix(security): apply deny-by-default authorization on management + API subroutes) changed RemoteServerHandler.RegisterRoutes to a + (read, admin *gin.RouterGroup) split, adding lines above Update and + shifting the sink from line 142 to ~148; widened to a line_range so + the multi-line `logger.Log().WithError(...).WithField(...).Warn(...)` + chain stays covered regardless of which line CodeQL anchors + startLine to. added: "2026-08-27" review_by: "2026-11-27" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 235806fee..0491ea373 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.40.1" + ".": "0.40.2" } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 134cbc655..91d314010 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -331,7 +331,7 @@ fork/offline fallback. - **Handlers:** Process HTTP requests, validate input, return responses - **Middleware:** CORS, GZIP, authentication, logging, metrics, panic recovery -- **Routes:** Route registration and grouping (public vs authenticated) +- **Routes:** Route registration and grouping (public, authenticated, and admin-only — see [Management API Authentication & Authorization](#management-api-authentication--authorization)) **Example Endpoints:** @@ -872,6 +872,46 @@ pin (the stage already carries ~40 such pins). - **Credential Encryption:** AES-GCM with key rotation for stored credentials - **Password Hashing:** bcrypt with cost factor 12 +### Management API Authentication & Authorization + +**Roles** (`backend/internal/models/user.go`): `admin`, `user`, `passthrough`. +`passthrough` is a forward-auth identity only and has no management access. + +**Route-group boundary** (`backend/internal/api/routes/routes.go`): + +| Group | Middleware chain | Who reaches it | +|-------|------------------|----------------| +| `protected` | `AuthMiddleware` | any authenticated caller | +| `management` | `+ RequireManagementAccess()` | `admin` and `user` (rejects `passthrough`) | +| `managementAdmin` | `+ RequireRole(models.RoleAdmin)` | `admin` only | +| `securityAdmin`, `authenticatedAdmin` | `+ RequireRole(models.RoleAdmin)` | `admin` only (same idiom, area-scoped) | + +`managementAdmin` is a sibling group derived from `management` (`management.Group("/")` +with `RequireRole(admin)` added). The classification rule is **mutation vs. read**: +a `GET`/list that backs a `role=user`-reachable screen stays on `management`; its +state-changing siblings — and any route exposing privileged infrastructure — move +to `managementAdmin` (or take a per-route `RequireRole(admin)` argument where they +are registered inline). Areas gated to `admin` this way include CrowdSec admin +APIs, DNS-provider credentials and ACME, certificate export, access-list / +security-header / domain writes, tunnel-provider (Hecate) config, Orthrus agent +provisioning, remote-server (SSH) config, application settings, feature flags, +plugin enable/disable, notification test/preview, audit-log viewing, and +encryption management. The subgroup middleware is the only guard on these routes +(no redundant in-handler role checks), matching the pre-existing `securityAdmin` +pattern. + +**Deny-by-default enforcement:** `routes_test.go` iterates every +`POST`/`PUT`/`PATCH`/`DELETE` route under `/api/v1/` and asserts a `role=user` +token receives `403` unless the route is on an explicit, commented allowlist, so a +newly added privileged route cannot silently land on an under-guarded group. + +**Account creation:** there is no public self-registration route. The first +administrator is bootstrapped through `POST /api/v1/setup` on an instance with no +users; every subsequent account is created by an existing admin — directly +(`POST /api/v1/users`) or via the email-invite flow +(`POST /api/v1/users/invite` → `GET /api/v1/invite/validate` → +`POST /api/v1/invite/accept`). `/setup` refuses once any user exists. + ### Emergency Break-Glass Protocol **3-Tier Recovery System:** @@ -910,7 +950,7 @@ Charon operates with **two distinct traffic flows** on separate ports, each with - Management interface must remain accessible even when security modules are misconfigured - Emergency endpoints (`/api/v1/emergency/*`) require unrestricted access for system recovery -- Separation of concerns: admin access control is handled by JWT, not proxy-level security +- Separation of concerns: management access control is handled by JWT authentication plus role-based route guards (see [Management API Authentication & Authorization](#management-api-authentication--authorization)), not proxy-level security #### Proxy Traffic (Ports 80/443) diff --git a/Dockerfile b/Dockerfile index 89f0d9469..bd16d95a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,8 @@ ARG CHARON_TOOLCHAIN_IMAGE=ghcr.io/wikid82/charon-toolchain # NOT Renovate-tracked (a content-hash tag has no series to follow, N7) — the # toolchain-image.yml bot owns these two lines. DIGEST is the arch-independent # manifest-list (OCI index) digest, so one pin covers linux/amd64 + linux/arm64. -ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-1efe7f19fa52a512 -ARG CHARON_TOOLCHAIN_DIGEST=sha256:6575f4c6a9f76074870c64df9dd4c9ebee812342f37f52ae5ef8f511ba9f8f00 +ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-9eb9862f44b9e769 +ARG CHARON_TOOLCHAIN_DIGEST=sha256:b41e571d5951bbfc3daa3dccdca033ad9dee535a8e720ac7e3b0bce338f223b2 # Stage selector — default consumes the prebuilt toolchain image (no compile). # Fork PRs / bootstrap / offline builds pass @@ -60,7 +60,7 @@ ARG KLAUSPOST_COMPRESS_VERSION=1.20.0 # and CrowdSec/cscli binaries (which pull it in transitively) are patched immediately, # ahead of upstream releases. # renovate: datasource=go depName=google.golang.org/grpc -ARG GRPC_VERSION=1.83.1 +ARG GRPC_VERSION=1.83.2 # renovate: datasource=npm depName=npm ARG NPM_VERSION=12.0.2 diff --git a/SECURITY.md b/SECURITY.md index fca033d34..3f7db23ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1148,9 +1148,29 @@ attackers from using the application to access internal resources or cloud metad ### Authentication & Authorization - **JWT-based authentication**: Secure token-based sessions -- **Role-based access control**: Admin vs. user permissions - **Session management**: Automatic expiration and renewal - **Secure cookie attributes**: HttpOnly, Secure (HTTPS), SameSite +- **Role-based access control**: Three roles — `admin`, `user`, and `passthrough` + (forward-auth identity only, no management access). + - **`admin`** is required for all administrative and infrastructure operations: + CrowdSec controls, DNS-provider credentials and ACME, certificate export, + access-list / security-header / domain changes, tunnel-provider and remote + agent configuration, SSH remote-server configuration, application settings, + feature flags, plugin enable/disable, notification test/preview, audit-log + viewing, and encryption management. + - **`user`** covers day-to-day proxy-host management. A `user` may open and read + most configuration screens, but every state-changing action on the areas + above is rejected (`403 Forbidden`). + - Enforcement is structural: privileged API routes are mounted on an + admin-only route group (`RequireManagementAccess` + `RequireRole(admin)`), + distinct from the group that serves the read views behind non-admin screens. + A deny-by-default test in CI fails the build if a new state-changing route is + added without an explicit authorization decision. +- **Account creation is admin-controlled**: there is no public self-registration + endpoint — it is not a configuration toggle, it is simply not exposed. The + first administrator is created through the one-time `/setup` screen on a fresh + instance; every account after that is created by an existing admin, either via + an email invite link or by adding the user directly. ### Data Protection @@ -1318,6 +1338,10 @@ fails the build on any blocking finding. 4. **Secure Webhooks**: Only use trusted webhook endpoints 5. **Strong Passwords**: Enforce password complexity policies 6. **Backup Encryption**: Encrypt backup files before storage +7. **Account Creation**: Create accounts only through the initial `/setup` screen + and the admin-controlled invite / add-user flow. There is no public + registration endpoint to disable — it is not exposed at all — so no toggle + needs hardening here. ### Configuration Hardening diff --git a/backend/integration/crowdsec_lapi_integration_test.go b/backend/integration/crowdsec_lapi_integration_test.go index a98fb7e22..615b66c51 100644 --- a/backend/integration/crowdsec_lapi_integration_test.go +++ b/backend/integration/crowdsec_lapi_integration_test.go @@ -45,18 +45,20 @@ func newTestConfig() *testConfig { } } -// authenticate registers and logs in to get session cookies. +// authenticate bootstraps the first admin (if setup is still required) and logs +// in to get session cookies. func (tc *testConfig) authenticate(t *testing.T) error { t.Helper() - // Register (may fail if user exists - that's OK) - registerPayload := map[string]string{ + // Bootstrap the first admin via /setup (idempotent for our purposes: a + // "setup already completed" 403 just means the admin already exists). + setupPayload := map[string]string{ "email": "lapi-test@example.local", "password": "testpassword123", "name": "LAPI Tester", } - payloadBytes, _ := json.Marshal(registerPayload) - _, _ = tc.Client.Post(tc.BaseURL+"/api/v1/auth/register", "application/json", bytes.NewReader(payloadBytes)) + payloadBytes, _ := json.Marshal(setupPayload) + _, _ = tc.Client.Post(tc.BaseURL+"/api/v1/setup", "application/json", bytes.NewReader(payloadBytes)) // Login loginPayload := map[string]string{ diff --git a/backend/internal/api/handlers/additional_coverage_test.go b/backend/internal/api/handlers/additional_coverage_test.go index 83eaa0288..d1a98dafe 100644 --- a/backend/internal/api/handlers/additional_coverage_test.go +++ b/backend/internal/api/handlers/additional_coverage_test.go @@ -705,32 +705,6 @@ func TestRemoteServerHandler_TestConnectionCustom_Unreachable2(t *testing.T) { assert.Contains(t, w.Body.String(), `"reachable":false`) } -// Auth Handler Register error paths - -func setupAuthCoverageDB(t *testing.T) *gorm.DB { - t.Helper() - db := OpenTestDB(t) - _ = db.AutoMigrate(&models.User{}, &models.Setting{}) - return db -} - -func TestAuthHandler_Register_InvalidJSON(t *testing.T) { - db := setupAuthCoverageDB(t) - - cfg := config.Config{JWTSecret: "test-secret"} - authService := services.NewAuthService(db, cfg) - h := NewAuthHandler(authService, nil) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("POST", "/register", bytes.NewBufferString("invalid")) - c.Request.Header.Set("Content-Type", "application/json") - - h.Register(c) - - assert.Equal(t, 400, w.Code) -} - // Health handler coverage func TestHealthHandler_Basic(t *testing.T) { diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 7e4770efc..32b09b3e5 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -235,28 +235,6 @@ func (h *AuthHandler) Login(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"token": token}) } -type RegisterRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` - Name string `json:"name" binding:"required"` -} - -func (h *AuthHandler) Register(c *gin.Context) { - var req RegisterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - user, err := h.authService.Register(req.Email, req.Password, req.Name) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, user) -} - func (h *AuthHandler) Logout(c *gin.Context) { if userIDValue, exists := c.Get("userID"); exists { if userID, ok := userIDValue.(uint); ok && userID > 0 { diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 749042f28..7f97b8704 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -704,50 +704,6 @@ func TestAuthHandler_Login_Errors(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) } -func TestAuthHandler_Register(t *testing.T) { - t.Parallel() - handler, _ := setupAuthHandler(t) - - r := gin.New() - r.POST("/register", handler.Register) - - body := map[string]string{ - "email": "new@example.com", - "password": "password123", - "name": "New User", - } - jsonBody, _ := json.Marshal(body) - req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - assert.Contains(t, w.Body.String(), "new@example.com") -} - -func TestAuthHandler_Register_Duplicate(t *testing.T) { - t.Parallel() - handler, db := setupAuthHandler(t) - db.Create(&models.User{UUID: uuid.NewString(), Email: "dup@example.com", Name: "Dup"}) - - r := gin.New() - r.POST("/register", handler.Register) - - body := map[string]string{ - "email": "dup@example.com", - "password": "password123", - "name": "Dup User", - } - jsonBody, _ := json.Marshal(body) - req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusInternalServerError, w.Code) -} - func TestAuthHandler_Logout(t *testing.T) { t.Parallel() handler, _ := setupAuthHandler(t) @@ -1691,21 +1647,6 @@ func TestAuthHandler_Refresh_Unauthorized(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, res.Code) } -func TestAuthHandler_Register_BadRequest(t *testing.T) { - t.Parallel() - - handler, _ := setupAuthHandler(t) - r := gin.New() - r.POST("/register", handler.Register) - - req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBufferString("not-json")) - req.Header.Set("Content-Type", "application/json") - res := httptest.NewRecorder() - r.ServeHTTP(res, req) - - assert.Equal(t, http.StatusBadRequest, res.Code) -} - func TestAuthHandler_Logout_InvalidateSessionsFailure(t *testing.T) { t.Parallel() diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go index 2f71217fb..e607dd1c2 100644 --- a/backend/internal/api/handlers/handlers_test.go +++ b/backend/internal/api/handlers/handlers_test.go @@ -52,7 +52,7 @@ func TestRemoteServerHandler_List(t *testing.T) { ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() - handler.RegisterRoutes(router.Group("/api/v1")) + handler.RegisterRoutes(router.Group("/api/v1"), router.Group("/api/v1")) // Test List w := httptest.NewRecorder() @@ -75,7 +75,7 @@ func TestRemoteServerHandler_Create(t *testing.T) { ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() - handler.RegisterRoutes(router.Group("/api/v1")) + handler.RegisterRoutes(router.Group("/api/v1"), router.Group("/api/v1")) // Test Create serverData := map[string]any{ @@ -119,7 +119,7 @@ func TestRemoteServerHandler_TestConnection(t *testing.T) { ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() - handler.RegisterRoutes(router.Group("/api/v1")) + handler.RegisterRoutes(router.Group("/api/v1"), router.Group("/api/v1")) // Test connection w := httptest.NewRecorder() @@ -153,7 +153,7 @@ func TestRemoteServerHandler_Get(t *testing.T) { ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() - handler.RegisterRoutes(router.Group("/api/v1")) + handler.RegisterRoutes(router.Group("/api/v1"), router.Group("/api/v1")) // Test Get w := httptest.NewRecorder() @@ -186,7 +186,7 @@ func TestRemoteServerHandler_Update(t *testing.T) { ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() - handler.RegisterRoutes(router.Group("/api/v1")) + handler.RegisterRoutes(router.Group("/api/v1"), router.Group("/api/v1")) // Test Update updateData := map[string]any{ @@ -231,7 +231,7 @@ func TestRemoteServerHandler_Delete(t *testing.T) { ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() - handler.RegisterRoutes(router.Group("/api/v1")) + handler.RegisterRoutes(router.Group("/api/v1"), router.Group("/api/v1")) // Test Delete w := httptest.NewRecorder() @@ -400,7 +400,7 @@ func TestRemoteServerHandler_Errors(t *testing.T) { ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() - handler.RegisterRoutes(router.Group("/api/v1")) + handler.RegisterRoutes(router.Group("/api/v1"), router.Group("/api/v1")) // Get non-existent w := httptest.NewRecorder() diff --git a/backend/internal/api/handlers/hecate_handler.go b/backend/internal/api/handlers/hecate_handler.go index c3d6a6ff8..7886feddd 100644 --- a/backend/internal/api/handlers/hecate_handler.go +++ b/backend/internal/api/handlers/hecate_handler.go @@ -25,25 +25,31 @@ func NewHecateHandler(svc *services.HecateService) *HecateHandler { return &HecateHandler{svc: svc} } -// RegisterRoutes wires all Hecate management routes onto the given router group. -func (h *HecateHandler) RegisterRoutes(rg *gin.RouterGroup) { - rg.GET("/hecate/status", h.GetStatus) - rg.GET("/hecate/tunnels", h.List) - rg.POST("/hecate/tunnels", h.Create) - rg.GET("/hecate/tunnels/:uuid", h.Get) - rg.PUT("/hecate/tunnels/:uuid", h.Update) - rg.DELETE("/hecate/tunnels/:uuid", h.Delete) - rg.POST("/hecate/tunnels/:uuid/start", h.Start) - rg.POST("/hecate/tunnels/:uuid/stop", h.Stop) - rg.POST("/hecate/tunnels/:uuid/rotate-credentials", h.RotateCredentials) - rg.GET("/hecate/cloudflare/tunnels", h.ListCloudflareTunnels) - rg.GET("/hecate/tunnels/:uuid/config/cloudflared", h.GetCloudflaredConfig) - rg.GET("/hecate/tailscale/devices", h.ListTailscaleDevices) - rg.POST("/hecate/tailscale/sync", h.SyncTailscale) - rg.GET("/hecate/zerotier/networks", h.ListZeroTierNetworks) - rg.GET("/hecate/zerotier/networks/:network_id/members", h.ListZeroTierMembers) - rg.GET("/hecate/netbird/peers", h.ListNetBirdPeers) - rg.POST("/hecate/netbird/sync", h.SyncNetBird) +// RegisterRoutes wires the Hecate endpoints. Read-only status/list endpoints +// that back role=user-reachable screens (the Dashboard hecate widget and the +// proxy-host create/edit flow) are registered on read; everything that mutates +// tunnels or exposes tunnel-provider credentials / network topology is +// registered on admin (deny-by-default for role=user). Callers that do not +// need the split may pass the same group for both. +func (h *HecateHandler) RegisterRoutes(read, admin *gin.RouterGroup) { + read.GET("/hecate/status", h.GetStatus) + read.GET("/hecate/tunnels", h.List) + read.GET("/hecate/tunnels/:uuid", h.Get) + + admin.POST("/hecate/tunnels", h.Create) + admin.PUT("/hecate/tunnels/:uuid", h.Update) + admin.DELETE("/hecate/tunnels/:uuid", h.Delete) + admin.POST("/hecate/tunnels/:uuid/start", h.Start) + admin.POST("/hecate/tunnels/:uuid/stop", h.Stop) + admin.POST("/hecate/tunnels/:uuid/rotate-credentials", h.RotateCredentials) + admin.GET("/hecate/cloudflare/tunnels", h.ListCloudflareTunnels) + admin.GET("/hecate/tunnels/:uuid/config/cloudflared", h.GetCloudflaredConfig) + admin.GET("/hecate/tailscale/devices", h.ListTailscaleDevices) + admin.POST("/hecate/tailscale/sync", h.SyncTailscale) + admin.GET("/hecate/zerotier/networks", h.ListZeroTierNetworks) + admin.GET("/hecate/zerotier/networks/:network_id/members", h.ListZeroTierMembers) + admin.GET("/hecate/netbird/peers", h.ListNetBirdPeers) + admin.POST("/hecate/netbird/sync", h.SyncNetBird) } // GetStatus returns the runtime status of all managed tunnels. diff --git a/backend/internal/api/handlers/hecate_handler_test.go b/backend/internal/api/handlers/hecate_handler_test.go index 31d44d952..b1db7a6cc 100644 --- a/backend/internal/api/handlers/hecate_handler_test.go +++ b/backend/internal/api/handlers/hecate_handler_test.go @@ -526,7 +526,7 @@ func TestHecateHandler_RegisterRoutes(t *testing.T) { r := gin.New() group := r.Group("/management") - h.RegisterRoutes(group) + h.RegisterRoutes(group, group) routes := r.Routes() paths := make(map[string]bool) diff --git a/backend/internal/api/handlers/orthrus_handler.go b/backend/internal/api/handlers/orthrus_handler.go index 1514c5c95..7d69539b6 100644 --- a/backend/internal/api/handlers/orthrus_handler.go +++ b/backend/internal/api/handlers/orthrus_handler.go @@ -45,7 +45,7 @@ func NewOrthrusHandler(orthrsuSvc *services.OrthrusService, securityService *ser func (h *OrthrusHandler) SetProxyResolver(r orthrusProxyStatusResolver) { if r != nil { rv := reflect.ValueOf(r) - if rv.Kind() == reflect.Ptr && rv.IsNil() { + if rv.Kind() == reflect.Pointer && rv.IsNil() { h.proxyResolver = nil return } @@ -53,16 +53,23 @@ func (h *OrthrusHandler) SetProxyResolver(r orthrusProxyStatusResolver) { h.proxyResolver = r } -// RegisterRoutes wires all Orthrus management routes onto the given router group. -func (h *OrthrusHandler) RegisterRoutes(rg *gin.RouterGroup) { - rg.GET("/orthrus/agents", h.List) - rg.POST("/orthrus/agents", h.Provision) - rg.GET("/orthrus/agents/:uuid", h.Get) - rg.PATCH("/orthrus/agents/:uuid", h.Patch) - rg.DELETE("/orthrus/agents/:uuid", h.Delete) - rg.POST("/orthrus/agents/:uuid/revoke", h.Revoke) - rg.GET("/orthrus/agents/:uuid/snippets", h.GetInstallSnippets) - rg.GET("/orthrus/agents/:uuid/proxy-status", h.GetProxyStatus) +// RegisterRoutes wires the Orthrus agent endpoints. The agent list/summary +// reads that back the role=user-reachable proxy-host create/edit flow are +// registered on read; agent provisioning, mutation, revocation and the +// detail endpoints (install snippets embed a bootstrap token; proxy-status +// exposes agent internals) are registered on admin (deny-by-default for +// role=user). Callers that do not need the split may pass the same group for +// both. +func (h *OrthrusHandler) RegisterRoutes(read, admin *gin.RouterGroup) { + read.GET("/orthrus/agents", h.List) + read.GET("/orthrus/agents/:uuid", h.Get) + + admin.POST("/orthrus/agents", h.Provision) + admin.PATCH("/orthrus/agents/:uuid", h.Patch) + admin.DELETE("/orthrus/agents/:uuid", h.Delete) + admin.POST("/orthrus/agents/:uuid/revoke", h.Revoke) + admin.GET("/orthrus/agents/:uuid/snippets", h.GetInstallSnippets) + admin.GET("/orthrus/agents/:uuid/proxy-status", h.GetProxyStatus) } // List returns all registered Orthrus agents. diff --git a/backend/internal/api/handlers/orthrus_handler_test.go b/backend/internal/api/handlers/orthrus_handler_test.go index 15e5bf698..64cdf5515 100644 --- a/backend/internal/api/handlers/orthrus_handler_test.go +++ b/backend/internal/api/handlers/orthrus_handler_test.go @@ -504,7 +504,7 @@ func TestOrthrusHandler_RegisterRoutes(t *testing.T) { r := gin.New() group := r.Group("/management") - h.RegisterRoutes(group) + h.RegisterRoutes(group, group) routes := r.Routes() paths := make(map[string]bool) diff --git a/backend/internal/api/handlers/remote_server_handler.go b/backend/internal/api/handlers/remote_server_handler.go index 9163871c4..5afbcaf1c 100644 --- a/backend/internal/api/handlers/remote_server_handler.go +++ b/backend/internal/api/handlers/remote_server_handler.go @@ -38,15 +38,21 @@ func (h *RemoteServerHandler) SetUptimeService(u *services.UptimeService) { h.uptimeService = u } -// RegisterRoutes registers remote server routes. -func (h *RemoteServerHandler) RegisterRoutes(router *gin.RouterGroup) { - router.GET("/remote-servers", h.List) - router.POST("/remote-servers", h.Create) - router.GET("/remote-servers/:uuid", h.Get) - router.PUT("/remote-servers/:uuid", h.Update) - router.DELETE("/remote-servers/:uuid", h.Delete) - router.POST("/remote-servers/test", h.TestConnectionCustom) - router.POST("/remote-servers/:uuid/test", h.TestConnection) +// RegisterRoutes wires the remote-server endpoints. The list/detail reads that +// back the role=user-reachable proxy-host create/edit flow and the Remote +// Servers page are registered on read; create/update/delete and the SSH +// connection-test endpoints (they act on stored SSH targets and credentials) +// are registered on admin (deny-by-default for role=user). Callers that do not +// need the split may pass the same group for both. +func (h *RemoteServerHandler) RegisterRoutes(read, admin *gin.RouterGroup) { + read.GET("/remote-servers", h.List) + read.GET("/remote-servers/:uuid", h.Get) + + admin.POST("/remote-servers", h.Create) + admin.PUT("/remote-servers/:uuid", h.Update) + admin.DELETE("/remote-servers/:uuid", h.Delete) + admin.POST("/remote-servers/test", h.TestConnectionCustom) + admin.POST("/remote-servers/:uuid/test", h.TestConnection) } // List retrieves all remote servers. diff --git a/backend/internal/api/handlers/security_headers_handler.go b/backend/internal/api/handlers/security_headers_handler.go index 6b397ca71..0401fd5ec 100644 --- a/backend/internal/api/handlers/security_headers_handler.go +++ b/backend/internal/api/handlers/security_headers_handler.go @@ -31,24 +31,6 @@ func NewSecurityHeadersHandler(db *gorm.DB, caddyManager *caddy.Manager) *Securi } } -// RegisterRoutes registers all security headers routes -func (h *SecurityHeadersHandler) RegisterRoutes(router *gin.RouterGroup) { - group := router.Group("/security/headers") - group.GET("/profiles", h.ListProfiles) - group.GET("/profiles/:id", h.GetProfile) - group.POST("/profiles", h.CreateProfile) - group.PUT("/profiles/:id", h.UpdateProfile) - group.DELETE("/profiles/:id", h.DeleteProfile) - - group.GET("/presets", h.GetPresets) - group.POST("/presets/apply", h.ApplyPreset) - - group.POST("/score", h.CalculateScore) - - group.POST("/csp/validate", h.ValidateCSP) - group.POST("/csp/build", h.BuildCSP) -} - // ListProfiles returns all security header profiles // GET /api/v1/security/headers/profiles func (h *SecurityHeadersHandler) ListProfiles(c *gin.Context) { diff --git a/backend/internal/api/handlers/security_headers_handler_test.go b/backend/internal/api/handlers/security_headers_handler_test.go index 441be0799..12e5b520c 100644 --- a/backend/internal/api/handlers/security_headers_handler_test.go +++ b/backend/internal/api/handlers/security_headers_handler_test.go @@ -16,6 +16,24 @@ import ( "gorm.io/gorm" ) +// registerSecurityHeadersRoutesForTest wires every security-headers route onto +// a single group for handler-level tests. Production wiring (routes.go) splits +// these across the management / managementAdmin groups; these tests exercise +// handler behavior, not the authorization split, so a single group is fine. +func registerSecurityHeadersRoutesForTest(rg *gin.RouterGroup, h *SecurityHeadersHandler) { + group := rg.Group("/security/headers") + group.GET("/profiles", h.ListProfiles) + group.GET("/profiles/:id", h.GetProfile) + group.POST("/profiles", h.CreateProfile) + group.PUT("/profiles/:id", h.UpdateProfile) + group.DELETE("/profiles/:id", h.DeleteProfile) + group.GET("/presets", h.GetPresets) + group.POST("/presets/apply", h.ApplyPreset) + group.POST("/score", h.CalculateScore) + group.POST("/csp/validate", h.ValidateCSP) + group.POST("/csp/build", h.BuildCSP) +} + func setupSecurityHeadersTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) assert.NoError(t, err) @@ -26,7 +44,7 @@ func setupSecurityHeadersTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { router := gin.New() handler := NewSecurityHeadersHandler(db, nil) - handler.RegisterRoutes(router.Group("/")) + registerSecurityHeadersRoutesForTest(router.Group("/"), handler) return router, db } @@ -640,7 +658,7 @@ func TestUpdateProfile_LookupDBError(t *testing.T) { router := gin.New() handler := NewSecurityHeadersHandler(db, nil) - handler.RegisterRoutes(router.Group("/")) + registerSecurityHeadersRoutesForTest(router.Group("/"), handler) // Close DB before making request sqlDB, _ := db.DB() @@ -686,7 +704,7 @@ func TestDeleteProfile_LookupDBError(t *testing.T) { router := gin.New() handler := NewSecurityHeadersHandler(db, nil) - handler.RegisterRoutes(router.Group("/")) + registerSecurityHeadersRoutesForTest(router.Group("/"), handler) // Close DB before making request sqlDB, _ := db.DB() @@ -716,7 +734,7 @@ func TestDeleteProfile_CountDBError(t *testing.T) { router := gin.New() handler := NewSecurityHeadersHandler(db, nil) - handler.RegisterRoutes(router.Group("/")) + registerSecurityHeadersRoutesForTest(router.Group("/"), handler) req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/security/headers/profiles/%d", profile.ID), http.NoBody) w := httptest.NewRecorder() @@ -741,7 +759,7 @@ func TestDeleteProfile_DeleteDBError(t *testing.T) { router := gin.New() handler := NewSecurityHeadersHandler(db, nil) - handler.RegisterRoutes(router.Group("/")) + registerSecurityHeadersRoutesForTest(router.Group("/"), handler) // Close DB before delete to simulate DB error sqlDB, _ := db.DB() @@ -850,7 +868,7 @@ func TestGetProfile_UUID_DBError_NonNotFound(t *testing.T) { router := gin.New() handler := NewSecurityHeadersHandler(db, nil) - handler.RegisterRoutes(router.Group("/")) + registerSecurityHeadersRoutesForTest(router.Group("/"), handler) // Close DB to force a non-NotFound error sqlDB, _ := db.DB() @@ -899,7 +917,7 @@ func TestUpdateProfile_SaveError(t *testing.T) { router := gin.New() handler := NewSecurityHeadersHandler(db, nil) - handler.RegisterRoutes(router.Group("/")) + registerSecurityHeadersRoutesForTest(router.Group("/"), handler) // Close DB after profile is created - this will cause the First() to fail // when trying to find the profile. However, to specifically test Save() error, diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 17a832bfb..025e692b2 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -2819,6 +2819,66 @@ func TestUserHandler_UpdateUser_NonAdminSelfRoleChange(t *testing.T) { assert.Contains(t, w.Body.String(), "Cannot modify role or enabled status") } +// TestUserHandler_UpdateUser_NonAdminSelfCannotEscalatePrivilegedFields is a +// regression guard for the self-service path: PUT /api/v1/users/:id is on the +// userOKMutationAllowlist (legit self-service of name/password), so +// TestManagementGroup_MutationsAreAdminGuarded deliberately does not cover it. +// This asserts the persisted record — not just the HTTP status — so a future +// refactor that stops returning 403 and instead silently drops the field would +// still be caught. A role=user editing their OWN record with privileged fields +// (role, enabled) must not change those fields, and a role=user editing a +// DIFFERENT user's record must be rejected outright with 403. +func TestUserHandler_UpdateUser_NonAdminSelfCannotEscalatePrivilegedFields(t *testing.T) { + handler, db := setupUserHandler(t) + + self := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "noesc-self@example.com", Role: models.RoleUser, Enabled: true} + require.NoError(t, db.Create(&self).Error) + other := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "noesc-other@example.com", Name: "Other", Role: models.RoleUser, Enabled: true} + require.NoError(t, db.Create(&other).Error) + + newRouter := func(actingUserID uint) *gin.Engine { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Set("userID", actingUserID) + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + return r + } + + // Case 1: acting on OWN record with privileged fields in the body. The + // non-admin branch rejects the request before any field is applied, so the + // persisted role stays RoleUser and enabled stays as seeded. + body, _ := json.Marshal(map[string]any{"role": "admin", "enabled": false}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", self.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + newRouter(self.ID).ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + + var persistedSelf models.User + require.NoError(t, db.First(&persistedSelf, self.ID).Error) + assert.Equal(t, models.RoleUser, persistedSelf.Role, "role must not escalate via self-service update") + assert.True(t, persistedSelf.Enabled, "enabled must not be flipped via self-service update") + + // Case 2: acting on a DIFFERENT user's record is rejected outright. + body2, _ := json.Marshal(map[string]any{"name": "hijacked"}) + req2 := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", other.ID), bytes.NewBuffer(body2)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + newRouter(self.ID).ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusForbidden, w2.Code) + assert.Contains(t, w2.Body.String(), "Admin access required") + + var persistedOther models.User + require.NoError(t, db.First(&persistedOther, other.ID).Error) + assert.Equal(t, "noesc-other@example.com", persistedOther.Email) + assert.NotEqual(t, "hijacked", persistedOther.Name) +} + // --- UpdateUser invalid role string --- func TestUserHandler_UpdateUser_InvalidRole(t *testing.T) { diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 70f0d2db7..3322a979f 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -292,7 +292,6 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg ) api.POST("/auth/login", authHandler.Login) - api.POST("/auth/register", authHandler.Register) // Forward auth endpoint for Caddy (public, validates session internally) api.GET("/auth/verify", authHandler.Verify) @@ -373,6 +372,14 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg management := protected.Group("/") management.Use(middleware.RequireManagementAccess()) + // managementAdmin — admin-only sibling of the management group, for + // routes that mutate or expose privileged infrastructure. Mirrors the + // securityAdmin / authenticatedAdmin idiom; the subgroup middleware is + // the only guard (no redundant in-handler role checks). Reused by + // subsequent management-API hardening work. + managementAdmin := management.Group("/") + managementAdmin.Use(middleware.RequireRole(models.RoleAdmin)) + // Backups. Static routes (settings, remote-targets, upload, jobs) are // registered alongside the pre-existing /:filename[...] wildcard // routes — see routes_backup_test.go for the required regression @@ -421,30 +428,35 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg // System permissions diagnostics and repair systemPermissionsHandler := handlers.NewSystemPermissionsHandler(cfg, securityService, nil) management.GET("/system/permissions", systemPermissionsHandler.GetPermissions) - management.POST("/system/permissions/repair", systemPermissionsHandler.RepairPermissions) + management.POST("/system/permissions/repair", middleware.RequireRole(models.RoleAdmin), systemPermissionsHandler.RepairPermissions) // Audit Logs auditLogHandler := handlers.NewAuditLogHandler(securityService) - management.GET("/audit-logs", auditLogHandler.List) - management.GET("/audit-logs/:uuid", auditLogHandler.Get) + // Audit records expose other users' emails, source IPs and + // security-event detail — admin-only (info disclosure to a + // lower-privilege role otherwise). + managementAdmin.GET("/audit-logs", auditLogHandler.List) + managementAdmin.GET("/audit-logs/:uuid", auditLogHandler.Get) // Settings - with CaddyManager and Cerberus for security settings reload settingsHandler := handlers.NewSettingsHandlerWithDeps(db, caddyManager, cerb, securityService, dataRoot) + // Settings reads load for every role; every mutation is admin-only + // (per-route guard here + belt-and-braces in-handler checks retained). management.GET("/settings", settingsHandler.GetSettings) - management.POST("/settings", settingsHandler.UpdateSetting) - management.PATCH("/settings", settingsHandler.UpdateSetting) // E2E tests use PATCH - management.PATCH("/config", settingsHandler.PatchConfig) // Bulk configuration update + management.POST("/settings", middleware.RequireRole(models.RoleAdmin), settingsHandler.UpdateSetting) + management.PATCH("/settings", middleware.RequireRole(models.RoleAdmin), settingsHandler.UpdateSetting) // E2E tests use PATCH + management.PATCH("/config", middleware.RequireRole(models.RoleAdmin), settingsHandler.PatchConfig) // Bulk configuration update // Logo upload/delete — admin only logoHandler := handlers.NewLogoHandler(db, dataRoot) - management.POST("/settings/logo", logoHandler.UploadLogo) - management.DELETE("/settings/logo", logoHandler.DeleteLogo) + management.POST("/settings/logo", middleware.RequireRole(models.RoleAdmin), logoHandler.UploadLogo) + management.DELETE("/settings/logo", middleware.RequireRole(models.RoleAdmin), logoHandler.DeleteLogo) // Banner upload/delete — admin only (enforced inside ImageUploadHandler) bannerHandler := handlers.NewBannerHandler(db, dataRoot) - management.POST("/settings/banner", bannerHandler.UploadBanner) - management.DELETE("/settings/banner", bannerHandler.DeleteBanner) + management.POST("/settings/banner", middleware.RequireRole(models.RoleAdmin), bannerHandler.UploadBanner) + management.DELETE("/settings/banner", middleware.RequireRole(models.RoleAdmin), bannerHandler.DeleteBanner) // User-created named themes — available to all management users (not admin-only) themeHandler := handlers.NewCustomThemeHandler(db) @@ -455,18 +467,18 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg // SMTP Configuration management.GET("/settings/smtp", middleware.RequireRole(models.RoleAdmin), settingsHandler.GetSMTPConfig) - management.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig) - management.POST("/settings/smtp/test", settingsHandler.TestSMTPConfig) - management.POST("/settings/smtp/test-email", settingsHandler.SendTestEmail) + management.POST("/settings/smtp", middleware.RequireRole(models.RoleAdmin), settingsHandler.UpdateSMTPConfig) + management.POST("/settings/smtp/test", middleware.RequireRole(models.RoleAdmin), settingsHandler.TestSMTPConfig) + management.POST("/settings/smtp/test-email", middleware.RequireRole(models.RoleAdmin), settingsHandler.SendTestEmail) // URL Validation - management.POST("/settings/validate-url", settingsHandler.ValidatePublicURL) - management.POST("/settings/test-url", settingsHandler.TestPublicURL) + management.POST("/settings/validate-url", middleware.RequireRole(models.RoleAdmin), settingsHandler.ValidatePublicURL) + management.POST("/settings/test-url", middleware.RequireRole(models.RoleAdmin), settingsHandler.TestPublicURL) // Feature flags (DB-backed with env fallback) featureFlagsHandler := handlers.NewFeatureFlagsHandler(db) management.GET("/feature-flags", featureFlagsHandler.GetFlags) - management.PUT("/feature-flags", featureFlagsHandler.UpdateFlags) + management.PUT("/feature-flags", middleware.RequireRole(models.RoleAdmin), featureFlagsHandler.UpdateFlags) // User Management (admin only routes are in RegisterRoutes) management.GET("/users", userHandler.ListUsers) @@ -497,8 +509,8 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg // Domains domainHandler := handlers.NewDomainHandler(db, notificationService) management.GET("/domains", domainHandler.List) - management.POST("/domains", domainHandler.Create) - management.DELETE("/domains/:id", domainHandler.Delete) + management.POST("/domains", middleware.RequireRole(models.RoleAdmin), domainHandler.Create) + management.DELETE("/domains/:id", middleware.RequireRole(models.RoleAdmin), domainHandler.Delete) // DNS Providers - only available if encryption key is configured var orthrusServer *orthrus.OrthrusServer @@ -509,33 +521,37 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg } else { dnsProviderService := services.NewDNSProviderService(db, encryptionService) dnsProviderHandler := handlers.NewDNSProviderHandler(dnsProviderService) + // DNS provider reads back the role=user-reachable DNSProviders + // page; every mutation and the credential-test endpoints + // (DNS API credentials + ACME control) are admin-only. + adminRole := middleware.RequireRole(models.RoleAdmin) management.GET("/dns-providers", dnsProviderHandler.List) - management.POST("/dns-providers", dnsProviderHandler.Create) + management.POST("/dns-providers", adminRole, dnsProviderHandler.Create) management.GET("/dns-providers/types", dnsProviderHandler.GetTypes) management.GET("/dns-providers/:id", dnsProviderHandler.Get) - management.PUT("/dns-providers/:id", dnsProviderHandler.Update) - management.DELETE("/dns-providers/:id", dnsProviderHandler.Delete) - management.POST("/dns-providers/:id/test", dnsProviderHandler.Test) - management.POST("/dns-providers/test", dnsProviderHandler.TestCredentials) - // Audit logs for DNS providers - management.GET("/dns-providers/:id/audit-logs", auditLogHandler.ListByProvider) + management.PUT("/dns-providers/:id", adminRole, dnsProviderHandler.Update) + management.DELETE("/dns-providers/:id", adminRole, dnsProviderHandler.Delete) + management.POST("/dns-providers/:id/test", adminRole, dnsProviderHandler.Test) + management.POST("/dns-providers/test", adminRole, dnsProviderHandler.TestCredentials) + // Audit logs for DNS providers — actor PII, admin-only. + managementAdmin.GET("/dns-providers/:id/audit-logs", auditLogHandler.ListByProvider) // DNS Provider Auto-Detection (Phase 4) dnsDetectionService := services.NewDNSDetectionService(db) dnsDetectionHandler := handlers.NewDNSDetectionHandler(dnsDetectionService) - management.POST("/dns-providers/detect", dnsDetectionHandler.Detect) + management.POST("/dns-providers/detect", adminRole, dnsDetectionHandler.Detect) management.GET("/dns-providers/detection-patterns", dnsDetectionHandler.GetPatterns) // Multi-Credential Management (Phase 3) credentialService := services.NewCredentialService(db, encryptionService) credentialHandler := handlers.NewCredentialHandler(credentialService) management.GET("/dns-providers/:id/credentials", credentialHandler.List) - management.POST("/dns-providers/:id/credentials", credentialHandler.Create) + management.POST("/dns-providers/:id/credentials", adminRole, credentialHandler.Create) management.GET("/dns-providers/:id/credentials/:cred_id", credentialHandler.Get) - management.PUT("/dns-providers/:id/credentials/:cred_id", credentialHandler.Update) - management.DELETE("/dns-providers/:id/credentials/:cred_id", credentialHandler.Delete) - management.POST("/dns-providers/:id/credentials/:cred_id/test", credentialHandler.Test) - management.POST("/dns-providers/:id/enable-multi-credentials", credentialHandler.EnableMultiCredentials) + management.PUT("/dns-providers/:id/credentials/:cred_id", adminRole, credentialHandler.Update) + management.DELETE("/dns-providers/:id/credentials/:cred_id", adminRole, credentialHandler.Delete) + management.POST("/dns-providers/:id/credentials/:cred_id/test", adminRole, credentialHandler.Test) + management.POST("/dns-providers/:id/enable-multi-credentials", adminRole, credentialHandler.EnableMultiCredentials) // Encryption Management - Admin only endpoints rotationService, rotErr := crypto.NewRotationService(db) @@ -543,7 +559,10 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg logger.Log().WithError(rotErr).Warn("Failed to initialize rotation service - key rotation features will be unavailable") } else { encryptionHandler := handlers.NewEncryptionHandler(rotationService, securityService) - adminEncryption := management.Group("/admin/encryption") + // Derive from managementAdmin for defense-in-depth: the + // handlers already call isAdmin(c), but the subgroup guard + // removes the "silent 200 if that check is ever dropped" risk. + adminEncryption := managementAdmin.Group("/admin/encryption") adminEncryption.GET("/status", encryptionHandler.GetStatus) adminEncryption.POST("/rotate", encryptionHandler.Rotate) adminEncryption.GET("/history", encryptionHandler.GetHistory) @@ -560,14 +579,18 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg adminPlugins := management.Group("/admin/plugins") adminPlugins.GET("", pluginHandler.ListPlugins) adminPlugins.GET("/:id", pluginHandler.GetPlugin) - adminPlugins.POST("/:id/enable", pluginHandler.EnablePlugin) - adminPlugins.POST("/:id/disable", pluginHandler.DisablePlugin) - adminPlugins.POST("/reload", pluginHandler.ReloadPlugins) + // Mutations are admin-only (deny-by-default). Listing stays on + // management so the role=user-reachable /dns/plugins page loads. + adminPlugins.POST("/:id/enable", middleware.RequireRole(models.RoleAdmin), pluginHandler.EnablePlugin) + adminPlugins.POST("/:id/disable", middleware.RequireRole(models.RoleAdmin), pluginHandler.DisablePlugin) + adminPlugins.POST("/reload", middleware.RequireRole(models.RoleAdmin), pluginHandler.ReloadPlugins) // Manual DNS Challenges (Phase 1) - For users without automated DNS API access manualChallengeService := services.NewManualChallengeService(db) manualChallengeHandler := handlers.NewManualChallengeHandler(manualChallengeService, dnsProviderService) - manualChallengeHandler.RegisterRoutes(management) + // All manual-challenge routes are provider-mutation-adjacent + // (ACME control) with no role=user read need — admin-only. + manualChallengeHandler.RegisterRoutes(managementAdmin) // Hecate Tunnel & Pathway Manager tunnelMgr := hecate.NewTunnelManager(db, encryptionService) @@ -594,10 +617,10 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg orthrsuSvc := services.NewOrthrusService(db, orthrusServer) hecateHandler := handlers.NewHecateHandler(hecateSvc) - hecateHandler.RegisterRoutes(management) + hecateHandler.RegisterRoutes(management, managementAdmin) orthrusHandler := handlers.NewOrthrusHandler(orthrsuSvc, securityService) - orthrusHandler.RegisterRoutes(management) + orthrusHandler.RegisterRoutes(management, managementAdmin) if orthrusServer != nil { orthrusHandler.SetProxyResolver(orthrusServer) @@ -655,8 +678,10 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg management.POST("/notifications/providers", notificationProviderHandler.Create) management.PUT("/notifications/providers/:id", notificationProviderHandler.Update) management.DELETE("/notifications/providers/:id", notificationProviderHandler.Delete) - management.POST("/notifications/providers/test", notificationProviderHandler.Test) - management.POST("/notifications/providers/preview", notificationProviderHandler.Preview) + // Test/Preview send test messages / render templates with provider + // config and have no in-handler admin check — admin-only. + management.POST("/notifications/providers/test", middleware.RequireRole(models.RoleAdmin), notificationProviderHandler.Test) + management.POST("/notifications/providers/preview", middleware.RequireRole(models.RoleAdmin), notificationProviderHandler.Preview) management.GET("/notifications/templates", notificationProviderHandler.Templates) // External notification templates (saved templates for providers) @@ -665,7 +690,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg management.POST("/notifications/external-templates", notificationTemplateHandler.Create) management.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update) management.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete) - management.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview) + management.POST("/notifications/external-templates/preview", middleware.RequireRole(models.RoleAdmin), notificationTemplateHandler.Preview) // Ensure uptime feature flag exists to avoid record-not-found logs defaultUptime := models.Setting{Key: "feature.uptime.enabled", Value: "true", Type: "bool", Category: "feature"} @@ -835,7 +860,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg crowdsecExec := handlers.NewDefaultCrowdsecExecutor() crowdsecHandler := handlers.NewCrowdsecHandler(db, crowdsecExec, crowdsecBinPath, crowdsecDataDir) - crowdsecHandler.RegisterRoutes(management) + crowdsecHandler.RegisterRoutes(managementAdmin) // NOTE: CrowdSec reconciliation now happens in main.go BEFORE HTTP server starts // This ensures proper initialization order and prevents race conditions @@ -897,17 +922,33 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg if geoipSvc != nil { accessListHandler.SetGeoIPService(geoipSvc) } + // ACLs are a security control: reads + the non-persisting /:id/test + // dry-run stay on management; create/update/delete are admin-only. management.GET("/access-lists/templates", accessListHandler.GetTemplates) management.GET("/access-lists", accessListHandler.List) - management.POST("/access-lists", accessListHandler.Create) + management.POST("/access-lists", middleware.RequireRole(models.RoleAdmin), accessListHandler.Create) management.GET("/access-lists/:id", accessListHandler.Get) - management.PUT("/access-lists/:id", accessListHandler.Update) - management.DELETE("/access-lists/:id", accessListHandler.Delete) + management.PUT("/access-lists/:id", middleware.RequireRole(models.RoleAdmin), accessListHandler.Update) + management.DELETE("/access-lists/:id", middleware.RequireRole(models.RoleAdmin), accessListHandler.Delete) management.POST("/access-lists/:id/test", accessListHandler.TestIP) - // Security Headers + // Security Headers. Reads + the three pure calculator POSTs (they do + // not persist) stay on management so the role=user-reachable + // SecurityHeaders page works; profile create/update/delete and + // preset-apply are admin-only. securityHeadersHandler := handlers.NewSecurityHeadersHandler(db, caddyManager) - securityHeadersHandler.RegisterRoutes(management) + securityHeaders := management.Group("/security/headers") + securityHeaders.GET("/profiles", securityHeadersHandler.ListProfiles) + securityHeaders.GET("/profiles/:id", securityHeadersHandler.GetProfile) + securityHeaders.GET("/presets", securityHeadersHandler.GetPresets) + securityHeaders.POST("/score", securityHeadersHandler.CalculateScore) + securityHeaders.POST("/csp/validate", securityHeadersHandler.ValidateCSP) + securityHeaders.POST("/csp/build", securityHeadersHandler.BuildCSP) + securityHeadersAdmin := managementAdmin.Group("/security/headers") + securityHeadersAdmin.POST("/profiles", securityHeadersHandler.CreateProfile) + securityHeadersAdmin.PUT("/profiles/:id", securityHeadersHandler.UpdateProfile) + securityHeadersAdmin.DELETE("/profiles/:id", securityHeadersHandler.DeleteProfile) + securityHeadersAdmin.POST("/presets/apply", securityHeadersHandler.ApplyPreset) // Certificate routes // Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage @@ -932,13 +973,15 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg logger.Log().WithError(err).Warn("Failed to migrate certificate private keys") } + // Certificate reads back the role=user-reachable Certificates page; + // mutations and /export (returns private-key material) are admin-only. management.GET("/certificates", certHandler.List) - management.POST("/certificates", certHandler.Upload) - management.POST("/certificates/validate", certHandler.Validate) + management.POST("/certificates", middleware.RequireRole(models.RoleAdmin), certHandler.Upload) + management.POST("/certificates/validate", middleware.RequireRole(models.RoleAdmin), certHandler.Validate) management.GET("/certificates/:uuid", certHandler.Get) - management.PUT("/certificates/:uuid", certHandler.Update) - management.POST("/certificates/:uuid/export", certHandler.Export) - management.DELETE("/certificates/:uuid", certHandler.Delete) + management.PUT("/certificates/:uuid", middleware.RequireRole(models.RoleAdmin), certHandler.Update) + management.POST("/certificates/:uuid/export", middleware.RequireRole(models.RoleAdmin), certHandler.Export) + management.DELETE("/certificates/:uuid", middleware.RequireRole(models.RoleAdmin), certHandler.Delete) // Start certificate expiry checker warningDays := 30 @@ -961,7 +1004,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService) remoteServerHandler.SetUptimeService(uptimeService) // targeted monitor sync on CRUD (spec §3.1.3) - remoteServerHandler.RegisterRoutes(management) + remoteServerHandler.RegisterRoutes(management, managementAdmin) } // Caddy Manager already created above diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index 8ffdf8483..816ab0f15 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -12,6 +12,7 @@ import ( "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -159,7 +160,6 @@ func TestRegister_RoutesRegistration(t *testing.T) { "/api/v1/health", "/metrics", "/api/v1/auth/login", - "/api/v1/auth/register", "/api/v1/setup", } @@ -212,7 +212,6 @@ func TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist(t *testi publicMutationAllowlist := map[string]bool{ http.MethodPost + " /api/v1/auth/login": true, - http.MethodPost + " /api/v1/auth/register": true, http.MethodPost + " /api/v1/setup": true, http.MethodPost + " /api/v1/invite/accept": true, http.MethodPost + " /api/v1/security/events": true, @@ -332,7 +331,8 @@ func TestRegister_AllRoutesRegistered(t *testing.T) { // Auth routes assert.Contains(t, routeMap, "/api/v1/auth/login") - assert.Contains(t, routeMap, "/api/v1/auth/register") + // Public self-registration was removed (Part C); the route must not exist. + assert.NotContains(t, routeMap, "/api/v1/auth/register") assert.Contains(t, routeMap, "/api/v1/auth/verify") assert.Contains(t, routeMap, "/api/v1/auth/status") assert.Contains(t, routeMap, "/api/v1/auth/logout") @@ -377,6 +377,115 @@ func TestRegister_AllRoutesRegistered(t *testing.T) { assert.Greater(t, len(routes), 50, "Expected more than 50 routes to be registered") } +// TestRegister_PublicRegistrationEndpointRemoved verifies that the public +// self-registration endpoint no longer exists, while the two supported +// account-creation paths — first-admin bootstrap via /setup and the admin +// email-invite flow — keep working (spec §3.3.4). +func TestRegister_PublicRegistrationEndpointRemoved(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + // Temp-file DB (not shared-cache :memory:) so the schema and rows survive the + // connection churn from the background workers Register() starts. + dsn := "file:" + filepath.Join(t.TempDir(), "pubreg_removed.db") + "?_busy_timeout=5000" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + + cfg := config.Config{JWTSecret: "test-secret"} + require.NoError(t, Register(context.Background(), router, db, cfg)) + + call := func(method, path string, body string, token string) *httptest.ResponseRecorder { + var r io.Reader = http.NoBody + if body != "" { + r = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, r) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + // --- The public registration route is gone (any method) --- + registeredPaths := make(map[string]bool) + for _, rt := range router.Routes() { + registeredPaths[rt.Path] = true + } + assert.NotContains(t, registeredPaths, "/api/v1/auth/register", + "the /auth/register route must not be registered") + + assert.Equal(t, http.StatusNotFound, + call(http.MethodPost, "/api/v1/auth/register", + `{"email":"attacker@example.com","password":"password123","name":"Attacker"}`, "").Code, + "POST /api/v1/auth/register must 404") + assert.Equal(t, http.StatusNotFound, + call(http.MethodGet, "/api/v1/auth/register", "", "").Code, + "GET /api/v1/auth/register must 404") + + // --- First-admin bootstrap via /setup still works --- + setupStatus := call(http.MethodGet, "/api/v1/setup", "", "") + assert.Equal(t, http.StatusOK, setupStatus.Code) + assert.Contains(t, setupStatus.Body.String(), `"setupRequired":true`) + + created := call(http.MethodPost, "/api/v1/setup", + `{"name":"First Admin","email":"admin@example.com","password":"adminpassword123"}`, "") + require.Equal(t, http.StatusCreated, created.Code, "first /setup call must create the admin") + + var adminUser models.User + require.NoError(t, db.Where("email = ?", "admin@example.com").First(&adminUser).Error) + assert.Equal(t, models.RoleAdmin, adminUser.Role, "bootstrap user must be role=admin") + + var acmeEmail models.Setting + require.NoError(t, db.Where("key = ?", "caddy.acme_email").First(&acmeEmail).Error) + assert.Equal(t, "admin@example.com", acmeEmail.Value, "caddy.acme_email setting must be written") + + // --- /setup closes itself after the first admin exists --- + again := call(http.MethodPost, "/api/v1/setup", + `{"name":"Second","email":"second@example.com","password":"anotherpassword123"}`, "") + assert.Equal(t, http.StatusForbidden, again.Code) + assert.Contains(t, again.Body.String(), "Setup already completed") + + // --- The admin email-invite flow still creates a subsequent non-admin user --- + authSvc := services.NewAuthService(db, cfg) + adminToken, err := authSvc.GenerateToken(&adminUser) + require.NoError(t, err) + + invited := call(http.MethodPost, "/api/v1/users/invite", + `{"email":"invitee@example.com"}`, adminToken) + require.Equal(t, http.StatusCreated, invited.Code, "admin invite must succeed") + + var inviteeUser models.User + require.NoError(t, db.Where("email = ?", "invitee@example.com").First(&inviteeUser).Error) + require.NotEmpty(t, inviteeUser.InviteToken, "invited user must carry an invite token") + assert.Equal(t, models.RoleUser, inviteeUser.Role, "invited user defaults to role=user") + assert.False(t, inviteeUser.Enabled, "invited user is disabled until acceptance") + + validate := call(http.MethodGet, "/api/v1/invite/validate?token="+inviteeUser.InviteToken, "", "") + assert.Equal(t, http.StatusOK, validate.Code) + assert.Contains(t, validate.Body.String(), `"valid":true`) + + accept := call(http.MethodPost, "/api/v1/invite/accept", + `{"token":"`+inviteeUser.InviteToken+`","name":"Invitee","password":"inviteepassword123"}`, "") + require.Equal(t, http.StatusOK, accept.Code, "invite acceptance must succeed") + + var acceptedUser models.User + require.NoError(t, db.Where("email = ?", "invitee@example.com").First(&acceptedUser).Error) + assert.True(t, acceptedUser.Enabled, "accepted invitee must be enabled") + assert.Equal(t, models.RoleUser, acceptedUser.Role, "accepted invitee stays role=user") + + login := call(http.MethodPost, "/api/v1/auth/login", + `{"email":"invitee@example.com","password":"inviteepassword123"}`, "") + assert.Equal(t, http.StatusOK, login.Code, "invited user can log in after acceptance") +} + func TestRegister_MiddlewareApplied(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() @@ -1403,3 +1512,326 @@ func TestRegister_UptimeSummaryAndHistoryRoutesResolve(t *testing.T) { assert.NotEqualf(t, http.StatusNotFound, w.Code, "%s must resolve to a handler", path) } } + +// TestRegister_CrowdsecAdminRoutesRequireAdminRole verifies that the +// /admin/crowdsec/* routes are guarded by RequireRole(admin) (spec §3.1.4, +// advisory GHSA-3gc6-295r-xm5m). A role=user session must be rejected with a +// hard 403 on these privileged routes while still retaining access to +// genuinely user-allowed management routes; a role=admin session must reach +// the handler (any non-401/403 status is acceptable since CrowdSec is not +// running in the test). +func TestRegister_CrowdsecAdminRoutesRequireAdminRole(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_crowdsec_admin_authz"), &gorm.Config{}) + require.NoError(t, err) + + cfg := config.Config{JWTSecret: "test-secret"} + require.NoError(t, Register(context.Background(), router, db, cfg)) + + authSvc := services.NewAuthService(db, cfg) + + userAcct := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "user-crowdsec-authz@example.com", + Role: models.RoleUser, + Enabled: true, + } + require.NoError(t, db.Create(userAcct).Error) + userToken, err := authSvc.GenerateToken(userAcct) + require.NoError(t, err) + + adminAcct := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "admin-crowdsec-authz@example.com", + Role: models.RoleAdmin, + Enabled: true, + } + require.NoError(t, db.Create(adminAcct).Error) + adminToken, err := authSvc.GenerateToken(adminAcct) + require.NoError(t, err) + + do := func(method, path, token string) int { + req := httptest.NewRequest(method, path, http.NoBody) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w.Code + } + + routes := []struct { + name string + method string + path string + }{ + {"stop", http.MethodPost, "/api/v1/admin/crowdsec/stop"}, + {"bouncer key", http.MethodGet, "/api/v1/admin/crowdsec/bouncer/key"}, + {"ban", http.MethodPost, "/api/v1/admin/crowdsec/ban"}, + {"read file", http.MethodGet, "/api/v1/admin/crowdsec/file?path=acquis.yaml"}, + } + + for _, tc := range routes { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, http.StatusUnauthorized, do(tc.method, tc.path, ""), + "no token must be rejected with 401") + + assert.Equal(t, http.StatusForbidden, do(tc.method, tc.path, userToken), + "role=user must be rejected with 403 on privileged CrowdSec routes") + + adminCode := do(tc.method, tc.path, adminToken) + assert.NotEqual(t, http.StatusUnauthorized, adminCode, + "role=admin must not be rejected as unauthorized") + assert.NotEqual(t, http.StatusForbidden, adminCode, + "role=admin must reach the handler, not be forbidden") + }) + } + + // Control: the same role=user token remains valid for a genuinely + // user-allowed management route, proving the 403s above are the new + // admin guard specifically and not a broken session. + assert.NotEqual(t, http.StatusForbidden, do(http.MethodGet, "/api/v1/proxy-hosts", userToken), + "role=user must still reach user-allowed management routes") + assert.NotEqual(t, http.StatusUnauthorized, do(http.MethodGet, "/api/v1/proxy-hosts", userToken), + "role=user session token must be accepted") +} + +// --- Deny-by-default management-API authorization enforcement (spec §3.2.4) --- +// +// publicMutationAllowlist and userOKMutationAllowlist below ARE the +// deny-by-default policy for state-changing routes under /api/v1/. Every +// mutating route (POST/PUT/PATCH/DELETE) that is NOT in one of these two lists +// MUST reject a role=user caller with 403 and MUST let a role=admin caller +// through to the handler. Adding a route to either list is a deliberate, +// reviewed policy decision — each entry carries the reason it is reachable +// without admin. + +// publicMutationAllowlistEnforcement: routes that are not part of the +// authenticated management surface at all — they run their own auth scheme +// (unauthenticated bootstrap, event intake, or the emergency IP+token +// mechanism) and have no session-role semantics, so the role=user / role=admin +// dimension does not apply. +var publicMutationAllowlistEnforcement = map[string]string{ + "POST /api/v1/auth/login": "unauthenticated credential exchange", + "POST /api/v1/setup": "first-admin bootstrap on an empty DB; closes itself after first use", + "POST /api/v1/invite/accept": "invited user completes their own account before they have a session", + "POST /api/v1/security/events": "internal security-event intake (Cerberus); not a user-facing route", + "POST /api/v1/emergency/security-reset": "emergency recovery, guarded by ManagementCIDR + X-Emergency-Token, not by role", + "POST /api/v1/emergency/token/generate": "emergency-token lifecycle, guarded by ManagementCIDR + X-Emergency-Token, not by role", + "DELETE /api/v1/emergency/token": "emergency-token lifecycle, guarded by ManagementCIDR + X-Emergency-Token, not by role", + "PATCH /api/v1/emergency/token/expiration": "emergency-token lifecycle, guarded by ManagementCIDR + X-Emergency-Token, not by role", +} + +// userOKMutationAllowlist: authenticated routes deliberately reachable by +// role=user. Each is either a per-user self-service action, a non-persisting +// diagnostic/calculator, or a core role=user capability (proxy hosts/groups, +// named themes, uptime monitors) whose authorization is per-object, not +// role-based. +var userOKMutationAllowlist = map[string]string{ + // Per-user self-service (the acting user's own session / profile). + "POST /api/v1/auth/logout": "ends the caller's own session", + "POST /api/v1/auth/refresh": "refreshes the caller's own session", + "POST /api/v1/auth/change-password": "caller changes their own password", + "POST /api/v1/user/profile": "caller updates their own profile", + "POST /api/v1/user/api-key": "caller regenerates their own API key", + "POST /api/v1/changelog/ack": "caller acknowledges the changelog for themselves", + "POST /api/v1/changelog/opt-in": "caller sets their own changelog opt-in", + "PUT /api/v1/users/:id": "UpdateUser has a deliberate self-service branch (own name/password); admin-only fields are rejected in-handler", + "POST /api/v1/notifications/:id/read": "per-user inbox: mark one of the caller's notifications read", + "POST /api/v1/notifications/read-all": "per-user inbox: mark all of the caller's notifications read", + + // Core role=user capability — object-level authz (PermittedHosts / forward-auth), not role. + "POST /api/v1/proxy-hosts": "core role=user capability (per-host authz)", + "PUT /api/v1/proxy-hosts/:uuid": "core role=user capability (per-host authz)", + "DELETE /api/v1/proxy-hosts/:uuid": "core role=user capability (per-host authz)", + "POST /api/v1/proxy-hosts/test": "connection dry-run for the proxy-host form", + "PUT /api/v1/proxy-hosts/bulk-update-acl": "core role=user capability (per-host authz)", + "PUT /api/v1/proxy-hosts/bulk-update-group": "core role=user capability (per-host authz)", + "PUT /api/v1/proxy-hosts/bulk-update-security-headers": "core role=user capability (per-host authz)", + "POST /api/v1/proxy-groups": "core role=user capability", + "PUT /api/v1/proxy-groups/:uuid": "core role=user capability", + "DELETE /api/v1/proxy-groups/:uuid": "core role=user capability", + "POST /api/v1/themes": "named themes are available to all management users by design", + "PUT /api/v1/themes/:id": "named themes are available to all management users by design", + "DELETE /api/v1/themes/:id": "named themes are available to all management users by design", + + // Non-persisting calculators / diagnostics. + "POST /api/v1/security/headers/score": "pure scoring calculator, no persistence", + "POST /api/v1/security/headers/csp/validate": "pure CSP validator, no persistence", + "POST /api/v1/security/headers/csp/build": "pure CSP builder, no persistence", + "POST /api/v1/access-lists/:id/test": "non-persisting IP dry-run against an existing access list", + + // Observability — uptime monitoring is a role=user surface. + "POST /api/v1/uptime/monitors": "observability: role=user manages uptime monitors", + "PUT /api/v1/uptime/monitors/:id": "observability: role=user manages uptime monitors", + "DELETE /api/v1/uptime/monitors/:id": "observability: role=user manages uptime monitors", + "POST /api/v1/uptime/monitors/:id/check": "observability: on-demand check, no privileged state", + "POST /api/v1/uptime/sync": "observability: resync monitors from proxy hosts", + "POST /api/v1/system/uptime/check": "observability: enqueue a full uptime sweep", +} + +// adminHandlerRejectsByDesign: routes where a role=admin caller passes +// authorization and reaches the handler, but the handler itself then returns +// 401/403 for a non-authorization reason (a disabled feature, a missing +// break-glass token, or a seeded read-only preset). The role=user 403 +// assertion still applies — only the admin-side "reached the handler" +// assertion is skipped for these. +var adminHandlerRejectsByDesign = map[string]string{ + "POST /api/v1/system/permissions/repair": "403 permissions_repair_disabled unless SingleContainer + root", + "POST /api/v1/security/disable": "401 break-glass token required to disable Cerberus from non-localhost", + "PUT /api/v1/security/headers/profiles/:id": "profile id 1 is a seeded read-only preset → 403 cannot modify preset", + "DELETE /api/v1/security/headers/profiles/:id": "profile id 1 is a seeded read-only preset → 403 cannot delete preset", +} + +// TestManagementGroup_MutationsAreAdminGuarded walks every registered mutating +// route under /api/v1/ and enforces the deny-by-default policy: unless a route +// is explicitly allowlisted above, role=user must be rejected with 403 and +// role=admin must reach the handler. +// +// Scope: this test only walks state-changing methods (POST/PUT/PATCH/DELETE). +// Privileged GET/read routes are NOT exercised here — their placement is +// governed by the §3.2.2 read-route audit in docs/plans/current_spec.md, not +// by this walk. A future privileged read mistakenly mounted on the management +// group (rather than a role-scoped read group) will therefore not be caught +// automatically here; the audit is the backstop for that class. +func TestManagementGroup_MutationsAreAdminGuarded(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + // A temp-file DB (not shared-cache :memory:) so the schema and seeded users + // survive the connection churn of walking ~130 routes in one test. Single + // open connection + a busy timeout keeps the background workers Register() + // starts (stats ingester, expiry checker, uptime sync) from racing the + // request path into transient "not found" auth failures. + dsn := "file:" + filepath.Join(t.TempDir(), "mgmt_guard.db") + "?_busy_timeout=5000" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + + cfg := config.Config{ + JWTSecret: "test-secret", + EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + } + require.NoError(t, Register(context.Background(), router, db, cfg)) + + authSvc := services.NewAuthService(db, cfg) + + // High, fixed IDs: materializeRoutePath turns every ":param" into "1", so + // the admin-side probe of DELETE /users/:id hits /users/1 — keep the + // seeded accounts well clear of that so the walk can't delete its own + // credentials mid-run. + userAcct := &models.User{ + ID: 90000001, UUID: uuid.NewString(), APIKey: uuid.NewString(), + Email: "mgmt-guard-user@example.com", Role: models.RoleUser, Enabled: true, + } + require.NoError(t, db.Create(userAcct).Error) + userToken, err := authSvc.GenerateToken(userAcct) + require.NoError(t, err) + + adminAcct := &models.User{ + ID: 90000002, UUID: uuid.NewString(), APIKey: uuid.NewString(), + Email: "mgmt-guard-admin@example.com", Role: models.RoleAdmin, Enabled: true, + } + require.NoError(t, db.Create(adminAcct).Error) + adminToken, err := authSvc.GenerateToken(adminAcct) + require.NoError(t, err) + + mutating := map[string]bool{ + http.MethodPost: true, http.MethodPut: true, http.MethodPatch: true, http.MethodDelete: true, + } + + do := func(method, path, token string) int { + var body io.Reader = http.NoBody + if method != http.MethodDelete { + body = strings.NewReader("{}") + } + req := httptest.NewRequest(method, materializeRoutePath(path), body) + if method != http.MethodDelete { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w.Code + } + + seen := map[string]bool{} + for _, route := range router.Routes() { + if !strings.HasPrefix(route.Path, "/api/v1/") || !mutating[route.Method] { + continue + } + key := route.Method + " " + route.Path + seen[key] = true + + if reason, ok := publicMutationAllowlistEnforcement[key]; ok { + t.Logf("skip (public): %s — %s", key, reason) + continue + } + if reason, ok := userOKMutationAllowlist[key]; ok { + t.Logf("skip (user-ok): %s — %s", key, reason) + continue + } + + t.Run(key, func(t *testing.T) { + assert.Equalf(t, http.StatusForbidden, do(route.Method, route.Path, userToken), + "role=user must be denied (403) on non-allowlisted mutating route %s", key) + + if reason, ok := adminHandlerRejectsByDesign[key]; ok { + t.Logf("admin-side check skipped for %s — %s", key, reason) + return + } + adminCode := do(route.Method, route.Path, adminToken) + assert.NotEqualf(t, http.StatusUnauthorized, adminCode, + "role=admin must not be rejected as unauthorized on %s", key) + assert.NotEqualf(t, http.StatusForbidden, adminCode, + "role=admin must reach the handler (not 403) on %s", key) + }) + } + + // Guard against an allowlist entry silently rotting: every allowlisted key + // must correspond to a real registered route. + for key := range publicMutationAllowlistEnforcement { + assert.Truef(t, seen[key], "stale publicMutationAllowlistEnforcement entry (no such route): %s", key) + } + for key := range userOKMutationAllowlist { + assert.Truef(t, seen[key], "stale userOKMutationAllowlist entry (no such route): %s", key) + } + for key := range adminHandlerRejectsByDesign { + assert.Truef(t, seen[key], "stale adminHandlerRejectsByDesign entry (no such route): %s", key) + } +} + +// TestManagementGroup_RouteInventoryNoDuplicates guards the Commit 3 invariant +// that the deny-by-default sweep only changed middleware chains, never the set +// of registered endpoints. A read/mutation RegisterRoutes(read, admin) split +// that accidentally registers a path on both groups, or an inline move that +// leaves the old registration behind, shows up here as a duplicate +// "METHOD /path". +func TestManagementGroup_RouteInventoryNoDuplicates(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + dsn := "file:" + filepath.Join(t.TempDir(), "route_inventory.db") + "?_busy_timeout=5000" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + + cfg := config.Config{ + JWTSecret: "test-secret", + EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + } + require.NoError(t, Register(context.Background(), router, db, cfg)) + + seen := map[string]int{} + for _, route := range router.Routes() { + seen[route.Method+" "+route.Path]++ + } + for key, n := range seen { + assert.Equalf(t, 1, n, "route %s is registered %d times (expected exactly once)", key, n) + } +} diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 997cff4e4..eb15b03de 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -28,6 +28,14 @@ type Claims struct { jwt.RegisteredClaims } +// Register creates a user account, assigning RoleAdmin to the very first account +// (count == 0) and RoleUser to every subsequent one. +// +// It is retained solely as an internal/test-only user-creation helper. Public +// self-registration was removed: this method is no longer reachable via any HTTP +// route. First-admin bootstrap goes through POST /api/v1/setup, and further +// accounts are created by an admin (POST /api/v1/users) or via the email-invite +// flow. Behavior here is deliberately unchanged; do not wire it back to a route. func (s *AuthService) Register(email, password, name string) (*models.User, error) { email = strings.ToLower(email) var count int64 diff --git a/docs/features.md b/docs/features.md index a53d0ccee..7704efa1d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -319,6 +319,14 @@ Get alerted when it matters. Charon sends notifications through Discord, Gotify, ## 🛠️ Administration +### 👥 User Accounts & Roles + +Charon has administrators, who can change anything, and standard users, who manage proxy hosts and can view most settings but can't touch security-sensitive configuration. There's no public sign-up page — an admin adds every account, by email invite or directly, and the first administrator is created during initial setup. + +→ [Learn More](features/user-accounts.md) + +--- + ### 💾 Backup & Restore Your configuration is valuable. Charon makes it easy to backup your entire setup and restore it when needed—whether you're migrating to new hardware or recovering from a problem. Backups include a validated, checksummed archive format, configurable scheduling, optional passphrase encryption, and automatic copies to S3, SFTP, WebDAV, Dropbox, or Google Drive. diff --git a/docs/features/crowdsec.md b/docs/features/crowdsec.md index 060cb8011..ee50eb027 100644 --- a/docs/features/crowdsec.md +++ b/docs/features/crowdsec.md @@ -35,6 +35,10 @@ Key capabilities: No environment variables or manual configuration required. +> **Admin only.** Enabling, disabling, and managing CrowdSec — bans, bouncer +> keys, rulesets, and configuration — requires an administrator account. Standard +> users don't see the CrowdSec controls. + ### Hub Presets Access pre-built security configurations from the CrowdSec Hub: diff --git a/docs/features/custom-plugins.md b/docs/features/custom-plugins.md index c4f153e2f..bcec2b9c2 100644 --- a/docs/features/custom-plugins.md +++ b/docs/features/custom-plugins.md @@ -263,6 +263,10 @@ The plugin automatically configures Caddy's DNS challenge for Let's Encrypt: ## Plugin Management +> **Admin only.** Enabling, disabling, reloading, and unloading plugins requires +> an administrator account. Standard users can view the list of installed +> plugins but cannot change which ones are active. + ### Listing Loaded Plugins **Via Types Endpoint (Recommended):** diff --git a/docs/features/plugin-security.md b/docs/features/plugin-security.md index a3b7b7235..7b61b70ef 100644 --- a/docs/features/plugin-security.md +++ b/docs/features/plugin-security.md @@ -4,7 +4,11 @@ This guide covers security configuration and deployment patterns for Charon's pl ## Overview -Charon supports external DNS provider plugins via Go's plugin system. Because plugins execute **in-process** with full memory access, they must be treated as trusted code. This guide explains how to: +Charon supports external DNS provider plugins via Go's plugin system. Because plugins execute **in-process** with full memory access, they must be treated as trusted code. + +Managing plugins at runtime — enabling, disabling, reloading, or unloading them — requires an **administrator account**. Standard users can see which plugins are installed but cannot change which ones are active. + +This guide explains how to: - Configure signature-based allowlisting - Deploy plugins securely in containers diff --git a/docs/features/user-accounts.md b/docs/features/user-accounts.md new file mode 100644 index 000000000..876529f2a --- /dev/null +++ b/docs/features/user-accounts.md @@ -0,0 +1,70 @@ +--- +title: User Accounts & Roles +description: How people sign in to Charon, who is allowed to change what, and how new accounts get added +category: features +--- + +# User Accounts & Roles + +Charon keeps a short, simple list of who is allowed to sign in. There is no public +"create an account" page — every account is set up on purpose by someone who +already has access. That means a stranger who finds your Charon login screen has +nothing to do there but look at it. + +--- + +## The Two Kinds of Account + +**Administrator** + +The full-control account. An admin can change anything: security settings +(the firewall, CrowdSec, access lists, security headers), certificates and the +credentials used to get them, DNS provider logins, SSH connection details for +remote servers, tunnels and agents, app settings, plugins, and the user list +itself. The very first account you create when you set Charon up is an +administrator. + +**Standard user** + +A day-to-day account for adding and managing proxy hosts. A standard user can +still *open* most settings pages and see how things are configured — the +certificates list, the access lists, the DNS providers, and so on — but the +buttons that change security-sensitive configuration are for admins only. If a +standard user tries one anyway, Charon simply refuses it. + +A few areas are admin-only even to look at, because they show sensitive detail: +the CrowdSec control panel, the audit log, the remote agent management page, and +encryption management. Standard users don't see those in the menu. + +--- + +## Adding Someone New + +New accounts are always created by an existing administrator, in +**Settings → Users**. There are two ways: + +1. **Invite by email** — Charon emails the person a one-time link. They click it, + pick their own password, and they're in. +2. **Create directly** — the admin fills in the name, email, and a starting + password and hands it over. + +Either way, the admin chooses whether the new person is an administrator or a +standard user. + +--- + +## The Very First Account + +The first time you open a brand-new Charon, it shows a one-time setup screen and +asks you to create the starting administrator account. That's the only time an +account is created without an existing admin doing it. Once that account exists, +the setup screen is gone for good and all further accounts go through +**Settings → Users**. + +--- + +## Related + +- [How Charon Keeps You Safe](./security.md) — the bigger security picture +- [Access Control Lists](./access-control.md) — decide who can reach your proxied sites +- [Back to Features](../features.md) diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index b2411dd01..3e2199d2a 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,18 +1,9 @@ -# Technical Spec — Prebuilt Caddy + CrowdSec Toolchain Image (CI Docker-build timeout fix) +# Technical Spec — GHSA-3gc6-295r-xm5m Fix + Management-API Authorization Hardening + Retire Public Registration -**Status:** Revision 2 — for supervisor re-review -**Branch:** `feat/prebuilt-toolchain-image` -**Delivery model:** One feature = one PR, sliced into ordered logical commits (see [Commit Slicing Strategy](#12-commit-slicing-strategy)). -**Author:** Planning (Principal Architect) -**Date:** 2026-09-07 -**Supersedes on merge:** the previous `current_spec.md` (Uptime Monitoring at Scale — already delivered). - -### Revision history - -| Rev | Date | Change | -|---|---|---| -| 1 | 2026-09-07 | Initial draft. | -| **2** | **2026-09-07** | **Supervisor "APPROVE WITH CHANGES (major)" — resolved 7 blocking items:** B1 added §2.6 Alternatives Considered (decision record); B2 corrected the true current recurrence baseline (daily via nightly, not weekly) and pulled a **daily** `schedule` toolchain rebuild into committed scope (§3.4.1, §3.8.1–3.8.2; workflow lands in Commit 1, security-rebuild reroute in Commit 5); B3 rewrote the overstated "fresh `go mod tidy` MVS" claim in §3.8/R3 to state accurately what the forced rebuild catches (base-image + pin-bump drift only); B4 pin the two unpinned xcaddy plugins + feed them to the key (§2.2, §3.2.1, §3.4.2, Commit 1); B5 reworked the Commit Slicing Strategy so the CVE recurrence guard is never inert — the `--no-cache-filter` is retargeted to `caddy-inline`/`crowdsec-inline` inside Commit 1 and every commit gate proves the guard is live; B6 reconciled §3.9 timeouts with §3.7 fork path (fork-reachable CVE-gate jobs stay at 20 min); B7 made `verify-toolchain-pin.sh` failure-closed on same-repo PRs. Folded non-blocking N1–N11. | +**Status:** Draft for review (revised per coordinator rulings 2026-09-08) +**Advisory:** GHSA-3gc6-295r-xm5m — "Improper Authorization on CrowdSec Admin APIs via Public User Registration" (CWE-862, CVSS 8.8, reporter EQSTLab) +**Scope model:** ONE feature = ONE PR, delivered as an ordered sequence of logical commits (see [§9 Commit Slicing Strategy](#9-commit-slicing-strategy)). No PR splitting. +**Branch:** `development` (per `CLAUDE.md`: no worktrees, work on the current branch). --- @@ -20,922 +11,1102 @@ ### 1.1 Overview -Every CI workflow that builds the Charon container image recompiles a **custom Caddy v2 binary** (via `xcaddy`, with in-place source patching of transitive dependencies) and a **custom CrowdSec agent** (`crowdsec` + `cscli`) **from source, from scratch, on every run**. The two Dockerfile stages that do this — `caddy-builder` (`Dockerfile:302`) and `crowdsec-builder` (`Dockerfile:577`) — are explicitly excluded from all layer caching by `--no-cache-filter` / `no-cache-filters` in six workflows plus the shared composite action. - -Measured on the PR #1298 amd64 run: - -| Stage | Cold build time | -|---|---| -| `caddy-builder` (xcaddy build + patch + rebuild) | **748 s (12.5 min)** | -| `crowdsec-builder` (clone + patch + 2× `xx-go build`) | **~330 s** combined | -| GHA cache export (`type=gha,mode=max`) | ~90 s | - -`build-amd64` has `timeout-minutes: 15` with a nested `nick-fields/retry` `timeout_minutes: 15` (`docker-build.yml:403`, `:441`). The ~14-minute cold compile plus cache export blows the 15-minute budget; the integration jobs (`timeout-minutes: 20`) run out of budget once test work is stacked on top of the same cold compile. **PR #1298's four "failed" checks (`build-amd64`, `CrowdSec Bouncer Integration`, `Trivy Binary Scan`, `Cerberus Security Stack Integration`) were all CI job-timeout cancellations on the image build — not test or assertion failures.** - -The `--no-cache-filter` guards are deliberate (commits `5c046238`, `8cbc71f2`): the two builder stages patch pinned transitive dependencies **inside** the stage (`go get pkg@fixed`), and a build-arg bump does not reliably invalidate the GHA layer-cache key of a stage that only *consumes* that arg, so a restored stale layer keeps shipping a superseded, still-vulnerable dependency (this is exactly what produced the CVE-2026-45135 and 2026-09-04 grpc-go v1.83.0 recurrences). Removing the guards without another mechanism would reintroduce that class of silent regression. - -### 1.2 Objectives & Goals (ranked) - -1. **CI image builds stop timing out.** The `xcaddy` / CrowdSec compile must **not** run on the hot path of an ordinary app image build. Target: warm `build-amd64` completes in **< 8 min**; integration jobs **< 12 min**. -2. **The CVE-2026-84304-class recurrence guarantee is preserved or strengthened.** "Upstream ships a security fix, no repo pin changes" must still be caught on a defined cadence with an explicit alert path. -3. **Multi-arch is preserved.** `linux/amd64` and `linux/arm64` images keep getting a correctly cross-compiled Caddy/CrowdSec binary. -4. **A bumped pin can never silently ship an old toolchain.** A stale digest in the Dockerfile against a newer pin must fail a PR fast. -5. **Fork PRs, first-run bootstrap, and local `docker build` still work** without `packages: write` and without a published toolchain image. -6. **One source of truth for the build logic.** The `xcaddy` / CrowdSec build recipe must not be duplicated between the app Dockerfile and a separate toolchain Dockerfile. +A publicly reachable `POST /api/v1/auth/register` lets an anonymous attacker +create a `role=user` account. That account then reaches the entire +`/api/v1/admin/crowdsec/*` surface (~45 routes) because those routes are mounted +on the bare `management` router group, which is guarded only by +`RequireManagementAccess()` (rejects `role=passthrough` only — `role=user` +passes). Impact: bouncer API-key disclosure, disabling the IPS +(`POST /admin/crowdsec/stop` persists `SecurityConfig.Enabled=false`), ban +add/remove, and CrowdSec config-file read/write. + +This feature: + +- **Part A** — closes the authorization hole (the advisory fix): mount the + CrowdSec admin routes behind an explicit `RequireRole(admin)` subgroup, + mirroring the existing `securityAdmin` pattern. +- **Part B** — audits every route on the `management` group for the same class + of bug, fixes each under-guarded route found (at minimum: the + `/admin/plugins` mutation routes, a confirmed second live instance), and + introduces a "deny-by-default" structural guard + enforcement test so a + handler can no longer accidentally land privileged routes on an under-guarded + group. +- **Part C** — **removes the public `POST /auth/register` endpoint entirely** + (coordinator ruling). First-admin bootstrap continues via `POST /setup`; + post-bootstrap account creation is served by the **existing** admin + invite-user / email-invite flow (`User.InviteToken`, + `UserHandler.InviteUser` / `ValidateInvite` / `AcceptInvite`, + `frontend/src/pages/AcceptInvite.tsx`). No new invite model / service / + endpoints / UI are built. + +### 1.2 Objectives / Goals + +1. A `role=user` (or unauthenticated) caller receives `403` on every CrowdSec + admin route; `role=admin` is unaffected. +2. Every state-changing / privileged route on `management` is provably + admin-guarded or is a deliberate, documented `role=user` capability, enforced + by a CI test. +3. `POST /api/v1/auth/register` no longer exists — the route returns `404`. +4. First-admin bootstrap (`POST /setup`) and the existing email-invite + acceptance flow (`GET /invite/validate`, `POST /invite/accept`) continue to + work unchanged. +5. Backend coverage ≥ 85 %, frontend coverage ≥ 85 %, targeted E2E green, all + Definition-of-Done gates pass. ### 1.3 Non-goals -- Changing *which* Caddy plugins or CrowdSec version are shipped, or any dependency pin values. -- Changing the runtime image contents, entrypoint, ports, or `internal/caddy` / `internal/cerberus` behavior. -- Reworking the `arm64` QEMU split in `docker-build.yml` (already done in a prior spec). -- Moving off GHCR or introducing a second registry. - -### 1.4 EARS-style requirements - -| # | Requirement (EARS) | -|---|---| -| R1 | **When** an app image build runs in CI or locally with the default build-args, the system **shall** obtain the Caddy and CrowdSec binaries by `COPY --from` a digest-pinned prebuilt toolchain image, **without** invoking `xcaddy` or compiling CrowdSec. | -| R2 | **When** any security-relevant toolchain input changes on a PR (the two builder-stage bodies, their consumed version ARGs incl. the two now-pinned xcaddy plugins, `xx` pin, the digest-pinned `golang`/`alpine` builder bases, or `.trivyignore`), the toolchain-image workflow **shall** run and the freshness-guard check **shall** fail until the Dockerfile's pinned toolchain digest matches the newly published image for those inputs. | -| R3 | **While** no repo pin has changed, the scheduled **daily** toolchain rebuild **shall** rebuild the toolchain image with `--no-cache --pull` and scan it with Trivy; it **catches base-image drift** (new `golang`/`alpine`/plugin-base CVEs picked up via `--pull` and the digest re-resolve) **and pin-bump drift**, and — if a new digest or a new CRITICAL/HIGH finding results — **shall** open a bot PR bumping the pinned digest and alert via a GitHub issue on failure. It does **not** independently discover upstream security fixes to *unpinned transitive* Go dependencies (see §3.8 — that gap exists identically today and is closed only by an explicit pin bump). | -| R4 | **Where** the builder lacks `packages: write` or the toolchain image is unavailable (fork PR, first bootstrap, offline local build), the system **shall** fall back to compiling the `caddy-inline` / `crowdsec-inline` stages from source, producing an equivalent binary. | -| R5 | **When** the toolchain image is built, it **shall** be published as a multi-arch manifest list covering `linux/amd64` and `linux/arm64`, each entry carrying the correctly cross-compiled binary. | -| R6 | **When** `--no-cache-filter caddy-builder` / `crowdsec-builder` (and the `no-cache-filters` input) are removed from all six workflows and the composite action, normal `type=gha` layer caching **shall** cover every remaining stage. | +- Any new invite mechanism, model, service, endpoint, or UI. (Earlier draft's + `models.Invite` / `InviteService` / `InviteHandler` / `frontend/src/api/invites.ts` / + `useInvites` / `UsersPage` invite section / `/register` page are **dropped**.) +- Changes to the existing per-user email-invite flow beyond referencing it as + the supported post-bootstrap path. +- A general-purpose RBAC engine. The 3-tier model (`admin` / `user` / + `passthrough`) is unchanged. +- Per-IP auth rate limiting (see [§7](#7-remaining-open-questions) — deferred to + a follow-up issue; `/auth/register` is being removed and `/auth/login` + already has account lockout). --- ## 2. Research Findings -### 2.1 Current build graph (verified) - -``` -Dockerfile stages (944 lines total): - - xx (tonistiigi/xx:1.9.0, Dockerfile:73) ── cross-compile helper - │ - ├─► gosu-builder (:80, COPY --from=xx) - ├─► frontend-builder (:134, node:24) - ├─► backend-builder (:178, COPY --from=xx) - │ - ├─► caddy-builder (:302) FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine - │ NO `COPY --from=xx`. Pure Go cross-compile: - │ go install xcaddy → `xcaddy build` (Stage 1, generates go.mod) - │ → ~20× `go get pkg@fixed` security patches (Stage 2) - │ → module-cache source patches (celmatcher.go, bouncer) - │ → GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /usr/bin/caddy ← 748 s - │ → embeds-version assertions (cel-go v0.29.x, grpc v1.83.1) - │ - ├─► crowdsec-builder (:577) FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine - │ COPY --from=xx / / (:578). CGO cross-compile: - │ xx-apk add gcc musl-dev musl → git clone crowdsec vX.Y - │ → ~20× `go get pkg@fixed` → sed patch debugger.go - │ → CGO_ENABLED=1 xx-go build crowdsec + cscli ← ~330 s - │ → xx-verify - │ - ├─► crowdsec-fallback (:713) FROM ${ALPINE_IMAGE} ── DEAD CODE (see note below) - │ - └─► final runtime (:751) FROM ${ALPINE_IMAGE} - COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy (:807) - COPY --from=crowdsec-builder /crowdsec-out/crowdsec /usr/local/bin/crowdsec (:814) - COPY --from=crowdsec-builder /crowdsec-out/cscli /usr/local/bin/cscli (:815) - COPY --from=crowdsec-builder /crowdsec-out/config /etc/crowdsec.dist (:817) -``` - -Key finding: **`caddy-builder` does not use `xx`** — it is a plain `$BUILDPLATFORM` golang image doing `GOOS/GOARCH` cross-compilation with `CGO` disabled. `crowdsec-builder` **does** use `xx` because CrowdSec needs `CGO_ENABLED=1` (sqlite). **Both stages are `FROM --platform=$BUILDPLATFORM`**, so on `docker-build.yml`'s arm64 leg the *builder* stages have always run natively on the amd64 host and cross-compiled — **QEMU has only ever emulated the final arm64 stage's `RUN` lines**, never the Caddy/CrowdSec compile. This is what makes a native-amd64, no-QEMU multi-arch toolchain build possible (see §3.5). - -**Correction (was wrong in Rev 1): `crowdsec-fallback` (`:713`) is dead code.** Verified: no `COPY --from=crowdsec-fallback`, no `FROM crowdsec-fallback`, and no `--target crowdsec-fallback` anywhere in the repo. It is never built and never consumed. The final stage copies unconditionally from `crowdsec-builder` (`:814-817`). Rev 1's §3.2.3 claim that "`crowdsec-fallback` is selected by the existing arch logic" was incorrect. **Action:** Commit 1 deletes the `crowdsec-fallback` stage (`:713-748`). `CROWDSEC_RELEASE_SHA256` (`:22`, re-declared at `:586`) is *only* used by the tarball `sha256sum -c` inside that stage — after deletion the global `ARG` and the `:586` re-declaration are dead too and are removed in the same commit. `crowdsec-inline` clones from git (`git clone --branch "v${CROWDSEC_VERSION}"`), so it is unaffected. **Verify at implementation time** whether `CROWDSEC_RELEASE_SHA256` has its own updater workflow (grep `.github/workflows/` for `CROWDSEC_RELEASE_SHA256`); if so, delete it in the same commit. (Per CLAUDE.md "delete dead code immediately". If the reviewer prefers to keep `crowdsec-fallback` as a deliberate escape hatch, the fallback position is: leave it untouched and simply exclude it from the toolchain key — it does not affect the shipped binary. Planning's recommendation is deletion.) +### 2.1 Existing architecture (verified in-repo on `development`) -### 2.2 Version ARGs consumed by the builder stages (verified line numbers) +#### Auth / authorization primitives -| ARG | Global default (line) | Re-declared in | +| Element | Location | Behavior | |---|---|---| -| `GO_VERSION` | `1.27.1` (`:13`) | base of both stages (`FROM golang:${GO_VERSION}-alpine` — **moving tag, see N4 below**) | -| `ALPINE_IMAGE` | `alpine:3.24.1@sha256:28bd…` (`:16`) | toolchain-runtime base (already digest-pinned) | -| `CROWDSEC_VERSION` | `1.8.1` (`:20`) | caddy `:318`, crowdsec `:585`, fallback `:720` | -| `CROWDSEC_RELEASE_SHA256` | `deae1f43…` (`:22`) | crowdsec `:586`, fallback `:721` | -| `EXPR_LANG_VERSION` | `1.17.8` (`:26`) | caddy `:313`, crowdsec `:587` | -| `XNET_VERSION` | `0.58.0` (`:28`) | caddy `:314`, crowdsec `:588` | -| `XCRYPTO_VERSION` | `0.56.0` (`:33`) | caddy `:315`, crowdsec `:589` | -| `KLAUSPOST_COMPRESS_VERSION` | `1.20.0` (`:38`) | caddy `:316`, crowdsec `:590` | -| `GRPC_VERSION` | `1.83.1` (`:44`) | caddy `:317`, crowdsec `:591` | -| `CADDY_VERSION` | `2.11.4` (`:56`) | caddy `:305` | -| `CADDY_CANDIDATE_VERSION` | `2.11.4` (`:58`) | caddy `:306` | -| `CADDY_USE_CANDIDATE` | `0` (`:59`) | caddy `:307` | -| `CADDY_PATCH_SCENARIO` | `B` (`:60`) | caddy `:308` | -| `CADDY_SECURITY_VERSION` | `1.1.64` (`:62`) | caddy `:309` | -| `CORAZA_CADDY_VERSION` | `2.6.0` (`:64`) | caddy `:310` | -| `XCADDY_VERSION` | `0.4.7` (declared inside stage, `:~311`) | caddy only | -| `xx` image | `tonistiigi/xx:1.9.0@sha256:c64defb9…` (`:73`) | crowdsec `:578` | -| **`CADDY_GEOIP2_VERSION`** | **NEW — see B4** | caddy `:391` — currently `--with github.com/zhangjiayin/caddy-geoip2` with **no `@version`** | -| **`CADDY_RATELIMIT_VERSION`** | **NEW — see B4** | caddy `:392` — currently `--with github.com/mholt/caddy-ratelimit` with **no `@version`** | - -**B4 — two xcaddy plugins are unpinned (`Dockerfile:391-392`).** `--with github.com/zhangjiayin/caddy-geoip2` and `--with github.com/mholt/caddy-ratelimit` carry no `@version`, so `xcaddy` resolves "latest" at build time. The literal Dockerfile text never changes when those projects tag a release, so `toolchain-key.sh` would not notice, and a stale toolchain image would be reused when an upstream plugin fix actually warrants a rebuild. **Fix (Commit 1, preferred):** add `ARG CADDY_GEOIP2_VERSION=` and `ARG CADDY_RATELIMIT_VERSION=` near `:64` with `# renovate: datasource=go` annotations, and change lines `:391-392` to `--with github.com/zhangjiayin/caddy-geoip2@v${CADDY_GEOIP2_VERSION}` / `--with github.com/mholt/caddy-ratelimit@v${CADDY_RATELIMIT_VERSION}`. Resolve the current versions at implementation time via `xcaddy`'s build log or `go list -m` in the existing `caddy-inline` module cache. Both ARGs join the §3.4.2 key input set. - -**N4 — `golang:${GO_VERSION}-alpine` is a moving tag.** Only `GO_VERSION` (the minor, e.g. `1.27.1`) feeds the key; the underlying `-alpine` digest floats and, post-change, the app hot path no longer `--pull`s it (only the toolchain workflow does). A silent `golang:1.27.1-alpine` rebuild upstream (new Alpine base, patched toolchain) would not change the key. **Fix (Commit 1):** digest-pin both builder-stage bases — `FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine@sha256: AS caddy-inline` (and `crowdsec-inline`) — with a `# renovate: datasource=docker depName=golang` annotation, and include the pinned digest line in the key input set. The **daily** toolchain rebuild's `--pull` + Renovate digest bumps then keep it fresh; the app build inherits it transitively through the pinned toolchain image — the intended daily-cadence refresh path for builder-base drift (the app hot path deliberately does not re-pull it). - -Also inside the stages: many *literal* pinned versions in `go get` lines (e.g. `go-jose/v3@v3.0.5`, `cel-go@v0.29.2`, `quic-go@v0.60.0`, `golang.org/x/mod@v0.40.0`, `ipstore@v0.4.0`). Because these are literals in the stage body, the **content hash of the stage text** (not just the ARG list) must feed the toolchain tag key (§3.4). - -### 2.3 `--no-cache-filter` / `no-cache-filters` occurrences (verified — full removal list) - -| # | File:line | Form | Job / context | -|---|---|---|---| -| 1 | `.github/workflows/docker-build.yml:463-464` | raw `--no-cache-filter caddy-builder` / `crowdsec-builder` | `build-amd64` (`nick-fields/retry` → raw `docker buildx build`) | -| 2 | `.github/workflows/docker-build.yml:549-550` | raw `--no-cache-filter …` | `build-arm64` | -| 3 | `.github/workflows/security-pr.yml:164` | `no-cache-filters: caddy-builder,crowdsec-builder` (composite input) | `Build Docker image (Local)` step, job `timeout-minutes: 20` (`:32`) | -| 4 | `.github/workflows/supply-chain-pr.yml:261` | `no-cache-filters:` (composite input) | `Build Docker image (Local)` step, job `timeout-minutes: 20` (`:34`) | -| 5 | `.github/workflows/e2e-tests-split.yml:224` | `no-cache-filters:` on `docker/build-push-action` (`:215`) | `build` job | -| 6 | `.github/workflows/nightly-build.yml:243` | `no-cache-filters:` on `docker/build-push-action` (`:229`) | `Build and push Docker image` (multi-arch) | -| 7 | `.github/actions/build-charon-image/action.yml:15` (input decl) + `:52` (passthrough) | `no-cache-filters` composite input, default `''` | consumed by `cerberus-integration.yml:34`, `crowdsec-integration.yml:34`, `waf-integration.yml:34`, `rate-limit-integration.yml:34` (none of those four override it today) | - -The composite action's own doc comment (`action.yml:16-33`) instructs CVE-scan callers to set `no-cache-filters: caddy-builder,crowdsec-builder`; that comment must be rewritten (§3.6). - -### 2.4 Existing patterns to reuse - -- **Weekly security rebuild:** `.github/workflows/security-weekly-rebuild.yml` — `schedule: '0 12 * * 2'` (Tue 12:00 UTC) + `workflow_dispatch{force_rebuild}`, `timeout-minutes: 60`, `no-cache: ${{ schedule || force_rebuild }}`, `pull: true`, publishes `ghcr.io/wikid82/charon:security-scan-YYYYMMDD`, Trivy CRITICAL/HIGH gate + SARIF upload + JSON artifact + failure `::warning::`. **This spec repurposes this workflow to rebuild the *toolchain* image** rather than a throwaway app image (it currently scans an image nobody consumes). -- **Bot-PR-bumps-a-pin:** `.github/workflows/update-geolite2.yml` — weekly cron + `workflow_dispatch`, downloads upstream, `sed -i` the `ARG …_SHA256=` line in the Dockerfile, `docker build --check` syntax gate, `peter-evans/create-pull-request@v8` targeting `base: development`, `branch: bot/update-geolite2-checksum`, labels `dependencies/automated/docker`, failure → `actions/github-script` opens an issue. Commits `15ca90b8` / `94b93fdf` are live examples. **Reuse verbatim structure for the digest-bump bot (§3.4.3).** -- **Toolchain-bump scripts:** `scripts/update-go-toolchain.sh`, `scripts/update-node-toolchain.sh`, `scripts/caddy-compat-matrix.sh` — house style for a `scripts/*.sh` helper invoked by CI. -- **Renovate regex managers** already track every `ARG` above via `# renovate:` annotations — must be preserved (§3.3). - -### 2.5 Constraints from `CLAUDE.md` / `ARCHITECTURE.md` - -- All frontend in `frontend/`, backend in `backend/` — unaffected (this is CI/build only). -- Conventional commits; `(security)` scope only for genuine security work, subject line vague. The initial-pin and freshness-guard commits *are* security-relevant — use `feat(security):` / `fix(security):` with vague subjects (e.g. `feat(security): pin bundled proxy toolchain to a scanned prebuilt image`). The routine daily digest-refresh bot PR uses **`chore(docker):`** — `feat:` there makes release-please cut a minor release on every refresh. -- Weekly `nightly → main` promotion PRs merge via **merge commit**. This feature's PR targets `development` (normal flow) — **confirmed it does not touch `weekly-nightly-promotion.yml`** and imposes no new constraint on the promotion merge method. (`weekly-nightly-promotion.yml` carries the app image through unchanged; the toolchain digest pin travels with the Dockerfile like any other line.) -- `ARCHITECTURE.md` §"Deployment Architecture / Multi-Stage Dockerfile" (`:1082`), §"Infrastructure" table (`:158`), §"Directory Structure" (`:286`), §"Layer 2: CrowdSec Integration" (`:780`) must be updated (§9). -- **Ignore-file check (CLAUDE.md "Ignore Files"):** the new files are `scripts/toolchain-key.sh`, `scripts/verify-toolchain-pin.sh`, `scripts/lib/dockerfile-stage.sh`, `scripts/tests/toolchain-key.bats` (+ `verify-toolchain-pin.bats`, `helpers/toolchain_fixture.bash`), `.github/workflows/toolchain-image.yml`, `docs/ci/toolchain-image.md`. **Correction (Rev 2.1):** the earlier claim that `scripts/` is not copied into the image was wrong — `Dockerfile` `COPY scripts/ /app/scripts/` copies the whole directory into the runtime image (it already ships ~40 `scripts/*.sh` + a pre-existing `.bats`). These four build-only helpers are used only by `toolchain-image.yml` and the `quality-checks.yml` `verify-toolchain-pin` / bats jobs from a plain checkout — never from inside a built container — so **`.dockerignore` now excludes `scripts/tests/`, `scripts/toolchain-key.sh`, `scripts/verify-toolchain-pin.sh`, `scripts/lib/dockerfile-stage.sh`** (blacklist semantics, no `!scripts/…` re-includes to fight). `.github/` and `docs/` are already excluded, so `toolchain-image.yml` / `docs/ci/toolchain-image.md` never enter the context. `.gitignore` — these are source files that must be committed; none matches an existing ignore glob → **no `.gitignore` change**. `.codecov.yml` — shell/bats and YAML carry no Go/TS coverage → **no `.codecov.yml` change**. Recorded explicitly per CLAUDE.md. - ---- - -## 2.6 Alternatives Considered (Decision Record) +| `AuthMiddleware` | `backend/internal/api/middleware/auth.go` | Validates JWT / cookie, sets `c.Set("userID", …)` and `c.Set("role", string(user.Role))`. | +| `RequireManagementAccess()` | `backend/internal/api/middleware/auth.go:116` | **Only** aborts when `role == RolePassthrough`. `role=user` and `role=admin` pass. | +| `RequireRole(role)` | `backend/internal/api/middleware/auth.go` | Aborts `401` if no role; aborts `403` unless `userRole == role` **or** `userRole == RoleAdmin`. So `RequireRole(RoleAdmin)` ⇒ admin-only, and `RequireRole(anything)` still lets admin through. | +| `requireAdmin(c)` / `isAdmin(c)` | `backend/internal/api/handlers/permission_helpers.go` | In-handler guard. `isAdmin` = `c.GetString("role") == "admin"`. `requireAdmin` writes `403 {"error":"admin privileges required","error_code":"permissions_admin_only"}`. | +| `rejectPassthrough(c, action)` | `backend/internal/api/handlers/user_handler.go:225` | In-handler 403 for passthrough. | +| Roles | `backend/internal/models/user.go` | `RoleAdmin="admin"`, `RoleUser="user"`, `RolePassthrough="passthrough"`. `RoleUser` doc: "can access the Charon management UI with restricted permissions" (restriction is per-host `PermittedHosts`, not per-feature). | -The user has confirmed **approach A (prebuilt toolchain image)**. This section records the lighter alternative that was weighed against it, why that alternative is genuinely viable, and the concrete grounds on which A was still chosen — so the decision is auditable rather than assumed. - -### Alternative B — Keep the two stages inline; replace `--no-cache-filter` with a content-hash-keyed buildx GHA cache scope - -**Mechanism.** Leave `caddy-builder` / `crowdsec-builder` exactly where they are in the Dockerfile. Delete every `--no-cache-filter caddy-builder,crowdsec-builder`. In its place, give the two expensive stages their own dedicated GHA cache scope whose key is the content hash of the pin set (the same `scripts/toolchain-key.sh` output proposed for approach A): +#### Route groups — `backend/internal/api/routes/routes.go` ``` -KEY=$(scripts/toolchain-key.sh) # caddy-crowdsec- -docker buildx build \ - --cache-from type=gha,scope=charon-app \ - --cache-to type=gha,mode=max,scope=charon-app \ - --cache-from type=gha,scope=builders-${KEY} \ - --cache-to type=gha,mode=max,scope=builders-${KEY} \ - ... +api := router.Group("/api/v1") // public + api.POST("/auth/login", …) + api.POST("/auth/register", authHandler.Register) // line 295 — PUBLIC, no gate ← ADVISORY (Part C removes) + api.GET("/setup", …) / api.POST("/setup", …) // bootstrap first admin (Part C keeps) + api.GET("/invite/validate", …) / api.POST("/invite/accept", …) // existing email-invite (Part C references as supported path) + protected := api.Group("/"); protected.Use(authMiddleware) // any authenticated user + management := protected.Group("/") + management.Use(middleware.RequireManagementAccess()) // line 373-374 — passthrough-only reject + securityAdmin := management.Group("/security") + securityAdmin.Use(middleware.RequireRole(models.RoleAdmin)) // line 796-797 — CORRECT admin gate (template) + adminEncryption := management.Group("/admin/encryption") // line 546 — no RequireRole, BUT every handler calls isAdmin(c) + adminPlugins := management.Group("/admin/plugins") // line 560 — no RequireRole AND plugin_handler has NO admin check ← BUG (Part B) + crowdsecHandler.RegisterRoutes(management) // line 838 — no RequireRole AND crowdsec_handler has NO admin check ← ADVISORY (Part A) + … ~20 other *.RegisterRoutes(management) / inline management.* … +RegisterImportHandler(…) { // separate func, line 1005 + authenticatedAdmin := api.Group("/") + authenticatedAdmin.Use(AuthMiddleware(authService), RequireRole(models.RoleAdmin)) // line 1011-1012 — CORRECT admin gate (2nd template / name precedent) +} ``` -When a pin moves, `KEY` changes, the `builders-` scope is a guaranteed miss, and the stage recompiles exactly once; every subsequent build on that `KEY` restores the layer. When a pin does **not** move, the layer is restored and no compile happens. - -**What Alternative B genuinely delivers — stated fairly:** - -- It **does** fix the #1298 timeouts in the common case: after the first build on a given `KEY`, every PR/CI build restores the `caddy-builder` / `crowdsec-builder` layers from `builders-` and skips the ~14 min compile. -- It **preserves the exact pin-bump recurrence guarantee**: the cache key is derived from the pin content, so a bumped `CADDY_VERSION` (or any tracked ARG, or any edit to the stage body) forces a clean recompile — the same property approach A's freshness guard enforces, achieved without a guard because the key *is* the cache identity. -- It is **~half the work**: no new image, no new registry package, no `toolchain-image.yml`, no digest-bump bot, no `verify-toolchain-pin.sh`, no fork-fallback selector stages, no `packages: read` cross-workflow plumbing. Roughly Commits 1, 3 and 6 of approach A's plan, and no new failure surface (GHCR availability, private-package permissions, bot-PR merge latency). -- Local `docker build` is unaffected — no image pull, no login. - -**Why approach A is still chosen — concrete grounds:** - -1. **GHA cache eviction makes Alternative B's timeout fix unreliable.** GitHub Actions caches (`type=gha`) share a **10 GB per-repository LRU budget**. This repo already runs `gh_cache_cleanup.yml` and its existing timeout comments explicitly cite "cold GHA cache (first run / post-eviction) is a full ~10–14 m image build" (`security-pr.yml:32`, `supply-chain-pr.yml:34`, the integration workflows' `:29`). A `mode=max` multi-stage image cache for Charon is large; the `builders-` scope competes with `docker-build-amd64`, `docker-build-arm64`, `charon-integration-image`, `charon-app`, npm, Go build caches, and the e2e image tarball for that 10 GB. On a busy week the `builders-` entry is evicted between runs and the **next** PR eats a cold ~14 min compile again — i.e. Alternative B reduces the *frequency* of timeout-class builds but does not *eliminate* them, which is the actual acceptance bar (§5 AC #2: no timeout across 3 consecutive runs, and none thereafter). A digest-pinned image in GHCR's package store is **not** subject to the Actions cache LRU — it is pulled, not cache-restored — so approach A removes the cold-build possibility entirely rather than making it rarer. - -2. **Trivy scans a small, stable, isolated artifact.** With approach A, the weekly/daily security scan targets `ghcr.io/wikid82/charon-toolchain` — two binaries plus an Alpine base, a stable surface whose findings map directly to the bundled Caddy/CrowdSec supply chain. With Alternative B there is no separate artifact: every scan re-derives bundled-binary findings from the full application image on every run, mixed with app-layer and base-image findings, and there is no way to pin/attest "the bundled toolchain that was scanned green on date X" independently of the app image. +Verified line numbers (grep, `development` HEAD): `auth/register` route `:295`, +`management := protected.Group("/")` `:373`, `adminPlugins` `:560`, +`securityAdmin` `:796`, `crowdsecHandler.RegisterRoutes(management)` `:838`. -3. **The recompile cost is paid out-of-band.** Under approach A the ~14–30 min compile only ever runs in `toolchain-image.yml` (45 min budget) or `security-weekly-rebuild.yml` (60 min budget) — never on a contributor's PR or on `docker-build.yml`'s tight per-arch budgets. Under Alternative B the first build on every new `KEY` (every pin bump — routine, Renovate opens several a week) pays the full compile *on whatever PR happens to bump the pin*, on that PR's normal timeout budget. B6/§3.9 shows those budgets are already close to the edge. +#### In-handler admin-check audit (grep `requireAdmin(|isAdmin(|RoleAdmin|GetString("role")|rejectPassthrough`, non-test) -4. **Eviction-immunity also fixes the arm64 leg.** `docker-build.yml`'s `build-arm64` runs under QEMU; today it emulates the *fast* stages plus the final-stage `RUN` lines around a cold-or-warm builders layer. Under approach A the arm64 app build does a `COPY --from` of a pre-cross-compiled binary out of the pinned image's arm64 child — no dependence on an arm64-scoped GHA cache entry surviving. Alternative B's `builders-` scope for arm64 is a separate, separately-evictable entry. - -**Residual point in Alternative B's favour, acknowledged:** approach A adds GHCR as a hard build dependency and a private-package permission surface (N8), needs a fork fallback (§3.7), and is more moving parts to operate. The mitigations are in §3.10 (retry wrap, documented inline fallback, `imagetools` platform assertion) and the operator runbook (`docs/ci/toolchain-image.md`, §9). On balance the eviction-immunity (point 1) is decisive: it is the difference between "timeouts become rarer" and "timeouts cannot happen", and the latter is the stated goal. +| Handler | In-handler role refs | Mounted on | Effective guard for `role=user` | +|---|---|---|---| +| `crowdsec_handler.go` | **0** | `management` (bare) | **NONE — vulnerable** (advisory) | +| `plugin_handler.go` | **0** | `management.Group("/admin/plugins")` (bare) | **NONE — mutations vulnerable** (Part B) | +| `encryption_handler.go` | 4 (`isAdmin`) | `management.Group("/admin/encryption")` (bare) | OK (in-handler) | +| `docker_handler.go` | 0 | `management` | none — read-only (`GET /docker/containers`, used by proxy-host create) | +| `proxy_host_handler.go` / `proxy_group_handler.go` | 0 | `management` | none — intended `role=user` capability | +| `remote_server_handler.go` | 0 | `management` | none | +| `security_headers_handler.go` | 0 | `management.Group("/security/headers")` | none | +| `hecate_handler.go` / `orthrus_handler.go` | 0 | `management` | none | +| `manual_challenge_handler.go` | 0 | `management` (`/dns-providers/:id/...`) | none | +| `settings_handler.go` | 8 | `management` + one `RequireRole` arg on `GET /settings/smtp` (`:457`) | partial in-handler | +| `system_permissions_handler.go` | 3 | `management` | in-handler | +| `certificate_handler.go`, `access_list_handler.go`, `domain_handler.go`, `uptime_handler.go`, `stats_handler.go`, `feature_flags_handler.go`, `audit_log_handler.go` | 0 | `management` | none — per-route verdict in §3.2 | +| `notification_provider_handler.go` | 3 (`requireAdmin` — `Create`/`Update`/`Delete` only; **`Test` & `Preview` are NOT guarded**) | `management` | mutations OK in-handler; `POST /notifications/providers/test` + `/preview` unguarded → see table #33b | +| `notification_template_handler.go` | 3 (`requireAdmin` — `Create`/`Update`/`Delete` only; **`Preview` NOT guarded**) | `management` | mutations OK in-handler; `POST /notifications/external-templates/preview` unguarded → see table #33b | +| `security_notifications.go` | 2 (`requireAdmin` — `GetSettings`/`UpdateSettings`) | `management` | OK (in-handler) | +| `notification_handler.go` (per-user inbox) | 0 | `management` | none — USER-OK (list / mark-read) | +| `security_handler.go` | many (`requireAdmin`) | reads on `management`, writes on `securityAdmin` | OK | +| `backup_handler.go` / `backup_remote_handler.go` | many (`requireAdmin`) | `management` | OK (in-handler) | +| `user_handler.go` | many (`requireAdmin` / `rejectPassthrough`) | `management` | OK (in-handler; `UpdateUser` deliberately allows non-admin self-service) | + +**Conclusion:** `management` is a de-facto "any authenticated non-passthrough +user" group; admin enforcement is applied inconsistently by three mechanisms +(dedicated subgroup, per-route middleware arg, in-handler `requireAdmin`). Two +areas — CrowdSec (all) and Plugins (mutations) — have **no** enforcement. + +#### Frontend route/nav gating — `frontend/src/App.tsx`, `frontend/src/components/Layout.tsx` + +- The SPA has **almost no role gating**. `App.tsx` wraps only: + - `/settings/*` in `` + - `/settings/users` in `` + - Everything else under `/` (`/security/*`, `/access-lists`, `/dns/*`, + `/hecate/*`, `/certificates`, `/security/audit-logs`, `/security/crowdsec`, + …) is reachable by any authenticated non-passthrough user, incl. `role=user`. +- `Layout.tsx` nav: only the **"Users"** entry is `role === 'admin'`-gated + (`:127`); passthrough sees no nav (`:151`); `uptime` / `cerberus` sections are + feature-flag gated. So a `role=user` today sees and can open CrowdSec config, + Access Lists, Security Headers, DNS providers, Certificates, Hecate, Audit + Logs, etc., and those pages call their APIs successfully because + `management` doesn't stop them. +- `RequireRole` component: `frontend/src/components/RequireRole.tsx` — renders + children if `user.role ∈ allowed`, else redirects. Ready to reuse. +- Public routes: `/login`, `/setup`, `/accept-invite` only. **No signup/register + page or route exists.** `grep "auth/register"` in `frontend/src` → 0 hits. + The endpoint is unused by the UI. + +**Implication for Part B (Q7 ruling):** because non-admin screens currently +consume many of these read endpoints, moving a *read/list* endpoint to +admin-only would regress a `role=user` page. The classification in §3.2 is +therefore **mutation-oriented**: reads/lists that back a `role=user`-reachable +page stay on `management`; mutations move behind `RequireRole(admin)` (via a +per-route arg, or a `RegisterRoutes(read, admin)` split where the handler +registers its own routes). Only **CrowdSec** (forced by Part A — no +`role=user` read need) moves *wholesale* to `managementAdmin`. Hecate, Orthrus +and Remote Servers each keep a small set of `GET` reads on `management` +(consumed by the proxy-host create/edit flow and the Dashboard) and move only +their mutations — verified against `frontend/src` (§3.2.1 C1/C2). Where a page +becomes admin-only in practice (CrowdSec, Audit Logs, the Orthrus +agent-management page, Encryption) a **companion frontend `RequireRole` guard + +nav filter** is added (mirroring the existing "Users" pattern) so `role=user` +never lands on a 403-ing page. + +#### `/auth/register` and `/setup` — how the first admin is created + +- `authHandler.Register` — `backend/internal/api/handlers/auth_handler.go:244`; + `RegisterRequest{Email,Password,Name}` (`min=8` password) at `:238`. Calls + `h.authService.Register(req.Email, req.Password, req.Name)` at `:251`, returns + `201` + user JSON. **No gating of any kind.** +- `authService.Register(email, password, name)` — + `backend/internal/services/auth_service.go:31`: `count == 0` ⇒ `RoleAdmin`, + else `RoleUser`. No toggle / invite / flag. +- **`POST /setup` does NOT call `authService.Register`.** + `UserHandler.Setup` (`backend/internal/api/handlers/user_handler.go:141`) + builds `models.User{Role: models.RoleAdmin, …}` directly and `tx.Create(&user)` + inside its own transaction (also writes `caddy.acme_email`). It is fully + independent of `authService.Register` / `authHandler.Register`. +- **`authService.Register` is NOT dead after removing the route.** grep + `\.Register(` (non-`metrics`/`tracker`/`dnsprovider`) — it is called from + ~28 test sites as a user-creation helper: + - `backend/internal/services/auth_service_test.go` (16 calls — incl. the + `count==0 → RoleAdmin` behavior test, `TestAuthService_Register*`) + - `backend/internal/api/middleware/auth_test.go` (12 calls) + - `backend/internal/api/handlers/user_integration_test.go:52` +- **`authHandler.Register` (HTTP handler) references:** only + `routes.go:295` (the route) and + `backend/internal/api/handlers/additional_coverage_test.go:729` + (`TestAuthHandler_Register_InvalidJSON` — a 400-on-bad-JSON coverage test). +- **Test / inventory references to the route path** (`grep "auth/register"`): + - `backend/internal/api/routes/routes_test.go:162` — `expectedRoutes` list in + `TestRegister_RoutesRegistration` + - `backend/internal/api/routes/routes_test.go:215` — `publicMutationAllowlist` + in `TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist` + - `backend/internal/api/routes/routes_test.go:335` — + `assert.Contains(t, routeMap, "/api/v1/auth/register")` in + `TestRegister_AllRoutesRegistered` + - `backend/integration/crowdsec_lapi_integration_test.go:59` — `authenticate()` + helper POSTs `/api/v1/auth/register` (errors ignored) to bootstrap a test + user; build-tagged integration test, not in default CI. + +⇒ **Part C deletions are exactly:** the route (`routes.go:295`), +`AuthHandler.Register` (`auth_handler.go:244-256`), `RegisterRequest` +(`auth_handler.go:238-242`). **Keep** `AuthService.Register` (+ its +`count==0 → RoleAdmin` logic) — still referenced by ~28 test call sites as a +helper. Update the 4 test references above. + +#### Existing email-invite flow (unchanged — the supported post-bootstrap path) + +- `models.User` fields (`backend/internal/models/user.go`): `InviteToken` + (`json:"-"`, `gorm:"index"`), `InviteExpires`, `InvitedAt`, `InvitedBy`, + `InviteStatus` (`"pending"|"accepted"|"expired"`); helper + `User.HasPendingInvite()`. +- `UserHandler.InviteUser` (`POST /users/invite`, admin — `requireAdmin`), + `ResendInvite` (`POST /users/:id/resend-invite`), `PreviewInviteURL`, + `ValidateInvite` (`GET /invite/validate`, public), `AcceptInvite` + (`POST /invite/accept`, public). `generateSecureToken()` at + `user_handler.go:494` (`crypto/rand` 32B → hex). +- Frontend: `frontend/src/pages/AcceptInvite.tsx` (route `/accept-invite`, reads + `?token=`), `frontend/src/api/users.ts` + (`inviteUser`/`validateInvite`/`acceptInvite`/`resendInvite`/`previewInviteURL`), + `frontend/src/pages/UsersPage.tsx` (`/settings/users`, admin-gated). + +#### AutoMigrate + +`backend/internal/api/routes/routes.go:112` — single `db.AutoMigrate(&models.X{}, …)` +call. **No new models in this feature**, so no change here. + +#### Test patterns + +- `backend/internal/api/routes/routes_test.go`: + - `TestRegister_AllRoutesRegistered` (`:310`) — asserts `routeMap` contains + `/api/v1/admin/crowdsec/*` and `/api/v1/auth/register`. + - `TestRegister_AdminRoutes` (`:481`) — GET admin paths expecting `401` + unauthenticated. + - `TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist` + (`:196`) — iterates every mutating `/api/v1/*` route, asserts `401|403` + unless in `publicMutationAllowlist`. **This is the Part B harness** — extend + it with a `role=user` dimension and remove the `auth/register` allowlist + entry. +- E2E: `tests/security-enforcement/authorization-rbac.spec.ts` & + `auth-api-enforcement.spec.ts` — `loginAndGetToken(context, {email,password})` + vs `TEST_USERS.admin` / `TEST_USERS.user`; assert `role=user` → `403` on + privileged routes. Playwright projects: `security-tests` (CI shard), + `firefox` (local DoD, single browser). + +### 2.2 Docs to update + +| Doc | Why | +|---|---| +| `ARCHITECTURE.md` → "Security Architecture" / "Authentication & Authorization" | New `managementAdmin` authorization boundary; public registration removed; bootstrap-via-`/setup` + email-invite is the account-creation model. | +| `SECURITY.md` → "Authentication & Authorization" (~line 1148) | RBAC description: explicit admin-subgroup enforcement; no public self-registration. | +| `docs/security.md`, `docs/features/access-control.md` | User-facing: how accounts are created (first-run setup + admin invites), admin-only security surfaces. | +| `docs/features.md` | One-line touch if wording references self-registration. | +| `docs/features/crowdsec.md`, `docs/features/custom-plugins.md` / `plugin-security.md` | Note admin-only requirement (behavior clarification). | -### Alternative C — Bake binaries into a committed build artifact / Git LFS +### 2.3 External dependencies -Rejected without deep analysis: storing compiled multi-arch binaries in the repo (or LFS) defeats reproducibility, bloats history, has no scan/attestation story, and still needs a refresh mechanism. Strictly worse than A on every axis that matters here. +None new. Stdlib + existing libs only. --- ## 3. Technical Specifications -### 3.1 Target architecture +### 3.1 Part A — Close the authorization hole (advisory fix) -Extract the two expensive stages' *outputs* into a **separately-versioned, independently-scanned multi-arch prebuilt image** — `ghcr.io/wikid82/charon-toolchain` — so the `xcaddy` / CrowdSec compile happens on a **daily schedule and whenever a tracked pin moves**, not once per app build. +#### 3.1.1 Structural change in `routes.go` -**Single source of truth:** the build recipe stays in the **main `Dockerfile`**. The existing stage bodies are renamed `caddy-builder → caddy-inline` and `crowdsec-builder → crowdsec-inline`. A new thin `toolchain-runtime` stage assembles their outputs into a publishable image. The toolchain workflow builds `--target toolchain-runtime`; the app build selects between the prebuilt image and the inline stages via a build-arg. No recipe duplication. +Declare one admin subgroup on `management`, immediately after `management` is +created (`routes.go:373-374`), named for consistency with the existing +`securityAdmin` / `authenticatedAdmin`: -#### Build graph — BEFORE - -``` - ┌─────────────────────────────┐ - every app build ───────►│ caddy-builder (748 s) │──┐ - (CI: --no-cache-filter) │ xcaddy build + patch + build│ │ - └─────────────────────────────┘ │ COPY --from - ┌─────────────────────────────┐ ├──► final runtime image - every app build ───────►│ crowdsec-builder (330 s) │──┘ - (CI: --no-cache-filter) │ clone + patch + xx-go build │ - └─────────────────────────────┘ - cold compile on EVERY: docker-build (amd64+arm64), nightly, security-pr, - supply-chain-pr, e2e-tests-split, 4× integration workflows -``` - -#### Build graph — AFTER - -``` - ┌──────────────────────── toolchain image lifecycle (rare) ─────────────────────────┐ - │ trigger: daily cron | workflow_dispatch | PR touching toolchain inputs │ - │ │ - │ docker buildx build --target toolchain-runtime │ - │ --platform linux/amd64,linux/arm64 --no-cache --pull (cron/dispatch) │ - │ caddy-inline (cross-compile, no QEMU) ─┐ │ - │ crowdsec-inline (xx cross-compile, no QEMU) ─┤ │ - │ toolchain-runtime: FROM alpine; COPY both ─┘ │ - │ → push ghcr.io/wikid82/charon-toolchain:caddy-crowdsec- (+ :latest, │ - │ + :) → Trivy CRITICAL/HIGH gate → SARIF │ - │ → if new digest: bot PR bumps ARG CHARON_TOOLCHAIN_DIGEST in Dockerfile │ - └───────────────────────────────────────────────────────────────────────────────────┘ - │ digest pin (one ARG line in Dockerfile) - ▼ - ┌──────────────────────── every app build (hot path) ──────────────────────────────┐ - │ FROM ${CHARON_TOOLCHAIN_IMAGE}@${CHARON_TOOLCHAIN_DIGEST} AS toolchain-prebuilt │ - │ FROM ${CADDY_BUILDER_SRC} AS caddy-builder (default → toolchain-prebuilt) │ - │ FROM ${CROWDSEC_BUILDER_SRC} AS crowdsec-builder (default → toolchain-prebuilt) │ - │ COPY --from=caddy-builder /usr/bin/caddy ... (UNCHANGED) │ - │ COPY --from=crowdsec-builder /crowdsec-out/crowdsec ... (UNCHANGED) │ - │ normal type=gha layer cache covers every stage; NO --no-cache-filter │ - │ │ - │ fallback (fork PR / bootstrap / offline): │ - │ --build-arg CADDY_BUILDER_SRC=caddy-inline │ - │ --build-arg CROWDSEC_BUILDER_SRC=crowdsec-inline → compiles from source │ - └───────────────────────────────────────────────────────────────────────────────────┘ -``` +```go +// management: any authenticated non-passthrough user (RequireManagementAccess). +management := protected.Group("/") +management.Use(middleware.RequireManagementAccess()) -### 3.2 `Dockerfile` changes - -#### 3.2.1 New ARGs (add near the pinned-toolchain block, `:11`) - -```dockerfile -# ---- Prebuilt Caddy + CrowdSec toolchain image ---- -# Built by .github/workflows/toolchain-image.yml from the caddy-inline / -# crowdsec-inline stages below. Bumped by the open-bump-pr job (bot PR) when a -# security-relevant input moves OR the DAILY --no-cache --pull rebuild produces -# a new digest. The freshness-guard CI check (scripts/verify-toolchain-pin.sh) -# fails any PR where TAG/DIGEST is stale for the current pins. -ARG CHARON_TOOLCHAIN_IMAGE=ghcr.io/wikid82/charon-toolchain -# NOT Renovate-tracked (content-hash tag has no series to follow, N7) — the -# open-bump-pr bot in toolchain-image.yml owns these two lines. -ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-0000000000000000 -ARG CHARON_TOOLCHAIN_DIGEST=sha256: - -# Stage selector — default uses the prebuilt image; fork PRs / bootstrap / -# offline builds pass `--build-arg CADDY_BUILDER_SRC=caddy-inline -# --build-arg CROWDSEC_BUILDER_SRC=crowdsec-inline` to compile from source. -ARG CADDY_BUILDER_SRC=toolchain-prebuilt -ARG CROWDSEC_BUILDER_SRC=toolchain-prebuilt +// managementAdmin: management routes that mutate or expose privileged +// infrastructure. Deny-by-default for role=user. Mirrors securityAdmin +// (routes.go ~§"Security module enable/disable") and authenticatedAdmin +// (RegisterImportHandler). Enforcement is the ONLY guard on these routes — +// no redundant in-handler requireAdmin (see spec §3.2 Q6 ruling). +managementAdmin := management.Group("/") +managementAdmin.Use(middleware.RequireRole(models.RoleAdmin)) ``` -#### 3.2.2 Rename existing stages + close the two pin gaps (Commit 1) +Change `routes.go:838`: -- `Dockerfile:302` — `FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS caddy-builder` → `FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine@sha256: AS caddy-inline` (N4 — digest-pin the base). -- `Dockerfile:577` — same treatment for `crowdsec-builder` → `crowdsec-inline`. -- Add near `:64`, with `# renovate: datasource=go` annotations (B4): - ```dockerfile - # renovate: datasource=go depName=github.com/zhangjiayin/caddy-geoip2 - ARG CADDY_GEOIP2_VERSION= - # renovate: datasource=go depName=github.com/mholt/caddy-ratelimit - ARG CADDY_RATELIMIT_VERSION= - ``` - and change `Dockerfile:391-392` to `--with github.com/zhangjiayin/caddy-geoip2@v${CADDY_GEOIP2_VERSION}` / `--with github.com/mholt/caddy-ratelimit@v${CADDY_RATELIMIT_VERSION}` (declare both ARGs inside `caddy-inline` alongside the other `ARG CADDY_*` at `:305-310`). -- **Delete** the dead `crowdsec-fallback` stage (`:713-748`) and the now-dead `CROWDSEC_RELEASE_SHA256` ARG (`:22`, `:586`) — N1. - -Apart from the base-image digest and the two plugin `@version` suffixes, **no logic inside the two stages changes**. All `go get` patches, module-cache source patches, and embeds-version assertions are retained verbatim — they are the security recipe and the toolchain image is *the* place they now run. - -#### 3.2.3 New `toolchain-prebuilt` and `toolchain-runtime` stages - -Insert after `crowdsec-inline` (where `crowdsec-fallback` used to be, now deleted): - -```dockerfile -# ---- Prebuilt toolchain (default source for caddy-builder / crowdsec-builder) ---- -# Digest-pinned. Contains /usr/bin/caddy and /crowdsec-out/{crowdsec,cscli,config} -# at the SAME paths the inline stages produce, so the COPY --from lines in the -# final stage need no change. -FROM ${CHARON_TOOLCHAIN_IMAGE}@${CHARON_TOOLCHAIN_DIGEST} AS toolchain-prebuilt - -# ---- Toolchain image assembly target (built by toolchain-image.yml) ---- -# NOT part of the app build graph (nothing FROMs it there). `docker buildx build -# --target toolchain-runtime` produces the publishable multi-arch image. -FROM ${ALPINE_IMAGE} AS toolchain-runtime -COPY --from=caddy-inline /usr/bin/caddy /usr/bin/caddy -COPY --from=crowdsec-inline /crowdsec-out/crowdsec /crowdsec-out/crowdsec -COPY --from=crowdsec-inline /crowdsec-out/cscli /crowdsec-out/cscli -COPY --from=crowdsec-inline /crowdsec-out/config /crowdsec-out/config -# Provenance label so `docker inspect` on the toolchain image shows the key. -LABEL io.charon.toolchain.key="${CHARON_TOOLCHAIN_TAG}" - -# ---- Effective builder stages: alias to prebuilt image OR inline compile ---- -FROM ${CADDY_BUILDER_SRC} AS caddy-builder -FROM ${CROWDSEC_BUILDER_SRC} AS crowdsec-builder +```go +crowdsecHandler.RegisterRoutes(management) → crowdsecHandler.RegisterRoutes(managementAdmin) ``` -`FROM ${ARG} AS name` where the ARG resolves to a **prior stage name** is valid BuildKit; unreferenced stages (`caddy-inline` etc. when `…_SRC=toolchain-prebuilt`) are pruned from the graph and never built. When `…_SRC=caddy-inline`, `toolchain-prebuilt` is still declared but unreferenced → also pruned, so a fork build never needs to pull the image. - -**`crowdsec-fallback` (`:713-748`):** deleted in Commit 1 — it is dead code (verified, §2.1 correction). Nothing referenced it before this change. If the reviewer wants it retained as an escape hatch, it stays out of the toolchain key and out of the graph regardless. - -#### 3.2.4 Final-stage `COPY --from` lines — UNCHANGED, plus a cheap embed assertion (N5) - -`Dockerfile:807`, `:814`, `:815`, `:817` keep referencing `caddy-builder` / `crowdsec-builder` and the same source paths. This is the whole point of putting the binaries at identical paths in `toolchain-runtime`. +`CrowdsecHandler.RegisterRoutes` is unchanged (it already prefixes every route +with `/admin/crowdsec/…`). The full path set is identical; only the middleware +chain gains `RequireRole(admin)`. **No in-handler `requireAdmin` is added to +`crowdsec_handler.go`** (Q6 ruling — subgroup-only, matching `securityAdmin`). -**N5 — add a post-`COPY` assertion in the final stage.** Today the "did the binary embed the fixed cel-go / grpc-go" checks (`Dockerfile:564`, `:569`) run *inside* `caddy-inline` — so on the prebuilt path they only ever executed when the toolchain image was built, and a wrong/rolled-back `CHARON_TOOLCHAIN_DIGEST` (or a hand-edited pin pointing at an old image) would sail through the app build silently. Add a small `RUN` right after `COPY --from=caddy-builder … /usr/bin/caddy` (and the crowdsec copies): - -```dockerfile -RUN set -e; \ - caddy list-modules 2>/dev/null | grep -q 'http.handlers.rate_limit' || { echo "toolchain image missing expected caddy plugins"; exit 1; }; \ - go_ver_check() { command -v go >/dev/null && go version -m "$1" || true; }; \ - /usr/local/bin/cscli version >/dev/null || { echo "cscli from toolchain image not runnable"; exit 1; } -``` - -The final stage has no Go toolchain, so a full `go version -m` embed check is not possible there — instead assert (a) the Caddy binary loads and lists the expected custom plugins (`rate_limit`, `crowdsec`, `geoip2`, `coraza`), (b) `cscli version` runs and prints the expected `v${CROWDSEC_VERSION}`. A wrong-arch or stale-recipe image fails these immediately. The authoritative embeds-version assertions remain in `caddy-inline` and run in `toolchain-image.yml`. Additionally, a CI step in `docker-build.yml` (it already has a "Caddy/CrowdSec CVE verification" step post-build, `merge-and-publish`) runs `docker run --rm go version -m /usr/bin/caddy | grep …` against the *final* image for the full check — extend that existing step to also assert the toolchain `LABEL io.charon.toolchain.key` matches `scripts/toolchain-key.sh`. - -### 3.3 Renovate / pin-tracking - -- Every `# renovate:` annotation on the version ARGs stays. Renovate keeps bumping `CADDY_VERSION` etc. as today; the two new plugin ARGs (B4) and the digest-pinned `golang` base (N4) get annotations too. -- A Renovate bump to any of those ARGs now *also* needs a toolchain rebuild. The **freshness guard** (§3.4.2) turns that into a hard PR failure with a one-line fix (`workflow_dispatch` the toolchain workflow, or wait for the bot), so a Renovate PR that bumps `CADDY_VERSION` cannot merge with a stale toolchain. -- **N7 (corrected):** the `CHARON_TOOLCHAIN_IMAGE` / `_TAG` / `_DIGEST` three-ARG split with a **content-hash tag** (`caddy-crowdsec-`) is *not* something Renovate's `datasource=docker` manager tracks out of the box — it has no semver/digest series to follow on that tag. It simply won't fire, which is harmless: the daily rebuild + digest-bump bot (§3.4.3) is the sole authority on that pin. Do **not** add a Renovate entry implying it works; add a comment in `renovate.json` stating the toolchain digest is bot-owned. - -### 3.4 New workflow: `.github/workflows/toolchain-image.yml` - -Builds & publishes `ghcr.io/wikid82/charon-toolchain`. - -#### 3.4.1 Triggers, permissions, concurrency - -```yaml -name: Toolchain Image — Build & Publish -on: - schedule: - - cron: '0 6 * * *' # DAILY 06:00 UTC — committed scope (B2). --no-cache --pull. - workflow_dispatch: - inputs: - force_rebuild: { type: boolean, default: true, description: "Build with --no-cache --pull" } - pull_request: - paths: - - 'Dockerfile' # coarse; the key script decides if it truly changed - - '.github/workflows/toolchain-image.yml' - - 'scripts/toolchain-key.sh' - - 'scripts/verify-toolchain-pin.sh' - - 'scripts/lib/dockerfile-stage.sh' - - '.trivyignore' - # The Tuesday `security-weekly-rebuild.yml` also `workflow_call`s this workflow for the - # heavier "full Trivy report + SARIF + JSON artifact" pass; the daily `schedule` above - # is the freshness driver. Two entry points, one build definition. - workflow_call: - inputs: - force_rebuild: { type: boolean, default: true } - publish: { type: boolean, default: true } # PR path builds but does not push :latest -concurrency: - group: toolchain-image-${{ github.ref }} - cancel-in-progress: false # never cancel a publish mid-push -permissions: - contents: read - packages: write # push to GHCR - security-events: write # Trivy SARIF - pull-requests: write # bot digest-bump PR (schedule/dispatch/workflow_call only) -``` +#### 3.1.2 Companion frontend guard (prevents a `role=user` dead page) -**Daily cadence is committed scope, not optional (B2).** See §3.8 for the baseline analysis that requires it. The `schedule` trigger runs `--no-cache --pull` every day at 06:00 UTC; on a day with no digest change it is a ~30-minute no-op (acceptable — one runner, off-peak). `security-weekly-rebuild.yml` keeps its Tuesday slot for the fuller scan/report but is no longer the *only* forced-rebuild driver. +`role=user` can currently open `/security/crowdsec` (`CrowdSecConfig` page) and +its nav entry, which after Part A would 403 on every call. Add, in the same PR: -- **`pull_request` from a fork:** GitHub grants only `contents: read`, no `packages: write`. The job's publish/push steps are guarded `if: github.event.pull_request.head.repo.full_name == github.repository`. On a fork PR the workflow still *builds* `--target toolchain-runtime` (validates the recipe compiles) but does not push and does not open a bot PR. The fork's *app* build meanwhile uses the inline fallback (§3.7), so a fork PR is fully testable without the image. -- **`timeout-minutes: 45`** (cold amd64+arm64 cross-compile of both stages ≈ 25–30 min + Trivy). +- `frontend/src/App.tsx` — wrap the `security/crowdsec` route element in + `` (like `/settings/users`). +- `frontend/src/components/Layout.tsx` — gate the `navigation.crowdsec` child + entry (`:112`) with `user?.role === 'admin'` (spread-in pattern, same as + `:127` "Users"). -#### 3.4.2 Tag key derivation — `scripts/toolchain-key.sh` +#### 3.1.3 Error contract -Deterministic, content-addressed. Output: `caddy-crowdsec-<16 hex>`. +`RequireRole(models.RoleAdmin)` already returns `401 {"error":"Unauthorized"}` +(no role) / `403 {"error":"Forbidden"}` (`role=user`/`passthrough`). No +middleware change. Matches the reporter PoC's expectation of a hard `403` for a +non-admin token. -Inputs to the SHA-256: +#### 3.1.4 Regression tests — `backend/internal/api/routes/routes_test.go` (+ handler test) -1. The **exact text** of the `caddy-inline` stage (`Dockerfile` from `FROM … AS caddy-inline` to the blank line before the next `FROM`), extracted by the **shared** `extract_stage` routine in `scripts/lib/dockerfile-stage.sh` (N9 — one copy, `source`d by both `toolchain-key.sh` and `verify-toolchain-pin.sh`). -2. The **exact text** of the `crowdsec-inline` stage (same routine). -3. The resolved default values of every ARG in the §2.2 table — **including the two new `CADDY_GEOIP2_VERSION` / `CADDY_RATELIMIT_VERSION` plugin pins (B4)** — parsed from the `ARG NAME=default` lines, so a bump to `CADDY_VERSION` (or a plugin) changes the key even though the stage body only interpolates `${…}`. -4. The `tonistiigi/xx` pin line (`:73`) **and the digest-pinned `golang:${GO_VERSION}-alpine@sha256:…` base lines of both inline stages (N4)**. -5. `sha256sum .trivyignore`. -6. A `SCHEMA_VERSION` constant in the script (bump to force a global rebuild if the recipe-extraction logic itself changes). +New `TestRegister_CrowdsecAdminRoutesRequireAdminRole`: -```bash -#!/usr/bin/env bash -# scripts/lib/dockerfile-stage.sh — SHARED (N9). sourced by both scripts. -extract_stage() { # $1 = stage name, $2 = Dockerfile path - awk -v s="$1" ' - $0 ~ ("AS "s"$") {c=1} - c {print} - c && /^$/ && NR>1 {exit} - END { if (!c) { print "extract_stage: no stage \"" s "\"" > "/dev/stderr"; exit 3 } }' "$2" -} -``` - -```bash -#!/usr/bin/env bash -# scripts/toolchain-key.sh — prints the deterministic toolchain image tag. -set -euo pipefail -SCHEMA_VERSION=2 # rev-2: added plugin pins + digest-pinned golang base to the key -df="${1:-Dockerfile}" -here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/lib/dockerfile-stage.sh -source "$here/lib/dockerfile-stage.sh" - -caddy_stage="$(extract_stage caddy-inline "$df")" -crowdsec_stage="$(extract_stage crowdsec-inline "$df")" -# sanity: each stage must be non-trivial and actually build something -for s in "$caddy_stage" "$crowdsec_stage"; do - [[ "$(wc -l <<<"$s")" -ge 20 ]] && grep -q 'go build\|xx-go build' <<<"$s" \ - || { echo "toolchain-key: stage extraction looks wrong" >&2; exit 3; } -done -{ - echo "schema=$SCHEMA_VERSION" - printf '%s\n' "$caddy_stage" "$crowdsec_stage" - grep -E '^ARG (GO_VERSION|ALPINE_IMAGE|CROWDSEC_VERSION|EXPR_LANG_VERSION|XNET_VERSION|XCRYPTO_VERSION|KLAUSPOST_COMPRESS_VERSION|GRPC_VERSION|CADDY_VERSION|CADDY_CANDIDATE_VERSION|CADDY_USE_CANDIDATE|CADDY_PATCH_SCENARIO|CADDY_SECURITY_VERSION|CORAZA_CADDY_VERSION|CADDY_GEOIP2_VERSION|CADDY_RATELIMIT_VERSION)=' "$df" - grep -E 'tonistiigi/xx:|^FROM .*golang:.*-alpine@sha256:' "$df" - sha256sum .trivyignore | cut -d' ' -f1 -} | sha256sum | cut -c1-16 | sed 's/^/caddy-crowdsec-/' -``` - -Freshness guard — `scripts/verify-toolchain-pin.sh` (runs in `quality-checks.yml` on every PR, fast, no Docker build). **B7 — failure-closed on same-repo PRs:** - -```bash -#!/usr/bin/env bash -set -euo pipefail -KEY="$(scripts/toolchain-key.sh)" -PINNED_TAG="$(grep -E '^ARG CHARON_TOOLCHAIN_TAG=' Dockerfile | cut -d= -f2)" -PINNED_DIGEST="$(grep -E '^ARG CHARON_TOOLCHAIN_DIGEST=' Dockerfile | cut -d= -f2)" - -# Is this a trusted, same-repo run (has/should-have a registry-read token)? -# - push / same-repo pull_request / workflow_dispatch / schedule -> SAME_REPO=1 -# - pull_request from a fork -> SAME_REPO=0 -SAME_REPO=1 -if [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" \ - && "${GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME:-}" != "${GITHUB_REPOSITORY:-}" ]]; then - SAME_REPO=0 -fi - -if [[ "$KEY" != "$PINNED_TAG" ]]; then - echo "::error::Toolchain recipe/pins changed (recomputed $KEY, Dockerfile pins $PINNED_TAG)." - echo "::error::Run the 'Toolchain Image' workflow (workflow_dispatch) or wait for the bot PR, then bump ARG CHARON_TOOLCHAIN_TAG/DIGEST." - exit 1 -fi - -if [[ "$SAME_REPO" == "1" ]]; then - # HARD requirement: the tool AND the token must be present, and the pinned digest - # MUST resolve and MUST equal what GHCR serves for :$KEY. No silent skip. - command -v regctl >/dev/null || { echo "::error::regctl missing on a same-repo run — cannot verify digest"; exit 1; } - : "${GHCR_READ_TOKEN:?::error::GHCR_READ_TOKEN unset on a same-repo run — cannot verify digest}" - REMOTE_DIGEST="$(regctl image digest "ghcr.io/wikid82/charon-toolchain:$KEY")" \ - || { echo "::error:::$KEY does not resolve in GHCR — toolchain image was never published for this pin"; exit 1; } - if [[ "$REMOTE_DIGEST" != "$PINNED_DIGEST" ]]; then - echo "::error::Dockerfile pins $PINNED_DIGEST but GHCR :$KEY = $REMOTE_DIGEST (hand-edited or stale)." - exit 1 - fi - echo "Toolchain pin verified (same-repo): $KEY @ $PINNED_DIGEST" -else - # Fork PR: no packages:read, cannot reach GHCR. Degrade to tag-only equality - # (already checked above). The real digest check runs when a maintainer - # re-dispatches the same-repo event (see security-pr.yml workflow_run gate). - echo "::warning::Fork PR — digest existence not verified (no registry access). Tag matches recomputed key." -fi -``` - -`GHCR_READ_TOKEN` is `${{ secrets.GITHUB_TOKEN }}` (has `packages: read` for a repo-internal package once N8's package-linking is done); `regctl` is installed by the job (`ghcr.io/regclient/regctl` container or `iarekylew00t/regctl-installer`). - -- On a **same-repo PR that legitimately bumps a pin**: `toolchain-image.yml` (path trigger) builds & pushes `:`, and its `sync-pin-on-pr` job commits the `CHARON_TOOLCHAIN_TAG`/`DIGEST` bump onto the PR head branch, so the guard goes green within the same PR. -- On a **fork PR that bumps a pin**: guard fails on the tag mismatch with instructions to have a maintainer dispatch the workflow — acceptable, rare, safe. The fork's app build meanwhile uses the inline fallback (§3.7). - -#### 3.4.3 Jobs - -> **Amendment (Rev 2.1, post-approval — flagged for supervisor re-review).** -> BuildKit's default provenance / SBOM attestation manifests embed per-run -> timestamps + builder identity, so the OCI-index (manifest-list) digest of an -> otherwise byte-identical build changes on every run. Combined with -> `sync-pin-on-pr` + the path-filtered `pull_request` trigger this produced a -> self-perpetuating bot-commit loop on the feature PR. Fixes, all in this PR: -> -> 1. **Deterministic build.** `--provenance=false --sbom=false`, a **fixed** -> `SOURCE_DATE_EPOCH` (`1700000000`), and -> `--output type=image,"name=…:KEY,…:DATE,…:latest",push=true,rewrite-timestamp=true`. -> The toolchain image is an internal build *input*; the app image's own -> provenance/SBOM (in `docker-build.yml`) is separate and unaffected. Result: -> identical toolchain key ⇒ identical manifest-list digest (verified by two -> independent builds producing the same digest). -> 2. **Skip-if-already-published.** On any non-forced event (`pull_request`, -> plain path trigger) the job first `imagetools inspect`s `:${KEY}`; if it -> resolves, it SKIPS the build/push entirely and emits that existing digest. -> Only `schedule` / `workflow_dispatch force_rebuild=true` / `workflow_call` -> actually rebuild + repush. This also removes the ~30-min rebuild from -> unrelated Dockerfile PRs. -> 3. **`sync-pin-on-pr` is idempotent + self-trigger-safe:** guarded -> `github.actor != 'github-actions[bot]'` and no-ops unless -> `git diff --quiet Dockerfile` shows a real change after the sed. +| Case | Token role | Route | Expected | +|---|---|---|---| +| Control (PoC parity) | none | `POST /api/v1/admin/crowdsec/stop` | `401` | +| Escalation blocked | `user` | `POST /api/v1/admin/crowdsec/stop` | `403` | +| Escalation blocked | `user` | `GET /api/v1/admin/crowdsec/bouncer/key` | `403` | +| Escalation blocked | `user` | `POST /api/v1/admin/crowdsec/ban` | `403` | +| Escalation blocked | `user` | `GET /api/v1/admin/crowdsec/file?path=…` | `403` | +| Admin unaffected | `admin` | `GET /api/v1/admin/crowdsec/status` | not `401` / not `403` | + +Harness: `Register(ctx, gin.New(), db, cfg)`; seed a `role=user` + a +`role=admin` user; mint JWTs via +`services.NewAuthService(db,cfg).GenerateToken(&user)`; send +`Authorization: Bearer …`. Reuse the in-memory sqlite + `cfg.JWTSecret` pattern +already in `routes_test.go`. + +### 3.2 Part B — Audit & structurally harden the `management` group + +#### 3.2.1 Rulings baked in + +- **Q6 — belt-and-braces:** subgroup-only. Do **not** add in-handler + `requireAdmin` to `crowdsec_handler.go` / `plugin_handler.go`. Match + `securityAdmin` / `authenticatedAdmin` exactly. +- **Q7 — reads that back non-admin screens stay on `management`.** The frontend + exposes nearly every management page to `role=user` (§2.1). So: + classification is **mutation vs. read**, not endpoint-group. A `GET`/`list` + that a `role=user`-reachable page calls is **READ (stays on `management`)**; + its `POST`/`PUT`/`PATCH`/`DELETE` siblings move behind + `RequireRole(admin)`. Where a whole capability is infra-admin **and no + `role=user`-reachable screen consumes any of its reads**, the group moves + wholesale **and** gets a companion `RequireRole` frontend guard + nav filter + (like Part A does for CrowdSec). +- **Q8 — least-invasive split mechanism.** For routes registered inline in + `routes.go`, add `middleware.RequireRole(models.RoleAdmin)` as a per-route + 2nd handler arg (exactly like the existing `routes.go:457` + `management.GET("/settings/smtp", middleware.RequireRole(models.RoleAdmin), …)`). + Where a handler's own `RegisterRoutes(rg)` registers a mix of read and + mutation routes and only the mutations move, change that handler's signature + to `RegisterRoutes(read, admin *gin.RouterGroup)` and register each route on + the correct group. + - **`HecateHandler`, `OrthrusHandler`, `RemoteServerHandler` — read/write + split, NOT wholesale move** (C1/C2). Verified: `GET /orthrus/agents` is + consumed by `frontend/src/components/hecate/ConnectionTypeSelector.tsx` + (`useAgentList`, rendered inside the `role=user`-reachable proxy-host + create/edit flow) and `GET /hecate/status` by + `frontend/src/api/hecate.ts` (imported by `Dashboard.tsx`, route `/`, all + roles). Reads that stay on `management`: + `GET /hecate/status`, `GET /hecate/tunnels`, `GET /hecate/tunnels/:uuid`, + `GET /orthrus/agents`, `GET /orthrus/agents/:uuid`, + `GET /remote-servers`, `GET /remote-servers/:uuid`. Everything else on those + three handlers (create/update/delete/start/stop/rotate-credentials/revoke/ + provision/patch/install-snippets/proxy-status/test/provider-device + lists+sync) → `managementAdmin`. Each handler's `RegisterRoutes` takes + `(read, admin *gin.RouterGroup)`. + - **`SecurityHeadersHandler` — inline in `routes.go`, per-route args, NOT a + bespoke signature** (C6). Its ~11 routes move out of + `h.RegisterRoutes(management)` into explicit + `management.GET/POST(...)` / `managementAdmin.POST/PUT/DELETE(...)` lines in + `routes.go` (its siblings — certificates, access-lists, domains, + feature-flags — are already registered inline this way). The + `SecurityHeadersHandler.RegisterRoutes` method is removed. + - **`CrowdsecHandler`** moves wholesale (Part A) — no `role=user` read need. + - **`PluginHandler`, DNS/credential/manual-challenge, certificate, + access-list, domain, settings, feature-flags, system-repair, notification + test/preview** routes are all inline in `routes.go` → per-route + `RequireRole(admin)` args. + +#### 3.2.2 Route classification table + +Verdicts: **MOVE-GROUP** = whole registration → `managementAdmin` + companion +frontend guard (CrowdSec only) · **MOVE → `managementAdmin`** = these specific +route(s) re-registered on `managementAdmin` (a read that, on review, no +`role=user` screen needs) · **ADMIN-ARG** = keep on `management`, add per-route +`RequireRole(admin)` to the mutations, reads stay (for handlers that register +their own routes, this is a `RegisterRoutes(read, admin)` split) · **READ +(stays)** = `GET`/list that a `role=user`-reachable page consumes, no change · +**USER-OK** = stays on `management`, no change (add to the enforcement-test +allowlist if it is a non-mutating `POST`) · **KEEP (in-handler)** = already +guarded inside the handler, leave mechanism, verify test. + +> The implementing engineer MUST re-run +> `grep -n "management\.\(GET\|POST\|PUT\|PATCH\|DELETE\)\|\.RegisterRoutes(management)" routes.go` +> against HEAD at implementation time and reconcile drift with this table in the +> PR description. + +| # | Route(s) | Handler | Current guard | Verdict | Action | +|---|---|---|---|---|---| +| 1 | `POST/GET/DELETE /admin/crowdsec/*` (~45) | `CrowdsecHandler` | none | **MOVE-GROUP** | Part A: `RegisterRoutes(managementAdmin)` + frontend guard on `/security/crowdsec`. | +| 2 | `GET /admin/plugins`, `GET /admin/plugins/:id` | `PluginHandler` | none | **READ (stays)** | `/dns/plugins` page (`role=user`-reachable) lists plugins. Keep on `management`. | +| 3 | `POST /admin/plugins/:id/enable`, `/:id/disable`, `/reload` | `PluginHandler` | none | **ADMIN-ARG** | Add `middleware.RequireRole(models.RoleAdmin)` to these 3 inline registrations (`routes.go:562-565`). This closes the confirmed 2nd live instance. | +| 4 | `GET/POST/PUT/DELETE /admin/encryption/*` | `EncryptionHandler` | in-handler `isAdmin(c)` | **KEEP (in-handler)** + also move the `adminEncryption` group decl to `managementAdmin.Group("/admin/encryption")` for defense-in-depth (no behavior change; removes the "silent 200 if the in-handler check is ever dropped" risk). Verify existing tests. | +| 5 | `GET /security/status`, `/config`, `/decisions`, `/rulesets`, `/rate-limit/presets`, `/geoip/status`, `/waf/exclusions` | `SecurityHandler` (reads) | `management` | **READ (stays)** — security-posture visibility; `Security` dashboard is `role=user`-reachable. Document. | +| 6 | `securityAdmin.*` (all `POST /security/*`, module enable/disable, PATCH) | `SecurityHandler` (writes) | `securityAdmin` = `RequireRole(admin)` | **KEEP** — already correct; the template for this work. | +| 7 | `GET /security/headers/profiles`, `/profiles/:id`, `/presets`; `POST /score`, `/csp/validate`, `/csp/build` | `SecurityHeadersHandler` | `management` (`/security/headers` subgroup) | **USER-OK** — reads + pure calculators (the 3 `POST`s do not persist). `SecurityHeaders` page is `role=user`-reachable. Inline these on `management.GET/POST(...)` in `routes.go`; add the 3 calculator `POST`s to the enforcement-test allowlist. | +| 8 | `POST/PUT/DELETE /security/headers/profiles`, `POST /security/headers/presets/apply` | `SecurityHeadersHandler` | `management` | **ADMIN-ARG** — inline on `managementAdmin.POST/PUT/DELETE(...)` in `routes.go` (C6 — per-route, no bespoke 2-group `RegisterRoutes` signature; delete the `SecurityHeadersHandler.RegisterRoutes` method — its siblings are already registered inline). | +| 9 | `GET/POST/PUT/DELETE /proxy-hosts*`, bulk-update-{acl,group,security-headers} | `ProxyHostHandler` | `management` | **USER-OK** — core `role=user` capability; per-host authz via `PermittedHosts` / forward-auth. No change. | +| 10 | `GET/POST/PUT/DELETE /proxy-groups*` | `ProxyGroupHandler` | `management` | **USER-OK** — same rationale. No change. | +| 11 | `GET /remote-servers`, `GET /remote-servers/:uuid` | `RemoteServerHandler` | `management` | **READ (stays)** — proxy-host create/edit references remote servers; `RemoteServers` page is `role=user`-reachable. | +| 12 | `POST/PUT/DELETE /remote-servers*`, `POST /remote-servers/test`, `POST /remote-servers/:uuid/test` | `RemoteServerHandler` | `management` | **ADMIN-ARG** (C2) — SSH targets + credentials. `RemoteServerHandler.RegisterRoutes(read, admin *gin.RouterGroup)`: the 2 `GET`s (row 11) on `read`, these 5 on `admin`. | +| 13 | `GET /docker/containers` | `DockerHandler` | `management` | **READ (stays)** — proxy-host create picks a container. Read-only. | +| 14a | `hecate/*` — reads: `GET /hecate/status`, `GET /hecate/tunnels`, `GET /hecate/tunnels/:uuid` | `HecateHandler` | `management` | **READ (stays)** (C1) — `GET /hecate/status` is consumed by `frontend/src/api/hecate.ts` (imported by `Dashboard.tsx`, route `/`, all roles). `HecateHandler.RegisterRoutes(read, admin *gin.RouterGroup)`: these 3 on `read`. | +| 14b | `hecate/*` — mutations: tunnels create/update/delete, `:uuid/start`, `:uuid/stop`, `:uuid/rotate-credentials`, `cloudflare/tunnels`, `:uuid/config/cloudflared`, `tailscale/devices`+`sync`, `zerotier/networks`(+members), `netbird/peers`+`sync` | `HecateHandler` | `management` | **ADMIN-ARG** (C1) — tunnel-provider credentials + network topology. All non-`read` `HecateHandler` routes go on the `admin` group. Frontend: no nav/route guard change — `/hecate/tunnels` etc. stay visible to `role=user` (list loads; create/edit controls 403), same as Access Lists. | +| 15a | `orthrus/agents` — reads: `GET /orthrus/agents`, `GET /orthrus/agents/:uuid` | `OrthrusHandler` | `management` | **READ (stays)** (C1) — `GET /orthrus/agents` is consumed by `frontend/src/components/hecate/ConnectionTypeSelector.tsx` (`useAgentList`), rendered inside the `role=user`-reachable proxy-host create/edit flow. `OrthrusHandler.RegisterRoutes(read, admin *gin.RouterGroup)`: these 2 on `read`. | +| 15b | `orthrus/agents` — mutations + detail: `POST /orthrus/agents`, `PATCH /:uuid`, `DELETE /:uuid`, `POST /:uuid/revoke`, `GET /:uuid/snippets`, `GET /:uuid/proxy-status` | `OrthrusHandler` | `management` | **ADMIN-ARG** (C1) — agent provisioning = trust-boundary expansion; install snippets embed a bootstrap token. All non-`read` `OrthrusHandler` routes on the `admin` group. Frontend: keep `RequireRole allowed={['admin']}` on `/hecate/agent` + its nav child (the agent-management page is admin-only; the read used by the proxy-host form is not gated). | +| 16 | `GET /dns-providers`, `/dns-providers/types`, `/dns-providers/:id`, `/dns-providers/detection-patterns` | `DNSProviderHandler`, `DNSDetectionHandler` | `management` (inside `if cfg.EncryptionKey != ""`) | **READ (stays)** — `DNSProviders` page is `role=user`-reachable and lists providers. | +| 17 | `GET /dns-providers/:id/audit-logs` (`auditLogHandler.ListByProvider`, `routes.go:521`) | `AuditLogHandler` | `management` | **MOVE → `managementAdmin`** (C5) — same actor-PII concern as row 28. Move this single `GET` to `managementAdmin`. (`DNSProviders` page does not surface per-provider audit logs to non-admins.) | +| 17b | `POST/PUT/DELETE /dns-providers*`, `POST /dns-providers/:id/test`, **`POST /dns-providers/test`** (id-less `TestCredentials`, `routes.go:519`), `POST /dns-providers/detect`, all `/:id/credentials*` (incl. `/:cred_id/test`), `POST /:id/enable-multi-credentials`, all `/dns-providers/:id/manual-challenge(s)*` | `DNSProviderHandler`, `CredentialHandler`, `ManualChallengeHandler` | `management` | **ADMIN-ARG** (C7) — DNS API credentials + ACME control. Inline registrations → per-route `RequireRole(admin)` arg; `ManualChallengeHandler.RegisterRoutes` → pass `managementAdmin` (all 6 routes are provider-mutation-adjacent; no `role=user` read need). Name both `POST /dns-providers/:id/test` **and** `POST /dns-providers/test` explicitly. | +| 18 | `GET /certificates`, `GET /certificates/:uuid` | `CertificateHandler` | `management` | **READ (stays)** — `Certificates` page is `role=user`-reachable. | +| 19 | `POST /certificates`, `POST /certificates/validate`, `PUT /certificates/:uuid`, `POST /certificates/:uuid/export`, `DELETE /certificates/:uuid` | `CertificateHandler` | `management` | **ADMIN-ARG** — `/export` returns private-key material. Per-route `RequireRole(admin)` args. | +| 20 | `GET /access-lists`, `/access-lists/:id`, `/access-lists/templates`, `POST /access-lists/:id/test` | `AccessListHandler` | `management` | **USER-OK** — `AccessLists` page is `role=user`-reachable; `/test` is a non-persisting dry-run IP check. Reads stay; add `POST /:id/test` to the enforcement-test allowlist. | +| 21 | `POST/PUT/DELETE /access-lists*` | `AccessListHandler` | `management` | **ADMIN-ARG** — ACLs are a security control. Per-route `RequireRole(admin)` args. | +| 22 | `GET /settings`, `GET /feature-flags`, `GET /themes` | `SettingsHandler`, `FeatureFlagsHandler`, `CustomThemeHandler` | `management` | **READ (stays)** — the SPA loads these for every role (`Layout.tsx` uses `getSettings`). | +| 23 | `POST/PATCH /settings`, `PATCH /config`, `POST/DELETE /settings/logo`, `/settings/banner`, `GET/POST /settings/smtp*`, `POST /settings/validate-url`, `/settings/test-url` | `SettingsHandler` | mixed (1 `RequireRole` arg, 8 in-handler refs) | **ADMIN-ARG** — normalize: per-route `RequireRole(admin)` arg on every settings mutation + `GET /settings/smtp` (keep its existing arg). Keep in-handler checks as belt-and-braces (do not remove — they predate this and some tests assert them). | +| 24 | `PUT /feature-flags` | `FeatureFlagsHandler` | `management` | **ADMIN-ARG** — per-route arg; `GET` stays. | +| 25 | `GET/POST/PUT/DELETE /themes` | `CustomThemeHandler` | `management` | **USER-OK** — code comment: "available to all management users (not admin-only)". No change; document. | +| 26 | `backups*`, `backups/remote-targets*` | `BackupHandler`, `BackupRemoteHandler` | `management` + in-handler `requireAdmin` on every mutation | **KEEP (in-handler)** — verify each mutation path has a `requireAdmin` test; no structural move required. | +| 27 | `users*` (`GET/POST/PUT/DELETE /users`, `/invite`, `/preview-invite-url`, `/permissions`, `/resend-invite`) | `UserHandler` | `management` + in-handler `requireAdmin` (except `UpdateUser` self-service branch) | **KEEP (in-handler)** — `UpdateUser` deliberately allows a non-admin to change their own name/password, so it cannot move wholesale. Verify tests cover the admin-only branches. | +| 28 | `GET /audit-logs`, `GET /audit-logs/:uuid` (`routes.go:428-429`) | `AuditLogHandler` | `management` | **MOVE → `managementAdmin`** (C4) — audit records expose other users' emails, source IPs, and security-event detail (info disclosure to a lower-privilege role). Both `GET`s → `managementAdmin`. Frontend: wrap the `/security/audit-logs` route element in `` (`App.tsx:104`). No dedicated nav entry exists for it (`Layout.tsx` `cerberus` children do not include audit-logs), so no nav filter needed; if the `Security` dashboard renders an in-page link to it, hide that link for non-admins (optional polish). | +| 29 | `GET /domains` | `DomainHandler` | `management` | **READ (stays)** — `Domains` page is `role=user`-reachable. | +| 30 | `POST /domains`, `DELETE /domains/:id` | `DomainHandler` | `management` | **ADMIN-ARG** — per-route `RequireRole(admin)` args. | +| 31 | `system/permissions*` (`GET`, `POST /repair`), `GET /system/updates`, `GET /system/my-ip`, `POST /system/uptime/check`, `POST /system/uptime/*` | `SystemPermissionsHandler`, `UpdateHandler`, `SystemHandler` | `management` (+ 3 in-handler refs in system-permissions) | **ADMIN-ARG** for `POST /system/permissions/repair` (arg) — keep `GET /system/permissions` as READ; `GET /system/updates`, `GET /system/my-ip` **USER-OK**; `POST /system/uptime/check` **USER-OK** (observability). | +| 32 | `uptime/monitors*`, `stats/*`, `cerberus/logs/ws`, `logs*`, `websocket/*` | various | `management` | **USER-OK** — observability / read. WS auth already via `AuthMiddleware`. No change; a few non-mutating `POST`s (`/uptime/sync`, `/uptime/monitors/:id/check`) — **allowlist**. | +| 33a | `notifications*` — `POST/PUT/DELETE /notifications/providers*`, `.../external-templates*` (Create/Update/Delete); `GET/PUT /notifications/settings/security` | `NotificationProviderHandler`, `NotificationTemplateHandler`, `SecurityNotificationHandler` | `management` + in-handler `requireAdmin` (verified: `notification_provider_handler.go` ×3, `notification_template_handler.go` ×3, `security_notifications.go` ×2) | **KEEP (in-handler)** — already guarded on Create/Update/Delete + settings. Verify tests. | +| 33b | `POST /notifications/providers/test` (`routes.go:658`), `POST /notifications/providers/preview` (`:659`), `POST /notifications/external-templates/preview` (`:668`) | `NotificationProviderHandler.Test`/`.Preview`, `NotificationTemplateHandler.Preview` | `management` | **ADMIN-ARG** (C3) — **verified NO in-handler `requireAdmin`** on `Test`/`Preview` (only Create/Update/Delete). These send test messages / render templates with provider config → admin-only. Add `middleware.RequireRole(models.RoleAdmin)` per-route arg. (Without this, the new enforcement test asserts 403 for `role=user` and fails with no guidance.) | +| 33c | `GET /notifications`, `POST /notifications/:id/read`, `POST /notifications/read-all` | `NotificationHandler` | `management` | **USER-OK** — per-user inbox. Allowlist the 2 read-state `POST`s. | +| 34 | `import` / NPM / JSON import (`RegisterImportHandler`) | `ImportHandler` etc. | `authenticatedAdmin` = `RequireRole(admin)` | **KEEP** — already correct. | + +**Companion frontend guards added by Part B** (mirroring the "Users" pattern — +`` on the route element + `user?.role === 'admin'` +spread on the nav entry). Required: + +- `/security/crowdsec` route + `navigation.crowdsec` nav child (Part A / row 1). +- `/security/audit-logs` route (C4 / row 28). No nav entry exists for it — + route guard only; optionally hide any in-page link from the `Security` + dashboard for non-admins. +- `/hecate/agent` route + its nav child (rows 15a/15b) — the Orthrus + *agent-management page* is admin-only; the `GET /orthrus/agents` read used by + the proxy-host form stays ungated so `ConnectionTypeSelector` still works for + `role=user`. +- `/security/encryption` route + `navigation.encryption` nav child — already + effectively admin via in-handler `isAdmin(c)`; add the guard for UX parity + (row 4). + +**NOT guarded** (pages stay visible to `role=user`; reads succeed, mutation +controls 403): Access Lists, Security Headers, DNS Providers, Certificates, +Domains, Remote Servers, and the Hecate *tunnels* page (`/hecate/tunnels`, +`/hecate/providers`). Optional follow-up ([§7](#7-remaining-open-questions)): +hide the disabled create/edit/delete controls on these pages for non-admins. +Do **not** guard `navigation.hecate` wholesale — its `remote-servers` and +`tunnels` children remain `role=user`-usable for reads. + +#### 3.2.3 Recommended structural fix (chosen) vs. alternative + +**Chosen:** one `managementAdmin := management.Group("/"); .Use(RequireRole(admin))` +subgroup (Part A) + the per-route/per-group moves in the table + a +deny-by-default enforcement test. Identical idiom to `securityAdmin` / +`authenticatedAdmin`. DRY. + +**Rejected as the sole mechanism:** a pure per-route `RequireRole` sweep with no +subgroup — that is exactly the opt-in model that produced this advisory +(`crowdsecHandler.RegisterRoutes` can't take per-route middleware without a +signature change and would still land ~45 routes on a bare group). We use the +per-route form only for the individually-registered mutations that sit next to +USER-OK reads (Q8). + +#### 3.2.4 New enforcement test — `routes_test.go` + +`TestManagementGroup_MutationsAreAdminGuarded`: ``` -build-toolchain: - - checkout - - KEY=$(scripts/toolchain-key.sh); echo to $GITHUB_OUTPUT - - Set up QEMU? NO. Set up Buildx. - - login GHCR (skip on fork) - - PLAN: forced = (schedule || force_rebuild); if !forced && same-repo && - `imagetools inspect :${KEY}` resolves -> should_build=false, reuse that digest - - if should_build: SOURCE_DATE_EPOCH=1700000000 docker buildx build - --target toolchain-runtime - --platform linux/amd64,linux/arm64 - $( forced && echo --no-cache --pull ) - --provenance=false --sbom=false - --cache-from type=gha,scope=toolchain - --cache-to type=gha,mode=max,scope=toolchain - --output type=image,"name=…:KEY,…:$(date +%Y%m%d)$( same-repo && echo ,…:latest )",push=$( same-repo && echo true || echo false via type=cacheonly ),rewrite-timestamp=true - . - - DIGEST = existing_digest (if skipped) else - $(docker buildx imagetools inspect …:${KEY} --format '{{json .Manifest}}' | jq -r .digest) - - outputs: key, digest, same_repo - -trivy-scan: - needs: build-toolchain - - trivy image --severity CRITICAL,HIGH --exit-code 1 --ignorefile .trivyignore \ - ghcr.io/wikid82/charon-toolchain@${{ needs.build-toolchain.outputs.digest }} - - trivy image --format sarif ... → upload-sarif (category: toolchain-image:trivy) - - continue-on-error on the gate step is FALSE on schedule/dispatch (must be clean), - TRUE on PR (report-only; the app-image Trivy gates still run downstream) - -sync-pin-on-pr: # pull_request && same-repo && actor != github-actions[bot] - needs: [build-toolchain] - - sed -i "s|^ARG CHARON_TOOLCHAIN_TAG=.*|ARG CHARON_TOOLCHAIN_TAG=${KEY}|" Dockerfile - - sed -i "s|^ARG CHARON_TOOLCHAIN_DIGEST=.*|ARG CHARON_TOOLCHAIN_DIGEST=${DIGEST}|" Dockerfile - - if `git diff --quiet Dockerfile`: exit 0 (no commit — idempotent) - - git commit -m "chore(docker): sync toolchain image pin to ${KEY}" && git push (to PR head branch) - - ::notice:: re-run the freshness check (GITHUB_TOKEN pushes don't re-trigger PR checks) - -open-bump-pr: # event == schedule | workflow_dispatch | workflow_call ; NEVER on pull_request - needs: [build-toolchain, trivy-scan] - if: digest changed vs Dockerfile pin - - sed -i the two ARG lines (CHARON_TOOLCHAIN_TAG, CHARON_TOOLCHAIN_DIGEST) - - docker build --check -f Dockerfile . - - peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - base: development - branch: bot/bump-toolchain-image # updated in place if already open - title: "chore(docker): refresh bundled proxy toolchain image" - labels: dependencies, automated, docker, security - body: old→new digest, Trivy CRITICAL/HIGH summary, verification checklist - - on failure: actions/github-script → open issue "🚨 Toolchain image rebuild failed" +build router via Register(...); seed role=user and role=admin; mint JWTs. +for each route in router.Routes() where path starts /api/v1/ and method ∈ {POST,PUT,PATCH,DELETE}: + if route in PUBLIC_MUTATION_ALLOWLIST: continue // login, setup, invite/accept, security/events, emergency/security-reset (NOTE: auth/register REMOVED) + if route in USER_OK_MUTATION_ALLOWLIST: continue // proxy-hosts*, proxy-groups*, themes*, security-headers calculators (POST /score,/csp/validate,/csp/build), access-lists/:id/test, uptime sync + monitors/:id/check, notifications/:id/read + read-all, user self-service (PUT /users/:id), remote-servers reads are GET (not here) + send request with a valid role=user JWT → assert 403 // deny-by-default + send request with a valid role=admin JWT → assert != 403 // admin reaches handler ``` -The bot-PR title/body must **not** name the specific CVE/dependency (CLAUDE.md `(security)` vagueness rule): "refresh bundled proxy toolchain image so the shipped Caddy/CrowdSec binaries pick up upstream fixes." - -**Trivy gate semantics (revised):** the `--exit-code 1` CRITICAL/HIGH step is **blocking** on `schedule` / `workflow_dispatch` / `workflow_call` (a known CRITICAL in the bundled binaries turns the daily run red → failure issue). On `pull_request` it is **report-only** (`continue-on-error: true`) because the app-image Trivy gates in `docker-build.yml` / `security-pr.yml` still run downstream and a contributor PR must not be blocked by a pre-existing bundled-binary finding they did not introduce. - -#### 3.4.4 Repurpose `security-weekly-rebuild.yml` (N6) - -Replace its `Build Docker image (NO CACHE)` step — which builds a `charon:security-scan-YYYYMMDD` app image **that nothing consumes** — with `uses: ./.github/workflows/toolchain-image.yml` (`workflow_call`, `force_rebuild: true`). Keep its Trivy CRITICAL/HIGH table + SARIF upload + JSON artifact + failure `::warning::` steps, re-pointed at the toolchain digest. - -- Its `permissions:` block (`security-weekly-rebuild.yml:21`, currently `contents: read`, and job-level `:36-39` `contents/packages/security-events`) **must add `pull-requests: write`** — a `workflow_call`ed workflow cannot request perms the caller did not grant, so the caller must grant everything `open-bump-pr` needs (`contents: write`, `pull-requests: write`, `packages: write`, `security-events: write`). -- Keep `TRIVY_SARIF_CATEGORY` stable to avoid duplicate code-scanning tracks; rename the value to `…:trivy-toolchain`. -- **Cadence:** the Tuesday slot stays for the fuller report; the **daily** `schedule` in `toolchain-image.yml` (§3.4.1) is the freshness driver. What the forced rebuild actually catches is stated precisely in §3.8 — **not** "upstream `go get` MVS drift" (that claim was wrong, see §3.8 / B3). - -### 3.5 Multi-arch handling (hard constraint) - -**Decision: publish a genuine multi-arch manifest list, built without QEMU via `$BUILDPLATFORM` cross-compilation.** - -Justification: -- `caddy-inline` is already `FROM --platform=$BUILDPLATFORM golang:…` + `GOOS=$TARGETOS GOARCH=$TARGETARCH go build` (CGO off). `docker buildx build --platform linux/amd64,linux/arm64` runs this stage once per target platform, all on the amd64 host; each pass emits the correct-arch `caddy`. No emulation. -- `crowdsec-inline` is `FROM --platform=$BUILDPLATFORM golang:…` + `COPY --from=xx / /` + `xx-apk add … musl` + `CGO_ENABLED=1 xx-go build`. `tonistiigi/xx` provides the cross linker/sysroot; this is exactly how CrowdSec cross-compiles today for the arm64 leg of `docker-build.yml`. No emulation. -- `toolchain-runtime` is `FROM ${ALPINE_IMAGE}` + `COPY` only — no `RUN`, so nothing arch-specific executes; BuildKit assembles one layer per platform from the matching `caddy-inline`/`crowdsec-inline` outputs. -- Result: `ghcr.io/wikid82/charon-toolchain:` is a manifest list with `linux/amd64` and `linux/arm64` children. In the app build, `FROM …@sha256: AS toolchain-prebuilt` **without** `--platform` → BuildKit auto-selects the child matching the app build's `$TARGETPLATFORM`. So `docker-build.yml`'s `build-amd64` pulls the amd64 child, `build-arm64` (QEMU) pulls the arm64 child, and each does a plain `COPY --from` instead of running the builders. -- **N2 — accurate framing:** the Caddy/CrowdSec compile was *never* QEMU-emulated on the arm64 leg — both builder stages are `FROM --platform=$BUILDPLATFORM` and always cross-compiled natively on the amd64 host. QEMU on `build-arm64` only ever executed the *final* arm64 stage's `RUN` lines (apk installs, setcap, GeoIP fetch, verification). The real win here is **no compile at all** on any app build (cold or warm, amd64 or arm64) — not "arm64 stops emulating a compile". The arm64 leg still runs its final-stage `RUN` lines under QEMU exactly as before. -- The pinned `CHARON_TOOLCHAIN_DIGEST` is the **manifest-list digest** (arch-independent), so one pin covers both arches. - -Runner cost: the toolchain workflow does ~30 min of cross-compile once a day on one `ubuntu-latest` (mostly cache-hit no-ops between pin bumps), versus today's cold ~14-min compile on effectively every app build across `docker-build` (×2 arch), `nightly-build` (daily), `security-pr`, `supply-chain-pr`, `e2e-tests-split`, and 4 integration workflows. - -### 3.6 `--no-cache-filter` retarget (Commit 1) then removal (Commit 4, R6) — exact edits - -**Two-step, per B5.** Commit 1 changes the *value* at every site from `caddy-builder,crowdsec-builder` to `caddy-inline,crowdsec-inline` (so the recurrence guard keeps invalidating the actual `RUN` layers through the rename). Commit 4 — only after `verify-toolchain-pin` is a live required check — deletes them entirely. The table below is the Commit 4 removal list; Commit 1 touches the same sites with a value change. - -| File | Edit (Commit 4 = delete; Commit 1 = retarget value first) | +- Both allowlists are committed as explicit constants with a per-entry comment — + this list **is** the deny-by-default policy and is what the supervisor + reviews. +- Routes explicitly classified ADMIN-ARG in §3.2.2 that a reviewer might + otherwise expect on the allowlist (so they are **not** allowlisted and MUST + return 403 for `role=user`): `POST /notifications/providers/test`, + `POST /notifications/providers/preview`, + `POST /notifications/external-templates/preview` (C3); + `POST /dns-providers/test` + `POST /dns-providers/:id/test` (C7); + all Hecate mutation routes and all Orthrus mutation routes (C1). If any of + these lands on the bare `management` group at implementation time the test + fails — that is the intended tripwire. +- Also update `TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist`: + remove the `POST /api/v1/auth/register` entry from its `publicMutationAllowlist` + (route no longer exists — see Part C). + +### 3.3 Part C — Retire the public registration endpoint + +#### 3.3.1 Behavior change + +| Before | After | |---|---| -| `docker-build.yml:463-464` | delete the two `--no-cache-filter …` array lines in the `build-amd64` `BUILD_CMD` | -| `docker-build.yml:549-550` | delete the two `--no-cache-filter …` lines in `build-arm64` `BUILD_CMD` | -| `docker-build.yml:392` | rewrite comment: drop "no-cache-filter passed as native buildx flags"; note toolchain image is digest-pinned so layer cache is authoritative | -| `security-pr.yml:157-164` | remove the `with: no-cache-filters:` block + its 6-line justification comment from the `build-charon-image` step | -| `supply-chain-pr.yml:252-261` | same removal | -| `e2e-tests-split.yml:224` | delete `no-cache-filters: caddy-builder,crowdsec-builder` from the `docker/build-push-action` `with:` | -| `nightly-build.yml:243` | delete `no-cache-filters: caddy-builder,crowdsec-builder` | -| `.github/actions/build-charon-image/action.yml` | remove the `no-cache-filters` input (`:11-33` decl) and the `no-cache-filters: ${{ inputs.no-cache-filters }}` passthrough (`:52`); rewrite the `description:` to state the toolchain image is prebuilt+digest-pinned and every stage is layer-cached | -| `crowdsec-integration.yml`, `waf-integration.yml`, `rate-limit-integration.yml`, `cerberus-integration.yml` | no change needed (none passes the input) — but verify after the input is deleted that the composite still resolves (it will; input had a default) | - -After removal, add to each build step (where not already present) `--build-arg CHARON_TOOLCHAIN_DIGEST` is **not** needed — the Dockerfile default is authoritative. CI passes nothing extra on the happy path. - -### 3.7 Fork PR / bootstrap / offline fallback (R4, hard constraint) - -Three cases, one mechanism (`CADDY_BUILDER_SRC` / `CROWDSEC_BUILDER_SRC` build-args, §3.2.1): - -| Case | Detection | Behavior | -|---|---|---| -| **Fork PR** (no `packages: write`, cannot pull an internal image) | job-level expression `github.event.pull_request.head.repo.full_name != github.repository` sets `TOOLCHAIN_SRC=inline` | Every app-image build step passes `--build-arg CADDY_BUILDER_SRC=${{ env.CADDY_SRC }} --build-arg CROWDSEC_BUILDER_SRC=${{ env.CROWDSEC_SRC }}` where the two env vars are `caddy-inline`/`crowdsec-inline` on a fork and `toolchain-prebuilt`/`toolchain-prebuilt` otherwise. Full from-source compile (~14 min). Layer cache (`type=gha`) still applies to the fork's own repeated runs. **Because this path exists, the job `timeout-minutes` for every fork-reachable build job stays ≥ 20 (see §3.9 / B6) — it is NOT cut to 15.** | -| **Bootstrap** (toolchain image does not yet exist) | first `toolchain-image.yml` run publishes it; until then `CHARON_TOOLCHAIN_DIGEST` is a placeholder | Commit 1 publishes the image manually (`workflow_dispatch`) and links/marks the GHCR package internal (N8) **before** Commit 2 flips the Dockerfile default. Freshness guard lands in Commit 3; the `--no-cache-filter` sites are only removed in Commit 4, after the guard is live. | -| **Local `docker build`** (dev, offline, or not logged into GHCR) | developer choice | `docker build .` uses the pinned image (one ~30 MB pull, then cached). Offline / air-gapped: `make build-offline` → `docker build --build-arg CADDY_BUILDER_SRC=caddy-inline --build-arg CROWDSEC_BUILDER_SRC=crowdsec-inline .`. | - -**Security non-regression:** the release/CVE-gate paths — `docker-build.yml` (amd64+arm64), `nightly-build.yml`, `security-pr.yml`, `supply-chain-pr.yml` — always use the default (`toolchain-prebuilt`, digest-pinned, **daily-`--no-cache --pull`-rebuilt-and-scanned**). Fork PRs use `caddy-inline`, which is byte-for-byte the same recipe (same `go get pkg@fixed` lines, same embeds-version assertions) — a fork build is *not weaker*, just slower and unpinned. A fork PR cannot merge without a maintainer re-running the trusted same-repo path (`security-pr.yml` already gates this via its `workflow_run` trust-boundary check at `:146`), at which point the real prebuilt+scanned image is exercised. - -### 3.8 Security-guarantee analysis (CVE-2026-84304-class recurrence) - -#### 3.8.1 The true current baseline (B2 — corrected) - -Rev 1 understated this. Today, `--no-cache-filter caddy-builder,crowdsec-builder` forces a from-scratch rebuild of the two builder stages: - -- on **`nightly-build.yml`** — `schedule: '0 9 * * *'`, i.e. **daily**, and it builds the *shipped* `nightly` multi-arch image (`nightly-build.yml:229-243`); -- on **every** `docker-build.yml` release build (push to `main`/`development`/`nightly`, every version tag); -- on **every** `security-pr.yml` / `supply-chain-pr.yml` CVE-gate run (per PR); -- on **every** `e2e-tests-split.yml` image build (per PR / per run). - -So the effective current cadence at which the bundled Caddy/CrowdSec binaries are recompiled from source (re-running every `go get pkg@fixed`, re-resolving `go mod tidy`, re-pulling base images via the accompanying `--pull`) is **at least daily, and in practice several times a day on active days**. The weekly `security-scan-YYYYMMDD` image is a *scan* artifact, not the only rebuild. - -#### 3.8.2 What actually changes, and why the new cadence is acceptable - -After this change the forced-rebuild driver is the **daily** `schedule` on `toolchain-image.yml` (§3.4.1) plus per-PR rebuilds whenever a tracked pin moves. Refresh latency for the *shipped* image becomes: `daily toolchain rebuild` → `bot PR` → `human merge of bot PR` → next app build picks up the new digest. - -| Property | Today | After | +| `POST /api/v1/auth/register {email,password,name}` → `201` (first caller `role=admin`, rest `role=user`) | Route does not exist → **`404`**. | +| First admin via `POST /api/v1/setup` | **Unchanged.** | +| Additional users: (undocumented) public register, or admin `POST /users` / `POST /users/invite` → `/accept-invite` | **Only** admin `POST /users` (direct create) or `POST /users/invite` → `GET /invite/validate` → `POST /invite/accept` (`/accept-invite` page). | + +No behavior change to `/setup`, `/users*`, `/invite/*`, `/auth/login`, +`/auth/logout`, `/auth/refresh`, `/auth/me`, `/auth/change-password`. + +#### 3.3.2 Backend deletions & edits (exact) + +**Delete:** + +- `backend/internal/api/routes/routes.go:295` — the line + `api.POST("/auth/register", authHandler.Register)`. +- `backend/internal/api/handlers/auth_handler.go` — `func (h *AuthHandler) Register` + (`:244-256`) and `type RegisterRequest struct` (`:238-242`). Remove any imports + that become unused as a result (compiler / `staticcheck` will flag). + +**Keep (do NOT delete — still referenced):** + +- `backend/internal/services/auth_service.go` — `func (s *AuthService) Register` + and its `count == 0 ⇒ RoleAdmin` logic. Referenced by ~28 test call sites + (`auth_service_test.go`, `middleware/auth_test.go`, + `handlers/user_integration_test.go`) as a user-creation helper. Add a doc + comment noting it is now an internal/test helper with no HTTP surface. + +**Edit (test references to the removed route):** + +- `backend/internal/api/routes/routes_test.go:162` — remove + `"/api/v1/auth/register"` from the `expectedRoutes` slice in + `TestRegister_RoutesRegistration`. +- `backend/internal/api/routes/routes_test.go:215` — remove the + `http.MethodPost + " /api/v1/auth/register": true` entry from + `publicMutationAllowlist`. +- `backend/internal/api/routes/routes_test.go:335` — change + `assert.Contains(t, routeMap, "/api/v1/auth/register")` to + `assert.NotContains(t, routeMap, "/api/v1/auth/register")` (or move the + assertion into the new Part C test, §3.3.4). +- `backend/internal/api/handlers/additional_coverage_test.go` — + `TestAuthHandler_Register_InvalidJSON` (`:717-732`, calls `h.Register(c)`): + delete this test (the handler it covers is gone). Adjust the file's imports if + needed. +- `backend/integration/crowdsec_lapi_integration_test.go:52-59` — the + `authenticate()` helper's "Register (may fail if user exists - that's OK)" + block: replace the `POST /api/v1/auth/register` call with + `POST /api/v1/setup` (same `{name,email,password}` shape; also tolerates a + "already completed" 403). Build-tagged integration test, not in default CI, + but must stay compilable/correct. + +#### 3.3.3 `util.GenerateSecureToken` promotion — **DROPPED** + +The earlier draft promoted `user_handler.go`'s `generateSecureToken()` to +`backend/internal/util` for the now-cancelled invite pool. Nothing else needs +it. **No refactor** — `generateSecureToken()` stays unexported in +`user_handler.go` exactly as-is. + +#### 3.3.4 Tests — new `backend/internal/api/routes/routes_test.go` + +`TestRegister_PublicRegistrationEndpointRemoved`: + +| Case | Request | Expected | |---|---|---| -| Bundled-binary recompile cadence (no pin moved) | daily (nightly) + per active PR | **daily** (toolchain `schedule`) | -| Latency from a new toolchain digest to it being in the shipped image | 0 (next nightly/release builds it directly) | **daily rebuild + bot-PR merge latency** (target: merge within 1 business day; the bot PR is `feat(security)`-labelled and shows in the same queue as a Renovate security bump) | -| Human step in the loop | none | **yes — a maintainer merges `bot/bump-toolchain-image`** | - -The added human-merge step is the real trade. It is acceptable because: (a) the daily rebuild + Trivy gate still *detects* a problem on the same ~24 h cadence as today — only *shipping* the fix now waits on a PR merge; (b) the bot PR is small (two ARG lines), CI-verified, and lands in the security review queue the team already watches for Renovate; (c) an urgent case is a one-click `workflow_dispatch` + expedited merge (~30 min end to end, §6); (d) the alternative — auto-committing digest bumps to `development` with no review — is worse for a security-sensitive artifact. **The daily cadence (not weekly) is therefore committed scope**, precisely so the *detection* cadence matches today's; only the merge step is new. - -#### 3.8.3 What the forced `--no-cache --pull` rebuild does and does NOT catch (B3 — corrected) +| Route gone | `POST /api/v1/auth/register {…}` (no auth) | `404` | +| Route gone (any method) | `GET /api/v1/auth/register` | `404` | +| Bootstrap intact | `GET /api/v1/setup` on empty DB | `200 {"setupRequired":true}` | +| Bootstrap intact | `POST /api/v1/setup {name,email,password}` on empty DB | `201`; a `role=admin` user exists; `caddy.acme_email` setting written | +| Bootstrap closed after first | `POST /api/v1/setup` again | `403 {"error":"Setup already completed"}` | +| Email-invite intact | admin `POST /api/v1/users/invite {email}` → `GET /api/v1/invite/validate?token=…` → `POST /api/v1/invite/accept {token,name,password}` | invite validates; acceptance `200`; the invited user is `enabled` and can `POST /api/v1/auth/login` | + +`AuthService.Register` unit tests in `auth_service_test.go` are unchanged +(the method is unchanged). + +#### 3.3.5 Frontend + +- **No new pages, routes, api modules, or hooks.** +- `frontend/src/api/*` — confirm no `auth/register` caller exists (grep already + shows none). No edit. +- `frontend/src/pages/AcceptInvite.tsx`, `frontend/src/api/users.ts`, + `frontend/src/pages/UsersPage.tsx` — unchanged by Part C. `UsersPage` remains + the admin surface for creating/inviting users. +- Optional 1-line doc/help-text touch if any onboarding copy mentions + self-signup (grep `i18n` for "register" / "sign up" in + `frontend/src/locales` — likely none; skip if absent). + +### 3.4 Data flow (after this feature) -Rev 1 claimed the weekly rebuild's "fresh `go mod tidy` MVS → new binary" catches upstream fixes to **unpinned transitive** deps. **That claim is withdrawn — it is false:** - -- `go mod tidy` / MVS is **deterministic**. It selects the *minimum* version satisfying the constraints in `go.mod`/`go.sum`. An upstream project publishing a patched `v1.2.4` does **not** cause MVS to move off `v1.2.3` unless something in the require graph raises the lower bound. "Latest patch" is not an MVS input. -- `docker buildx build --no-cache` invalidates *layer* cache. It does **not** clear the `RUN --mount=type=cache,target=/go/pkg/mod` BuildKit cache mount — the Go module cache persists across `--no-cache` builds. (`--pull` only refreshes `FROM` images.) - -**What the daily `--no-cache --pull` toolchain rebuild genuinely catches:** - -| Vector | Caught? | Mechanism | -|---|---|---| -| Upstream fix to a **pinned** dep (any §2.2 ARG, incl. the two new plugin pins, or a literal `go get x@vN` in the stage body, or the stage text itself) | ✅ per-PR | Renovate/manual bump → `toolchain-key.sh` changes → `verify-toolchain-pin` **fails the PR** until the toolchain is rebuilt and the digest synced | -| **Base-image** drift — new `golang:1.27.1-alpine` / `alpine@sha256:…` / plugin-source-image CVEs | ✅ daily | `--pull` re-resolves the `FROM` digests; with N4's digest-pinned golang base, a Renovate digest bump also trips the key | -| **Alpine package** drift in `toolchain-runtime` / final stage (`apk upgrade`) | ✅ daily (toolchain) + per-release (app `--pull`, kept) | fresh `apk` index on `--no-cache` | -| Trivy signature DB gaining a new match against an **already-shipped** bundled version | ✅ daily | Trivy runs against the toolchain digest every day; new CRITICAL/HIGH → red run + failure issue | -| Upstream security fix to a genuinely **unpinned transitive** Go dep, where nothing raises the MVS lower bound | ❌ — **same gap as today** | only closed by a human adding an explicit `go get dep@fixed` pin (the existing pattern — the stage already has ~40 such pins). Renovate's Go-module manager + the `caddy-major-monitor.yml` / dependency-review tooling surface these; this spec does not change that surface either way. | - -**Net:** the recurrence guarantee for *pinned* deps is **strengthened** (a stale pin now hard-fails a PR instead of relying on a cache-key accident). The *unpinned-transitive* gap is **unchanged** — it exists identically today and is out of scope here; the plan explicitly does not claim to close it. - -**Strengthening vs today:** the daily toolchain Trivy gate is **blocking** on `schedule`/`dispatch`/`workflow_call` (`exit-code 1`) — today's `security-weekly-rebuild.yml` has `continue-on-error: true` on its first Trivy step, so a known CRITICAL currently only produces a `::warning::`. After this change it produces a red run + a tracked GitHub issue, daily. - -**Optional further hardening (follow-up, not committed):** a twice-daily `schedule` guarded by "rebuild only if no published tag for the current key OR last publish > 12 h" — halves detection latency for modest runner cost. - -### 3.9 Timeout right-sizing (R1 side-effect) - -| Job | File:line | Now | After | Rationale | -|---|---|---|---|---| -| `build-amd64` | `docker-build.yml:403`, `:441` | 15 / 15 | **20 / 20** | No compile on hot path; 20 gives headroom for a cold GHA cache miss on the *fast* stages + cache export + push. `docker-build.yml` never runs the inline fallback (release path, same-repo only), so 20 is safe. | -| `build-arm64` | `docker-build.yml:487`, `:527` | 25 / 25 | **keep 25** | QEMU still runs the final arm64 stage's `RUN` lines + `COPY` from the arm64 toolchain child; 25 stays comfortable. | -| `merge-and-publish` | `docker-build.yml:582` | 10 | keep 10 | unaffected | -| `security-pr` build | `security-pr.yml:32` | 20 | **keep 20** | **B6:** this job IS fork-reachable and runs the inline compile (~14 min) on a fork PR → 14 + checkout + Trivy + overhead would blow a 15-min cap. Keep 20. Update the `:32` comment to: "20m — warm same-repo build ~6–8 m; fork PRs compile the toolchain inline (~14 m + scan), which sets the floor." | -| `supply-chain-pr` build | `supply-chain-pr.yml:34` | 20 | **keep 20** | same reasoning; update comment `:34` identically. | -| `cerberus/crowdsec/waf/rate-limit-integration` | each `:29` | 20 | **keep 20** | fork-reachable + integration test work on top; 20 still right. Update the "first run … full cold build" comments to: "fork PRs build the toolchain inline; same-repo runs `COPY` it from the pinned image". | -| `e2e-tests-split.yml` build job | `:252` etc. | 60 | keep 60 | already generous; fork inline compile fits easily. | -| `toolchain-image.yml` | new | — | **45** | cold amd64+arm64 cross-compile of both stages + Trivy | -| `security-weekly-rebuild.yml` | `:35` | 60 | keep 60 (now mostly the `workflow_call` to toolchain-image) | - -**B6 reconciliation, explicit:** §3.7 establishes that `security-pr.yml`, `supply-chain-pr.yml`, and the four `*-integration.yml` jobs run the ~14-minute inline compile on fork PRs. Therefore **no job reachable by a fork inline build has its timeout cut**. Only `build-amd64` (release path, same-repo-only, never inline) is raised 15→20. If the team later wants tighter same-repo feedback, the reduction can be made conditional: `timeout-minutes: ${{ github.event.pull_request.head.repo.full_name == github.repository && 15 || 20 }}` — noted as an option, not adopted now (keeps the YAML simpler and 20 min idle-capacity cost is negligible). - -**Stale-comment fixes:** `docker-build.yml:381` ("amd64's fast native build") — reword: the Caddy/CrowdSec compile now lives in the prebuilt toolchain image, and the arm64 builder stages were always cross-compiled (never QEMU) regardless. Fix the dangling `docs/plans/current_spec.md §1.1` cross-reference in the same comment block (it points at the retired uptime spec) to cite this document. Grep `.github/**` for `no-cache-filter` / `xcaddy` / `10-14m` / `12-14 min` / `cold build` / `full cold build` and reconcile every comment (Commit 6). +``` +First run (no users) + │ POST /api/v1/setup {name,email,password} + ▼ +api (public) → UserHandler.Setup → tx{ INSERT users(role=admin, enabled=true) ; upsert Setting caddy.acme_email } + ▼ 201 + +Add a user (admin only) + │ admin → POST /api/v1/users {email,name,password,role?} (direct) + │ or → POST /api/v1/users/invite {email,role?} → email/link → /accept-invite?token=… → POST /api/v1/invite/accept + ▼ UserHandler.CreateUser / InviteUser / AcceptInvite (all existing, unchanged) + +Removed + │ POST /api/v1/auth/register … + ▼ 404 (route deleted) + +Attacker with a role=user token (however obtained) + │ POST /api/v1/admin/crowdsec/stop → management → managementAdmin → RequireRole(admin) → 403 (Part A) + │ POST /api/v1/admin/plugins/x/enable → RequireRole(admin) arg → 403 (Part B #3) + │ POST /api/v1/certificates/x/export → RequireRole(admin) arg → 403 (Part B #19) + │ GET /api/v1/certificates → management → 200 (READ stays — non-admin page needs it) (Part B #18) +``` -### 3.10 Error handling / edge cases +### 3.5 Error handling & edge cases -| Scenario | Handling | +| Case | Handling | |---|---| -| Toolchain image pull fails mid app-build (GHCR outage) | app build fails fast with BuildKit's `failed to resolve source` — no silent fallback to a stale local layer. CI: `nick-fields/retry` already wraps `build-amd64`/`build-arm64` (3× / 10s). Document: maintainers can re-run or pass the inline build-args. | -| `regctl` not on runner | every workflow that runs `verify-toolchain-pin.sh` installs `regctl` first (`iarekylew00t/regctl-installer` or `docker run ghcr.io/regclient/regctl`). **B7:** on a same-repo run the script `exit 1`s if `regctl` or `GHCR_READ_TOKEN` is missing — it does **not** silently skip. Only a fork PR (`SAME_REPO=0`) degrades to tag-only comparison, with a `::warning::`. | -| Two toolchain builds race (dispatch + path-trigger on same commit) | `concurrency: toolchain-image-${{ github.ref }}`, `cancel-in-progress: false` → serialized; second is a cache hit / no-op. | -| Bot PR already open | `peter-evans/create-pull-request` updates the existing `bot/bump-toolchain-image` branch in place (same as GeoLite2 bot). | -| `toolchain-key.sh` awk stage-extraction breaks if a future edit removes the blank line between stages | script asserts each `extract_stage` returned ≥ 20 lines and contains `go build`; exits non-zero with a clear message otherwise. Unit-tested (§7). | -| Digest pinned but tag `:latest` moved (someone pushed manually) | guard compares against `:${KEY}` (content tag), never `:latest`; manual `:latest` pushes are cosmetic. | -| `CADDY_USE_CANDIDATE=1` experiment build | changes `toolchain-key.sh` output (ARG is in the hashed set) → distinct tag → distinct image; experiment is isolated, never collides with the mainline pin. | -| Renovate bumps `ghcr.io/wikid82/charon-toolchain` digest directly | harmless; guard still requires `TAG == key`, so a digest-only Renovate bump without a matching key change fails the guard and is closed in favor of the bot PR. Document in `renovate.json` a `packageRules` comment. | -| arm64 toolchain child missing (build published amd64-only by mistake) | app `build-arm64` fails at `FROM …@` with "no match for platform" — loud. `toolchain-image.yml` asserts `regctl manifest get` lists both platforms before pushing `:latest`. | +| `POST /auth/register` after deploy | `404` (Gin default no-route). Covered by test. | +| Client / script still POSTing `/auth/register` | Gets `404`; must switch to `/setup` (bootstrap) or admin invite. Called out in `ARCHITECTURE.md` + release notes. | +| `/setup` on an already-bootstrapped instance | `403 {"error":"Setup already completed"}` (existing logic, unchanged). | +| Concurrent `/setup` calls on empty DB | Existing `isSetupConflictError` / post-tx count re-check handles it (unchanged). | +| Removing `RegisterRequest` leaves an unused import in `auth_handler.go` | `goimports` / `staticcheck` catches; remove in the same commit. | +| `additional_coverage_test.go` import set after deleting the test | Adjust; `go build ./...` + `go vet` verify. | +| A moved route (Part B) that a `role=user` UI screen actually needs | Prevented by the mutation-vs-read classification (Q7) + frontend E2E asserting `role=user` still `200`s on the READ endpoints + still loads the non-gated pages. | +| `role=user` opens a page whose *mutations* now 403 (Access Lists, Security Headers, DNS Providers, Certificates, Domains, Remote Servers, Hecate tunnels) | Page loads (reads succeed — verified consumed by `role=user` screens); create/edit/delete return 403 `{"error":"Forbidden"}`. Acceptable; optional follow-up to hide the buttons ([§7](#7-remaining-open-questions)). | +| `role=user` opens an admin-only page (CrowdSec, Audit Logs, Orthrus agent-management, Encryption) | Companion `RequireRole` guard redirects them away; nav entry hidden where one exists — same UX as "Users" today. No dead page. | +| `ConnectionTypeSelector` / Dashboard hecate widget for `role=user` after Part B | Their reads (`GET /orthrus/agents`, `GET /hecate/status`, `GET /hecate/tunnels`) stay on `management` — verified they still return `200`. E2E asserts this. | +| GORM security scan | No model / query changes in this feature → `scripts/scan-gorm-security.sh` is N/A, but run it anyway if any handler file under `backend/internal/models/**` is touched (none expected). | +| Migration impact | None — no schema change. | --- ## 4. Implementation Plan -The phases map 1:1 onto the Commit Slicing Strategy (§12); this is the same plan viewed as work packages. - -### Phase 1 — "Spec behavior": key/guard scripts + toolchain workflow + stage split (Commit 1) - -- `scripts/lib/dockerfile-stage.sh` (shared `extract_stage`), `scripts/toolchain-key.sh`, `scripts/verify-toolchain-pin.sh`, with **bats unit tests** under `scripts/tests/` (house style: `scripts/*.sh` + a `bats` runner; add `shellcheck` + `bats` to the fast-lint set). -- No E2E/Playwright surface — this is CI/build infra. The executable "spec behavior" is: `toolchain-key.sh` is stable across a no-op Dockerfile reformat and changes when a tracked ARG / plugin pin / golang-base digest / stage line / `.trivyignore` changes; `verify-toolchain-pin.sh` is failure-closed on same-repo runs (B7). -- `.github/workflows/toolchain-image.yml` (daily `schedule` + `workflow_dispatch` + `pull_request` paths + `workflow_call`; build/publish + trivy-scan only). -- `Dockerfile`: rename stages, delete dead `crowdsec-fallback`, pin the two plugins + the golang base digest, add `toolchain-runtime` + temp aliases; **retarget the no-cache filters to `caddy-inline`/`crowdsec-inline`**. -- Manual `workflow_dispatch` first publish → capture `:` + manifest-list digest; set GHCR package **Internal** (N8). - -### Phase 2 — Consume the pinned image; wire the fallback selector (Commit 2) - -- `Dockerfile`: `CHARON_TOOLCHAIN_*` ARGs + `toolchain-prebuilt` + `FROM ${…_SRC} AS caddy-builder`. -- Fork-detection build-args on every app-image build step + **new `builder-src` input on the `build-charon-image` composite** (§3.7, N10). -- `Makefile` `build-offline`. - -### Phase 3 — Guardrails: freshness + app-side assertions (Commit 3) - -- `verify-toolchain-pin` → required check in `quality-checks.yml` (with `regctl` + `GHCR_READ_TOKEN`). -- `sync-pin-on-pr` + `open-bump-pr` jobs in `toolchain-image.yml`. -- N5 final-stage `RUN` assertions; extend `docker-build.yml`'s post-build verification to check the toolchain `LABEL` key. - -### Phase 4 — Remove the forced rebuilds; reroute the security scan (Commits 4–5) - -- Delete every `--no-cache-filter` / `no-cache-filters` + the composite `no-cache-filters` input (Commit 4) — only after Phase 3's guard is live. -- Repurpose `security-weekly-rebuild.yml` → `workflow_call` into `toolchain-image.yml`; blocking Trivy on `schedule`/`dispatch`/`workflow_call`; caller `permissions:` gains `contents: write` + `pull-requests: write` (N6) (Commit 5). - -### Phase 5 — Timeouts, comment sweep, docs (Commit 6) - -- Timeout edits (§3.9 — only `build-amd64` 15→20; CVE-gate jobs stay 20 per B6), stale-comment reconciliation. -- `ARCHITECTURE.md`, `SECURITY.md`/`docs/security.md`, new `docs/ci/toolchain-image.md`, `CONTRIBUTING.md`, `renovate.json` (§9). +### Phase 1 — E2E specs (behavior, as `test.fixme`) + +- `tests/security-enforcement/crowdsec-admin-authz.spec.ts` (new) — `role=user` + → `403` on `/admin/crowdsec/stop`, `/bouncer/key`, `/ban`, `/file`; + unauthenticated → `401`; `role=admin` → not `403`. +- Extend `tests/security-enforcement/authorization-rbac.spec.ts` — + `role=user` → `403` on: `POST /admin/plugins/:id/enable`, + `POST/DELETE /remote-servers*` (+ `POST /remote-servers/test`), + Hecate mutations (`POST /hecate/tunnels`, `POST /hecate/tunnels/:uuid/start`, + `POST /hecate/tailscale/sync`), Orthrus mutations (`POST /orthrus/agents`, + `DELETE /orthrus/agents/:uuid`, `GET /orthrus/agents/:uuid/snippets`), + `POST/PUT/DELETE /dns-providers*`, `POST /dns-providers/test`, + `POST /notifications/providers/test`, `POST /notifications/providers/preview`, + `POST /certificates/:uuid/export`, `POST/PUT/DELETE /access-lists*`, + `POST/DELETE /domains*`, `POST/PATCH /settings`, `GET /audit-logs`, + `GET /dns-providers/:id/audit-logs`; + `role=user` still `200` on `GET /proxy-hosts`, `GET /settings`, + `GET /themes`, `GET /certificates`, `GET /access-lists`, `GET /dns-providers`, + `GET /hecate/status`, `GET /hecate/tunnels`, `GET /orthrus/agents`, + `GET /remote-servers`. +- `role=user` navigating directly to `/security/crowdsec`, + `/security/audit-logs`, `/hecate/agent`, `/security/encryption` is redirected + (companion `RequireRole` guards); those nav entries are absent for `role=user` + where a nav entry exists. +- `tests/security-enforcement/public-registration-removed.spec.ts` (new) — + `POST /api/v1/auth/register` → `404`; `/setup` bootstrap still works on a + fresh instance; existing email-invite acceptance flow + (`/users/invite` → `/invite/validate` → `/invite/accept` → login) still works. +- All `test.fixme` until Phase 2/3 land; un-fixme in Phase 4. +- **Dropped from the earlier plan:** `invite-registration.spec.ts`. + +### Phase 2 — Backend + +- **Commit 2 (Part A):** `managementAdmin` subgroup decl; + `crowdsecHandler.RegisterRoutes(managementAdmin)`; companion frontend guard + (`/security/crowdsec` route + nav); `routes_test.go` regression (§3.1.4). + `fix(security):`. +- **Commit 3 (Part B):** re-run audit; apply the §3.2.2 table: + - Wholesale: CrowdSec (done in Commit 2). + - `RegisterRoutes(read, admin)` split: `HecateHandler`, `OrthrusHandler`, + `RemoteServerHandler` (reads listed in rows 11/14a/15a stay on `management`; + all other routes → `managementAdmin`). + - `SecurityHeadersHandler`: **delete** its `RegisterRoutes` method; register + its ~11 routes inline in `routes.go` (reads + 3 calculators on `management`, + profile mutations + `presets/apply` on `managementAdmin`). + - Per-route `RequireRole(admin)` args: plugin enable/disable/reload; + dns-provider mutations + `POST /dns-providers/test` + `POST /dns-providers/:id/test` + + credentials + `POST /dns-providers/detect`; `ManualChallengeHandler.RegisterRoutes(managementAdmin)`; + certificate mutations incl. `/export`; access-list mutations; domain + mutations; settings mutations (+ keep existing `GET /settings/smtp` arg); + `PUT /feature-flags`; `POST /system/permissions/repair`; + `POST /notifications/providers/test` + `/preview` + `/external-templates/preview`. + - `MOVE → managementAdmin`: `GET /audit-logs`, `GET /audit-logs/:uuid`, + `GET /dns-providers/:id/audit-logs`; `adminEncryption` group decl → + `managementAdmin.Group("/admin/encryption")` (defense-in-depth). + - Companion frontend guards: `` on + `/security/audit-logs`, `/hecate/agent` (+ nav child), + `/security/encryption` (+ nav child); `/security/crowdsec` already done. + - `TestManagementGroup_MutationsAreAdminGuarded` + `USER_OK_MUTATION_ALLOWLIST` + + `PUBLIC_MUTATION_ALLOWLIST` (reviewed constants); update + `TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist`. + `fix(security):`. +- **Commit 4 (Part C):** delete `/auth/register` route + `AuthHandler.Register` + + `RegisterRequest`; keep `AuthService.Register`; update the 4 backend test + references + the integration-test helper; new + `TestRegister_PublicRegistrationEndpointRemoved`. `fix(security):`. + +### Phase 3 — Frontend + +Rolled into Commits 2 & 3 (the companion `RequireRole` guards + nav filters are +small and belong with the backend change that necessitates them). No standalone +frontend commit — there is no new UI in this feature. + +### Phase 4 — Integration, hardening, docs + +- **Commit 5:** un-`fixme` the Phase 1 specs; run targeted specs (firefox). + File a follow-up issue for a general per-IP auth throttle middleware (out of + scope — noted, not built). Update `ARCHITECTURE.md`, `SECURITY.md`, + `docs/security.md`, `docs/features/access-control.md`, `docs/features.md`, + `docs/features/crowdsec.md`, `docs/features/custom-plugins.md` / + `plugin-security.md`. `docs:`. --- ## 5. Acceptance Criteria (Definition of Done) -1. **No app-image build compiles Caddy/CrowdSec on the happy path.** A CI `build-amd64` run with a warm cache shows no `xcaddy`/`go build … caddy`/`xx-go build … crowdsec` step; total job < 8 min. Verified from the run log in the PR. -2. **`build-amd64` / integration jobs no longer time out** across 3 consecutive CI runs on the PR (main gate that this feature exists to fix). -3. **`verify-toolchain-pin` is a required check** and: (a) passes on `main` HEAD, (b) **fails** on a deliberate commit that bumps `CADDY_VERSION` (or a plugin pin) without rebuilding, (c) **fails** on a deliberate commit that hand-edits `CHARON_TOOLCHAIN_DIGEST` to a wrong-but-valid digest on a same-repo run (proves B7 failure-closed — not a tag-only check), (d) passes again after `sync-pin-on-pr` runs. Demonstrated with temporary commits that are then reverted. -4. **Multi-arch intact:** `docker buildx imagetools inspect ghcr.io/wikid82/charon-toolchain:` lists `linux/amd64` + `linux/arm64`; the merged app image manifest still lists both; `docker run --rm --platform linux/arm64 /usr/bin/caddy version` and `… cscli version` succeed (in `docker-build.yml`'s existing verification step). Confirm the arm64 leg still runs only its final-stage `RUN` under QEMU (N2 — it never compiled the builders). -5. **Fallback works:** a CI leg builds the app image with `--build-arg CADDY_BUILDER_SRC=caddy-inline --build-arg CROWDSEC_BUILDER_SRC=crowdsec-inline` and passes the in-`caddy-inline` embeds-version assertions; a simulated fork run stays under the 20-min job cap (B6). -6. **Security guarantee mechanized:** the daily `schedule` on `toolchain-image.yml` runs `--no-cache --pull` + a **blocking** Trivy CRITICAL/HIGH gate; a wired failure issue; a new digest opens `bot/bump-toolchain-image`. `security-weekly-rebuild.yml` routes through the same `workflow_call`. §3.8.3's "does NOT catch unpinned-transitive MVS drift" statement is reflected verbatim in `SECURITY.md` (no overclaim). Dry-run via `workflow_dispatch` on the PR branch. -7. **No `--no-cache-filter` / `no-cache-filters` string remains** under `.github/workflows` or `.github/actions` (grep clean; `docs/` history excepted). The composite action no longer exposes a `no-cache-filters` input. -8. **All existing CI green:** `docker-build.yml`, `nightly-build.yml` (dispatch), `security-pr.yml`, `supply-chain-pr.yml`, `e2e-tests-split.yml`, 4× integration workflows pass on the PR. -9. **Backend/frontend untouched:** `cd backend && go build ./... && go test ./...` and `cd frontend && npm run build && npm run type-check` unaffected (no diff there). GORM security scan **N/A** (no `backend/internal/models/**` change). -10. **`ARCHITECTURE.md` updated** (§9) and `docs/` build/CI docs updated; `docs-writer` pass done. -11. **Trivy on the final app image** (existing `merge-and-publish` step) shows **no new** CRITICAL/HIGH versus the pre-change baseline — the bundled binaries are the same recipe. -12. Lefthook / staticcheck / `make lint-fast` clean; shell scripts pass `shellcheck` (add to `lefthook` if not already). - ---- - -## 6. Risks & Rollback - -| Risk | Likelihood | Impact | Mitigation | Rollback | -|---|---|---|---|---| -| Toolchain image stale vs an urgent 0-day; bot-PR-merge latency too slow | Low | High | daily rebuild + Trivy **detects** on ~24 h cadence (unchanged from today); urgent path = `workflow_dispatch` + expedited merge (~30 min); follow-up twice-daily toggle (§3.8.3) | n/a — detection cadence matches today; only the merge step is new (§3.8.2) | -| `FROM ${ARG} AS name` selector unsupported on a pinned BuildKit | Low | Med | verified against BuildKit ≥ 0.11 (repo uses current `buildx`); `docker build --check` + `--print` in Commit 1/2 gates | drop the selector; make `caddy-inline`/`crowdsec-inline` the direct stage names and gate the prebuilt image behind an explicit per-workflow `--build-arg` | -| GHCR outage blocks all builds (new hard dependency) | Low | High | `nick-fields/retry` wraps the release builds; documented inline fallback; Docker Hub mirror is a follow-up | flip the selector build-args to `caddy-inline` fleet-wide via a one-line workflow edit | -| `toolchain-key.sh` false-negative (misses a security-relevant change) | Med | High | key hashes full stage **text** + all consumed ARGs (incl. the 2 plugin pins) + golang-base digest + `.trivyignore` + `SCHEMA_VERSION`; bats tests; the **daily** `--no-cache --pull` rebuild is the base-image/`apk` backstop even if the key never moves (it is NOT a backstop for unpinned-transitive MVS — §3.8.3) | bump `SCHEMA_VERSION` → global rebuild + re-pin | -| Bot PR churn (digest bump when nothing meaningful changed) | Low | Low | `open-bump-pr` fires only when the **manifest-list digest** actually changes; a no-op day (same base digests, same `apk` index) reproduces the same digest → no PR | close PR; tune to "digest changed AND (Trivy delta OR >7 d since last bump)" | -| Fork PRs slower (full inline compile) | High (every fork PR) | Low | expected; fork CI already runs long; CVE-gate job timeouts stay at 20 (B6); documented in `CONTRIBUTING.md` | none needed | -| Timeout bump to `build-amd64` masks a real slowdown | Low | Low | AC #1 asserts < 8 min actual; a run > 12 min is investigated | revert timeout to 15 | -| Human forgets to merge the bot PR for days | Med | Med | bot PR carries the `security` label → shows in the same queue as Renovate security bumps; `repo-health.yml`/stale-bot surfaces it; runbook says target ≤ 1 business day | expedite; or `workflow_dispatch` + merge | - -**Whole-PR rollback:** revert the single merged commit. The `charon-toolchain` package stays in GHCR (harmless, unreferenced; `container-prune.yml` ages it out). `security-weekly-rebuild.yml` returns to building the throwaway scan image; the Dockerfile returns to inline `caddy-builder`/`crowdsec-builder` + `--no-cache-filter` — **security posture identical to today**. No data migration, no runtime change; app image content byte-identical (same recipe). - -**Contingency:** if the selector-stage approach hits a BuildKit bug in one workflow only, that workflow can pin `--build-arg CADDY_BUILDER_SRC=caddy-inline` as a temporary per-workflow escape hatch while keeping the prebuilt default everywhere else — no revert of the whole feature. +1. **Advisory closed:** unauthenticated → `401`, `role=user` → `403`, + `role=admin` → handler executes, on `/admin/crowdsec/stop`, + `/admin/crowdsec/bouncer/key`, `/admin/crowdsec/ban`, + `/admin/crowdsec/file`. Proven by `routes_test.go` + E2E. +2. **Plugins mutations closed:** `role=user` → `403` on + `POST /admin/plugins/:id/enable|disable`, `POST /admin/plugins/reload`; + `GET /admin/plugins*` still `200` for `role=user`. +3. **Deny-by-default:** `TestManagementGroup_MutationsAreAdminGuarded` passes; + every mutating `/api/v1/*` route is admin-guarded or on a reviewed allowlist + with a per-entry comment. +4. **Public registration gone:** `POST /api/v1/auth/register` → `404` (route + absent from `router.Routes()`). +5. **Bootstrap + invites intact:** `/setup` first-admin flow succeeds on a + fresh instance and 403s afterward; email-invite + (`/users/invite` → `/invite/validate` → `/invite/accept` → login) succeeds. + Regression tests prove both. +6. **`AuthService.Register` retained** and all its existing unit tests pass + unchanged; `AuthHandler.Register` / `RegisterRequest` / the register route + are removed with no dangling references (`go build ./...`, `staticcheck`, + `go vet` clean). +7. **No `role=user` dead pages:** admin-only pages (CrowdSec, **Audit Logs**, + the Orthrus agent-management page `/hecate/agent`, Encryption) are hidden + from `role=user` in nav and redirect on direct navigation. READ-classified + pages (Access Lists, Certificates, DNS Providers, Security Headers, Domains, + Remote Servers, Hecate tunnels) still load for `role=user`, and their reads + (`GET /orthrus/agents`, `GET /hecate/status`, `GET /hecate/tunnels`, + `GET /remote-servers`, …) still return `200`. Frontend E2E covers both. +8. **Coverage:** backend ≥ 85 % (`scripts/go-test-coverage.sh`), frontend + ≥ 85 % (`scripts/frontend-test-coverage.sh`); patch coverage green + (`bash scripts/local-patch-report.sh` → `test-results/local-patch-report.{md,json}`). +9. **Security gates:** `lefthook run pre-commit` (CodeQL Go + JS) 0 + high/critical; `make trivy` clean; `make lint-fast` / staticcheck clean. + (`scripts/scan-gorm-security.sh --check` if any `models/**` file is touched — + none expected.) +10. **Targeted E2E green (firefox only):** `crowdsec-admin-authz.spec.ts`, + `authorization-rbac.spec.ts`, `public-registration-removed.spec.ts`, + `auth-api-enforcement.spec.ts`. Full-suite / cross-browser deferred to CI. +11. **Type safety / build:** `cd frontend && npm run type-check` clean; + `cd backend && go build ./...`; `cd frontend && npm run build`. +12. **Docs:** `ARCHITECTURE.md` + `SECURITY.md` reflect the new authorization + boundary and the removal of public self-registration. --- -## 7. Testing strategy (validate without a 14-min wait) +## 6. Complexity Estimates -| What | How | Where | +| Component | Complexity | Notes | |---|---|---| -| `toolchain-key.sh` determinism | shell/bats test: run twice → identical; reformat whitespace outside the stages → identical; change a `go get` line inside `caddy-inline` → differs; bump `CADDY_VERSION` default → differs; touch `.trivyignore` → differs | `scripts/tests/toolchain-key.bats`, runs in `quality-checks.yml` (< 5 s) | -| `verify-toolchain-pin.sh` | bats matrix: matching pin → exit 0; mismatched tag → exit 1 (actionable message); **same-repo run + missing `regctl`/token → exit 1** (B7 failure-closed, mocked); **same-repo run + GHCR digest ≠ pinned → exit 1**; fork run (`SAME_REPO=0`) + no registry access → exit 0 with `::warning::` | `scripts/tests/verify-toolchain-pin.bats` | -| Selector stage resolves both ways | `docker build --check` + `docker buildx build --target caddy-builder --print` (BuildKit dry-run, no compile) for both `CADDY_BUILDER_SRC` values | new `toolchain-image.yml` PR-path job, seconds | -| Cache behavior (the actual fix) | CI observation: run `build-amd64` twice on the PR; second run's log shows `CACHED` for every stage and **no** `xcaddy` / `xx-go build` compile lines; assert job wall-time < 8 min via a step that checks `$SECONDS` | PR CI, no local 14-min wait | -| Guard-live check (B5) | in Commit 1's gate: `docker buildx build --no-cache-filter caddy-inline` re-runs the `xcaddy build` step (not `CACHED`); with the old `--no-cache-filter caddy-builder` value on the renamed graph it would show `CACHED` | Commit 1 CI leg | -| Fallback correctness | one CI leg builds the app image with the inline build-args; the in-`caddy-inline` `go version -m /usr/bin/caddy | grep 'cel-go … v0.29'` / `grpc … v${GRPC_VERSION}` assertions (`Dockerfile:564`, `:569`) are the test — they already fail the build if the binary is wrong; plus the new N5 final-stage assertion | `toolchain-image.yml` PR-path matrix leg | -| Wrong-digest detection (N5) | build the app image against a deliberately old `CHARON_TOOLCHAIN_DIGEST` → N5 final-stage `RUN` fails ("missing expected caddy plugins" / bad `cscli version`) | Commit 3 CI leg | -| Multi-arch child selection | `docker buildx imagetools inspect` two-platform assertion in `toolchain-image.yml` (before pushing `:latest`); `docker run --platform linux/arm64 … caddy version` in `docker-build.yml`'s existing post-build verification | existing + new assertion | -| Daily rebuild + bot | `workflow_dispatch` `toolchain-image.yml` from the PR branch with `force_rebuild: true`; confirm it publishes, scans, and (if digest changes) opens a draft `bot/bump-toolchain-image` PR; blocking Trivy gate on the dispatch path | manual, once, during PR review | -| No regression in app-image Trivy | compare `merge-and-publish` Trivy JSON artifact on the PR vs a recent `main` run — diff must be empty for CRITICAL/HIGH | PR CI artifact | -| E2E | existing `e2e-tests-split.yml` runs unchanged against the built image; targeted local run per CLAUDE.md DoD only if a spec is touched (none is) | CI | - -**Local dev validation (fast):** `scripts/toolchain-key.sh` + `bats scripts/tests/` (seconds); `docker buildx build --target caddy-builder --print` (no compile); pulling the published toolchain image and running `docker build .` end-to-end is a ~30 MB pull + fast stages only (~4–6 min), well under the old 14-min floor. +| Part A route move + frontend guard + tests | **Low** | 2-line routing change, 1 route wrap + 1 nav filter, 1 test file. | +| Part B audit + moves + splits + enforcement test | **Medium-High** | ~35 registration sites reviewed; ~16 per-route `RequireRole` args; 3 handlers gain a `RegisterRoutes(read, admin)` split (`Hecate`, `Orthrus`, `RemoteServer`); `SecurityHeadersHandler.RegisterRoutes` deleted + inlined; `GET /audit-logs*` + 1 per-provider audit read moved; 4 companion frontend `RequireRole` guards; new enforcement test + 2 reviewed allowlists; risk of a mis-classified `role=user` read (mitigated by `frontend/src` verification + E2E). | +| Part C deletions | **Low** | Delete 1 route + 1 handler + 1 struct; keep the service; fix 4 test refs + 1 integration helper; 1 new test. | +| Docs | **Low** | | --- -## 8. Component complexity estimate - -| Component | Complexity | Notes | -|---|---|---| -| Dockerfile stage rename + delete dead `crowdsec-fallback` + pin 2 plugins + digest-pin golang base + selector + `toolchain-runtime` + N5 assertion | **M** | mostly mechanical; selector pattern needs `--check` validation; plugin/base pins need one-time version resolution; COPY paths chosen to keep final stage untouched | -| `toolchain-image.yml` | **L** | multi-arch build, GHCR push, Trivy+SARIF, `sync-pin-on-pr`, `open-bump-pr`, fork guards, `workflow_call`, daily `schedule` | -| `scripts/lib/dockerfile-stage.sh` + `toolchain-key.sh` + `verify-toolchain-pin.sh` (failure-closed) + bats | **M** | shared awk extraction; robust asserts; B7 same-repo/fork branching; token+regctl plumbing in CI | -| Retarget then strip `--no-cache-filter` across 6 workflows + composite action | **S–M** | Commit 1 retarget (value change) + Commit 4 removal + composite input deletion (public interface change) + comment rewrites | -| Repurpose `security-weekly-rebuild.yml` | **M** | swap build step for `workflow_call`; caller `permissions:` must grant `contents: write` + `pull-requests: write` (N6); keep Trivy plumbing; blocking gate | -| Fork-detection build-args in every build step **+ new `builder-src` input on the `build-charon-image` composite (public interface change, 4 integration callers)** | **M** | ~8 build steps across 6 workflows + composite input + per-caller `head.repo.full_name` expression (N10 — was S–M, raised to M) | -| Timeout + comment reconciliation | **S** | grep-driven sweep; only `build-amd64` actually changes value | -| `ARCHITECTURE.md` + docs + `docs/ci/toolchain-image.md` runbook | **S–M** | §9 list + new runbook incl. one-time package-visibility step | +## 7. Remaining open questions + +All earlier open questions and all supervisor blocking/should-fix items are +resolved and baked into the spec: + +- Invite pool dropped → Q1/Q2/Q5 moot. +- Q6 — subgroup-only, no belt-and-braces in-handler `requireAdmin`. +- Q7 / C1 / C2 — mutation-vs-read classification; Hecate / Orthrus / + RemoteServer use a `RegisterRoutes(read, admin)` split (NOT wholesale move), + reads verified against `frontend/src`. +- C3 — `POST /notifications/{providers/test,providers/preview,external-templates/preview}` + added to the table as ADMIN-ARG; §2.1 in-handler audit row corrected. +- C4 / §7.1 — **resolved in this PR**: `GET /audit-logs*` → `managementAdmin` + + `` on `/security/audit-logs`. +- C5 — `GET /dns-providers/:id/audit-logs` → `managementAdmin`. +- C6 / §7.3 — **resolved**: `SecurityHeadersHandler.RegisterRoutes` deleted, its + routes inlined in `routes.go` with per-route args (matches its siblings). +- C7 — `POST /dns-providers/test` (id-less `TestCredentials`) named explicitly, + separate from `POST /dns-providers/:id/test`. +- Q4 — per-IP auth throttle: deferred, tracking issue filed in Commit 5. + +**Only remaining item — deferred UX polish (not a blocker, tracked in Commit 5):** + +1. Hide the disabled create/edit/delete controls for `role=user` on the + READ-classified pages (Access Lists, Certificates, DNS Providers, Security + Headers, Domains, Remote Servers, Hecate tunnels). The API already enforces + `403`; this is cosmetic. Out of scope for this PR; tracking issue filed + alongside the auth-throttle issue in Commit 5. --- -## 9. `ARCHITECTURE.md` / documentation update list +## 8. Risks & Mitigations -| File | Section | Change | +| Risk | Impact | Mitigation | |---|---|---| -| `ARCHITECTURE.md` | §"Deployment Architecture / Multi-Stage Dockerfile" (`:1082`) | replace the illustrative snippet's build-from-source framing; add a "Prebuilt toolchain image" subsection: what `charon-toolchain` contains, that Caddy/CrowdSec are compiled there (not in the app build), digest-pinned in `Dockerfile`, rebuilt **daily** `--no-cache --pull`, freshness-guarded, with the fork/offline inline fallback | -| `ARCHITECTURE.md` | §"Infrastructure" table (`:158`) | add row: **Bundled proxy toolchain** — `ghcr.io/wikid82/charon-toolchain` — multi-arch prebuilt Caddy + CrowdSec, daily-rebuilt + Trivy-gated | -| `ARCHITECTURE.md` | §"Directory Structure" (`:286`) | note `.github/workflows/toolchain-image.yml`, `scripts/toolchain-key.sh`, `scripts/verify-toolchain-pin.sh`, `scripts/lib/dockerfile-stage.sh`; note removal of the `crowdsec-fallback` Dockerfile stage | -| `ARCHITECTURE.md` | §"Security Architecture / Layer 2: CrowdSec Integration" (`:780`) and the defense-in-depth intro (`:750`) | note the CrowdSec agent + bouncer-enabled Caddy are supply-chain-hardened via the scanned, digest-pinned toolchain image; recurrence guarantee = **daily** `--no-cache --pull` toolchain rebuild + blocking Trivy gate + bot PR (+ per-PR `verify-toolchain-pin` for pinned-dep bumps). Be precise per §3.8.3: it does not close the unpinned-transitive-MVS gap (unchanged from today) | -| `ARCHITECTURE.md` | §"Development Workflow / Local Development Setup" (`:1204`) | add the offline build note (`--build-arg …_SRC=…-inline`) and `make build-offline` | -| `CONTRIBUTING.md` | build section | fork PRs compile the toolchain from source (slower CI); maintainers re-dispatch for the prebuilt path | -| `docs/features.md` | — | no user-facing capability change → **no edit** (per CLAUDE.md keep brief) | -| `docs/security.md` / `SECURITY.md` | supply-chain / build integrity paragraph | describe the toolchain image, its **daily** `--no-cache --pull` rebuild + blocking Trivy gate, the digest pin, and the `verify-toolchain-pin` freshness guard as the mechanism that keeps bundled binaries patched; state the §3.8.3 scope precisely (pinned-dep + base-image drift covered; unpinned-transitive MVS gap unchanged from today) — do not overclaim | -| new `docs/ci/toolchain-image.md` | — | operator/maintainer runbook: how the key works, how to force a rebuild, how to respond to the bot PR / failure issue, how to roll back | -| `Makefile` | — | `build-offline` target | -| `renovate.json` | — | comment on the `charon-toolchain` datasource entry: digest bumps are owned by the bot workflow, not Renovate | +| A READ endpoint mis-classified as ADMIN regresses a `role=user` page | `role=user` UI breaks | Q7 mutation-vs-read rule; frontend E2E asserts `role=user` keeps `GET` access + page loads for every READ-classified area; classification table in PR description; each commit individually revertable. | +| An admin-gated capability was actually needed by `role=user` | Lost functionality for `role=user` | Only CrowdSec moves wholesale (no `role=user` read). Hecate / Orthrus / RemoteServer keep their `role=user`-consumed `GET` reads on `management` (verified: `ConnectionTypeSelector` → `GET /orthrus/agents`, `Dashboard` → `GET /hecate/status`); only mutations move. Audit Logs / Orthrus agent page / Encryption become admin-only with a companion `RequireRole` guard (explicit redirect, not a silent 403). If a real `role=user` need surfaces, revert Commit 3 alone — Commit 2 (advisory fix) still stands. | +| Removing `RegisterRequest`/`Register` leaves dangling refs | Build break | grep evidence in §2.1 enumerates every reference; `go build ./...` + `staticcheck` + `go vet` in the commit gate; integration-test helper explicitly updated. | +| `AuthService.Register` mistakenly deleted | ~28 test call sites fail to compile | Spec is explicit: **keep** it; it is not dead. | +| Advisory still private / embargoed | Disclosure via commit message / changelog | `fix(security):` subjects deliberately vague — category + mitigation only, never "CrowdSec", "authorization bypass", "public registration", or route paths (§10). No GHSA id in subjects or changelog-visible lines. | +| `publicMutationAllowlist` still lists `auth/register` after route removal | Enforcement test references a non-existent route | Commit 4 removes that entry (§3.3.2). | +| Coverage dip from the large Part B routing diff | PR fails 85 % gate | New tests target new/moved code paths; `local-patch-report.sh` preflight before pushing. | +| Companion frontend guards missed for an admin-only page | `role=user` hits a 403-ing page | E2E: for `/security/crowdsec`, `/security/audit-logs`, `/hecate/agent`, `/security/encryption`, assert a `role=user` session is redirected and (where a nav entry exists) it is absent. | +| A non-mutating `POST` (`/access-lists/:id/test`, `/security/headers/score` etc.) breaks for `role=user` because it's a POST | `role=user` diagnostic feature 403s | These are explicitly in `USER_OK_MUTATION_ALLOWLIST` (§3.2.4) and stay on `management`; the enforcement test asserts `role=user` is NOT 403 for them. | --- -## 10. API / schema impact +## 9. Commit Slicing Strategy -**None.** No REST endpoint, no GORM model, no migration, no `internal/**` code, no frontend, no DB. This is entirely CI/build-graph and repo tooling. `routes.go` AutoMigrate untouched. - ---- +**Decision:** ONE PR, merged only when the whole feature is complete and the +full Definition of Done passes. Reviewability comes from the ordered commit +sequence below — **not** from splitting into backend/frontend/security PRs. +Each commit builds and passes its own validation gate. Order follows +`CLAUDE.md` "Suggested Commit Sequence" (E2E fixme → backend → frontend → +hardening+docs); the advisory fix (Part A) is placed first after the specs so it +is independently revertable. Part C collapsed to a single deletion commit — the +5-commit plan replaces the earlier 7. -## 11. Out-of-scope / follow-ups - -- Mirror `charon-toolchain` to Docker Hub for GHCR-outage resilience. -- Twice-daily toolchain freshness trigger (§3.8.3 hardening toggle). -- Conditional `timeout-minutes` expression on `security-pr` / `supply-chain-pr` to give same-repo runs a tighter 15-min budget while forks keep 20 (§3.9 B6) — deferred to keep the YAML simple. -- Fold `gosu-builder` / `backend-builder` into the toolchain image too (they are already fast; low value). -- Cosign-sign the toolchain image and verify the signature in the app build `FROM` (needs BuildKit attestation verification; separate spec). -- **N11 (confirmation, no action):** `orthrus-build.yml` builds `./agent/Dockerfile` — a **different** image (the Orthrus agent), with its own `cache-from/to type=gha` and no `caddy-builder`/`crowdsec-builder` stages. Verified out of scope; this spec makes no change to it. +Base branch: `development`. --- -## 12. Commit Slicing Strategy - -**Decision:** one feature = **one PR** targeting `development`, sliced into 6 ordered logical commits. Each commit builds and passes its own gate; the PR merges only when the full DoD (§5) passes. Not split across multiple PRs. - -The `weekly-nightly-promotion.yml` "merge commit only" rule is **not** engaged — this PR follows the normal `development` flow and touches no promotion machinery. - -**B5 — the CVE-recurrence guard is never inert.** The old plan had a window (Commit 1→3) where the stages were renamed to `caddy-inline`/`crowdsec-inline` but the workflows still said `--no-cache-filter caddy-builder` — a filter on an *alias* node does not invalidate the `RUN` layers that moved into `caddy-inline`, so the guard was silently dead. Fixed below: **Commit 1 retargets every `--no-cache-filter` / `no-cache-filters` from `caddy-builder,crowdsec-builder` to `caddy-inline,crowdsec-inline` in the same commit as the rename**, and the freshness guard (`verify-toolchain-pin`, Commit 3) is in place **before** those filters are removed (Commit 4). Every commit's gate below explicitly checks that *some* live mechanism forces a from-source recompile when a pin/recipe changes. - -### Commit 1 — `feat(security): add toolchain-image workflow, key tooling, split builder stages` +### Commit 1 — E2E specs for new behavior (`test.fixme`) -- **Scope:** the toolchain build/publish workflow + key/guard scripts; Dockerfile stage split; **retarget the no-cache filters to the RUN-bearing stage names**; first manual publish; make the new GHCR package internal. +- **Type:** `test: add fixme e2e specs for privileged-route authz and removal of public registration` +- **Scope:** Author (as `test.fixme`) the Playwright specs for Parts A/B/C. No + product code. - **Files:** - - `scripts/lib/dockerfile-stage.sh`, `scripts/toolchain-key.sh`, `scripts/verify-toolchain-pin.sh` (new) - - `scripts/tests/toolchain-key.bats` (+ wire into `quality-checks.yml` as a **non-blocking** job for now) - - `.github/workflows/toolchain-image.yml` (new — `schedule` daily + `workflow_dispatch` + `pull_request` paths + `workflow_call`; **build/publish + trivy-scan jobs only**; `sync-pin-on-pr` / `open-bump-pr` land in Commit 3) - - `Dockerfile` — rename `caddy-builder→caddy-inline`, `crowdsec-builder→crowdsec-inline`; **delete the dead `crowdsec-fallback` stage** (`:713-748`) and its now-dead `CROWDSEC_RELEASE_SHA256` ARG (N1); **pin the two xcaddy plugins** `CADDY_GEOIP2_VERSION` / `CADDY_RATELIMIT_VERSION` (B4); **digest-pin the `golang:${GO_VERSION}-alpine` base** of both inline stages (N4); add `toolchain-runtime` assembly stage; add temporary aliases `FROM caddy-inline AS caddy-builder` / `FROM crowdsec-inline AS crowdsec-builder` so the app build is unchanged this commit. - - **All six no-cache-filter sites + composite action** (§3.6) — change the value `caddy-builder,crowdsec-builder` → `caddy-inline,crowdsec-inline` (do **not** remove yet). -- **Dependencies:** none. -- **Bootstrap / N8:** after CI publishes the first image via `workflow_dispatch`, in GHCR set the `charon-toolchain` package visibility to **Internal** (or link it to the repo and grant the repo `packages: read`) so cross-workflow `FROM ghcr.io/…/charon-toolchain@digest` works with the default `GITHUB_TOKEN`. Document this one-time manual step in `docs/ci/toolchain-image.md` (Commit 6) and in the PR description. + - `tests/security-enforcement/crowdsec-admin-authz.spec.ts` (new) + - `tests/security-enforcement/public-registration-removed.spec.ts` (new) + - `tests/security-enforcement/authorization-rbac.spec.ts` (extend: plugin + mutations, remote-server mutations, Hecate mutations, Orthrus mutations + (incl. `/snippets`), dns-provider mutations + `POST /dns-providers/test`, + notification `test`/`preview`, cert `/export`, access-list/domain/settings + mutations, `GET /audit-logs*`; + `role=user` positive READ cases incl. + `GET /orthrus/agents`, `GET /hecate/status`, `GET /hecate/tunnels`, + `GET /remote-servers`; + admin-only nav/redirect checks for + `/security/crowdsec`, `/security/audit-logs`, `/hecate/agent`, + `/security/encryption`) +- **Depends on:** nothing. - **Validation gate:** - 1. `bats scripts/tests/` green; `shellcheck scripts/*.sh scripts/lib/*.sh` clean. - 2. `scripts/toolchain-key.sh` is stable across a whitespace-only reformat outside the two stages, and **changes** when (a) a `go get` line inside `caddy-inline` is edited, (b) `CADDY_VERSION` / `CADDY_GEOIP2_VERSION` default is bumped, (c) the golang base digest changes, (d) `.trivyignore` changes. - 3. `workflow_dispatch` toolchain-image.yml on the branch → publishes `ghcr.io/wikid82/charon-toolchain:caddy-crowdsec-`; `docker buildx imagetools inspect` shows **both** `linux/amd64` and `linux/arm64`. Record `:` + manifest-list digest for Commit 2. - 4. **Guard-live check:** on a scratch build, `docker buildx build --no-cache-filter caddy-inline …` shows the `xcaddy build` step running (not `CACHED`); with the old `--no-cache-filter caddy-builder` value it would show `CACHED` — confirm the retarget is what keeps the guard effective. + `npx playwright test crowdsec-admin-authz public-registration-removed authorization-rbac --project=firefox` + collects specs, all `fixme`/skipped, 0 failures; `eslint` clean on the new + spec files. -### Commit 2 — `feat(security): build app image from the pinned toolchain image` +--- -- **Scope:** default path consumes the prebuilt image by digest; inline stages become the selectable fallback; fork detection wired. -- **Files:** - - `Dockerfile` — add `CHARON_TOOLCHAIN_IMAGE/TAG/DIGEST` ARGs (values from Commit 1's publish), `CADDY_BUILDER_SRC`/`CROWDSEC_BUILDER_SRC` selector ARGs, `toolchain-prebuilt` stage; replace the temp aliases with `FROM ${CADDY_BUILDER_SRC} AS caddy-builder` / `FROM ${CROWDSEC_BUILDER_SRC} AS crowdsec-builder`. - - **Every app-image build step** in `docker-build.yml`, `nightly-build.yml`, `security-pr.yml`, `supply-chain-pr.yml`, `e2e-tests-split.yml`, and the **`build-charon-image` composite action** (new `builder-src` input, default `toolchain-prebuilt`, with the `head.repo.full_name` expression in each caller) — pass `--build-arg CADDY_BUILDER_SRC=… --build-arg CROWDSEC_BUILDER_SRC=…` (`toolchain-prebuilt` same-repo, `caddy-inline`/`crowdsec-inline` on forks). - - `Makefile` — `build-offline` target. -- **Dependencies:** Commit 1. -- **Note on the guard in this window:** default builds no longer run `caddy-inline` at all, so the retargeted `--no-cache-filter caddy-inline` is a no-op there — **intended**: the only path that still compiles is the fork/inline path, and the filter remains live *there*. The pin↔digest binding on the default path is enforced by Commit 3's freshness guard, added before any filter is removed (Commit 4). -- **Validation gate:** `docker build --check`; `docker build .` (default) → pulls the image, **no `xcaddy`/`xx-go build` in the log**, image boots, final-stage N5 assertions pass, `caddy version` + `cscli version` OK; `make build-offline` (inline) → compiles and passes the in-`caddy-inline` embeds-version assertions **and** still honours `--no-cache-filter caddy-inline`; `docker buildx build --target caddy-builder --print` resolves for both selector values; simulated fork run (push from a fork or manual expression override) uses the inline path and stays under the 20-min job cap (B6). - -### Commit 3 — `feat(security): enforce toolchain pin freshness + app-side embed assertions` - -- **Scope:** `verify-toolchain-pin` becomes a **required** check; `sync-pin-on-pr` + `open-bump-pr` jobs added to `toolchain-image.yml`; N5 final-stage assertions; extend `docker-build.yml`'s existing post-build CVE-verification step to also check the toolchain `LABEL` key. -- **Files:** `.github/workflows/quality-checks.yml` (required `verify-toolchain-pin` job, with `regctl` install + `GHCR_READ_TOKEN`), `.github/workflows/toolchain-image.yml` (add `sync-pin-on-pr`, `open-bump-pr`), `Dockerfile` (N5 `RUN` assertion after the `COPY --from` lines), `docker-build.yml` (extend verification step), `renovate.json` (comment: toolchain digest is bot-owned, N7). -- **Dependencies:** Commits 1–2 (a real pin must exist to guard). +### Commit 2 — Part A: enforce admin authorization on CrowdSec admin routes (advisory fix) + +- **Type:** `fix(security): tighten authorization checks on privileged API routes` +- **Scope:** + - `routes.go`: declare `managementAdmin := management.Group("/"); .Use(RequireRole(admin))`; + change `crowdsecHandler.RegisterRoutes(management)` → `(managementAdmin)`. + - Frontend companion guard: wrap `security/crowdsec` route in + `` (`App.tsx`); gate the `navigation.crowdsec` + nav child with `user?.role === 'admin'` (`Layout.tsx`). + - `routes_test.go`: `TestRegister_CrowdsecAdminRoutesRequireAdminRole` (§3.1.4); + a handler-level 403 assertion in `crowdsec_handler_test.go` if lightweight. +- **Files:** `backend/internal/api/routes/routes.go`, + `backend/internal/api/routes/routes_test.go`, + `backend/internal/api/handlers/crowdsec_handler_test.go` (maybe), + `frontend/src/App.tsx`, `frontend/src/components/Layout.tsx`, + `frontend/src/components/__tests__/Layout.test.tsx` (nav-gating assertion) or + a new small `App` route test. +- **Depends on:** Commit 1 (ordering). - **Validation gate:** - 1. Temp commit bumping `CADDY_VERSION` (no rebuild) → `verify-toolchain-pin` **fails** with the actionable message; the `toolchain-image.yml` path trigger rebuilds and `sync-pin-on-pr` pushes the `TAG`/`DIGEST` bump onto the branch → check green → revert temp commit. - 2. Temp commit hand-editing `CHARON_TOOLCHAIN_DIGEST` to a valid-but-wrong digest → `verify-toolchain-pin` **fails** on the same-repo digest-mismatch branch (proves B7 failure-closed: it is not a tag-only check). - 3. `open-bump-pr` runs only on `schedule`/`workflow_dispatch`/`workflow_call`, never `pull_request` (assert via a dry `workflow_dispatch`). - 4. Build an app image against a deliberately wrong (old) toolchain digest → the N5 final-stage assertion fails the build (proves a bad pin is caught even if `verify-toolchain-pin` were bypassed). + `cd backend && go build ./... && go test ./internal/api/routes/... ./internal/api/handlers/...`; + new test proves unauth→401 / `role=user`→403 / `role=admin`→not-403 on the 4 + representative routes; existing `TestRegister_AllRoutesRegistered` / + `TestRegister_CrowdSecRoutes` still pass (paths unchanged); + `cd frontend && npm run type-check && npx vitest run src/components/__tests__/Layout.test.tsx`; + `make lint-fast`; staticcheck clean. -### Commit 4 — `perf(ci): drop the forced from-source rebuilds; rely on the pinned image + guard` +--- -- **Scope:** remove every `--no-cache-filter` / `no-cache-filters` and the composite `no-cache-filters` input — now safe because (a) the default path never compiles, (b) `verify-toolchain-pin` enforces pin↔digest freshness per PR, (c) the daily toolchain rebuild + Trivy gate covers base-image drift, (d) the N5 assertion catches a wrong digest. -- **Files:** `docker-build.yml` (`:463-464`, `:549-550`), `security-pr.yml` (`:157-164` block), `supply-chain-pr.yml` (`:252-261` block), `e2e-tests-split.yml` (`:224`), `nightly-build.yml` (`:243`), `.github/actions/build-charon-image/action.yml` (delete the `no-cache-filters` input decl `:11-33` + passthrough `:52`; rewrite `description`). -- **Dependencies:** Commit 3 (guard must be live *before* the filters go). -- **Validation gate:** `grep -rn "no-cache-filter" .github/workflows .github/actions` → empty (comments/docs excluded); `docker-build.yml build-amd64` run twice on the branch → second run every stage `CACHED`, wall-time **< 8 min**, zero compile lines; all 8 build-consuming workflows green; a bump-a-pin temp commit still fails `verify-toolchain-pin` (guard still live via the freshness mechanism, not the deleted filter). +### Commit 3 — Part B: deny-by-default authorization across the management group + +- **Type:** `fix(security): apply deny-by-default authorization on management API subroutes` +- **Scope:** + - Re-run the route audit vs HEAD; reconcile with §3.2.2. + - `RegisterRoutes(read, admin *gin.RouterGroup)` split (C1/C2): `HecateHandler` + (reads `GET /hecate/status|/tunnels|/tunnels/:uuid` on `read`, rest on + `admin`); `OrthrusHandler` (reads `GET /orthrus/agents|/agents/:uuid` on + `read`, rest incl. `/snippets`, `/proxy-status` on `admin`); + `RemoteServerHandler` (reads `GET /remote-servers|/remote-servers/:uuid` on + `read`, rest incl. `/test` on `admin`). + - `SecurityHeadersHandler` (C6): **delete** `RegisterRoutes`; register its ~11 + routes inline in `routes.go` — reads + 3 calculator `POST`s on `management`, + profile `POST/PUT/DELETE` + `presets/apply` on `managementAdmin`. + - `MOVE → managementAdmin`: `GET /audit-logs`, `GET /audit-logs/:uuid` (C4), + `GET /dns-providers/:id/audit-logs` (C5); `adminEncryption` group decl → + `managementAdmin.Group("/admin/encryption")`. + - ADMIN-ARG (per-route `middleware.RequireRole(models.RoleAdmin)` 2nd arg): + plugin enable/disable/reload; dns-provider mutations + `POST /dns-providers/test` + + `POST /dns-providers/:id/test` (C7) + credential + `POST /dns-providers/detect`; + `ManualChallengeHandler.RegisterRoutes(managementAdmin)`; certificate + mutations incl. `/export`; access-list mutations; domain mutations; settings + mutations (+ keep existing `GET /settings/smtp` arg); `PUT /feature-flags`; + `POST /system/permissions/repair`; + `POST /notifications/providers/test` + `/providers/preview` + + `/external-templates/preview` (C3). + - Frontend companion `RequireRole` guards + nav filters: + `/security/audit-logs` (route only — no nav entry), `/hecate/agent` + (route + nav child), `/security/encryption` (route + nav child). + Do **not** guard `navigation.hecate` wholesale or `/hecate/tunnels`. + - `TestManagementGroup_MutationsAreAdminGuarded` + `USER_OK_MUTATION_ALLOWLIST` + + `PUBLIC_MUTATION_ALLOWLIST` (reviewed constants). +- **Files:** `backend/internal/api/routes/routes.go` (the ~35 sites in the + table), `backend/internal/api/handlers/security_headers_handler.go` + (delete `RegisterRoutes` method + its test that asserted the old group), + `backend/internal/api/handlers/hecate_handler.go` / `orthrus_handler.go` / + `remote_server_handler.go` (`RegisterRoutes(read, admin)` signature + + callers), `backend/internal/api/routes/routes_test.go`, any handler test that + assumed a now-moved route was reachable by `role=user` + (`hecate_handler_test.go`, `orthrus_handler_test.go`, + `audit_log_handler_test.go`, `notification_provider_handler_test.go`), + `frontend/src/App.tsx`, `frontend/src/components/Layout.tsx`, related frontend + tests. +- **Depends on:** Commit 2 (`managementAdmin`). +- **Validation gate:** `go build ./... && go test ./...` (full — catches handler + tests broken by moves); new enforcement test green; manual diff of + `router.Routes()` inventory before/after (path set unchanged, only middleware + chains differ); `cd frontend && npm run type-check && npx vitest run` (touched + suites); `make lint-fast`; staticcheck clean. -### Commit 5 — `feat(security): route the security rebuild through the toolchain image` +--- -- **Scope:** `security-weekly-rebuild.yml` `workflow_call`s `toolchain-image.yml` instead of building a throwaway app image; blocking Trivy on `schedule`/`dispatch`/`workflow_call`; caller grants all perms the bot job needs (N6). -- **Files:** `.github/workflows/security-weekly-rebuild.yml` (swap build step; `permissions:` add `contents: write` + `pull-requests: write` at job level; keep Trivy table/SARIF/JSON/`::warning::`; rename `TRIVY_SARIF_CATEGORY` value to `…:trivy-toolchain`). -- **Dependencies:** Commits 1, 3. -- **Validation gate:** `workflow_dispatch` on the branch → toolchain rebuilds `--no-cache --pull`; Trivy runs; SARIF uploads under the stable category; **no** bot PR when the digest is unchanged; temporarily drop a known-ignored item from `.trivyignore` → `schedule`-path Trivy step is **red** and the failure issue is created → restore `.trivyignore`. Confirm the daily `schedule` on `toolchain-image.yml` (added Commit 1) now also produces a bot PR path via `open-bump-pr` (Commit 3) when the digest moves. +### Commit 4 — Part C: remove the public registration endpoint + +- **Type:** `fix(security): reduce unauthenticated API surface` +- **Scope:** + - Delete `api.POST("/auth/register", …)` (`routes.go:295`), + `AuthHandler.Register`, `RegisterRequest` (`auth_handler.go`). Drop + now-unused imports. + - **Keep** `AuthService.Register` (+ `count==0 → RoleAdmin`); add a doc + comment marking it internal/test-only. + - Update `routes_test.go` refs (`:162` remove from `expectedRoutes`; `:215` + remove allowlist entry; `:335` → `assert.NotContains`); delete + `TestAuthHandler_Register_InvalidJSON` in `additional_coverage_test.go`; + switch `crowdsec_lapi_integration_test.go` `authenticate()` helper to + `POST /api/v1/setup`. + - New `TestRegister_PublicRegistrationEndpointRemoved` (§3.3.4) covering + route-gone + `/setup` bootstrap + email-invite acceptance. +- **Files:** `backend/internal/api/routes/routes.go`, + `backend/internal/api/handlers/auth_handler.go`, + `backend/internal/services/auth_service.go` (doc comment only), + `backend/internal/api/routes/routes_test.go`, + `backend/internal/api/handlers/additional_coverage_test.go`, + `backend/integration/crowdsec_lapi_integration_test.go`. +- **Depends on:** Commit 2 (shares `routes_test.go` allowlist edits — sequence + after B to avoid churn). +- **Validation gate:** `go build ./...` (+ `-tags integration` compile check for + the integration file); `go test ./internal/api/...`; `staticcheck` / `go vet` + clean (no dangling refs); `AuthService.Register` unit tests unchanged & green; + `make lint-fast`. -### Commit 6 — `docs(ci): document the toolchain image; right-size timeouts; sweep stale comments` +--- -- **Scope:** timeout edits (§3.9), stale-comment sweep, all `ARCHITECTURE.md` / docs updates (§9). -- **Files:** `docker-build.yml` (`build-amd64` timeout `:403`/`:441` 15→20; comment `:381` + dangling `§1.1` cross-ref), `security-pr.yml` (`:32` comment only — timeout **stays 20**, B6), `supply-chain-pr.yml` (`:34` comment only — stays 20), `*-integration.yml` (`:29` comments), `ARCHITECTURE.md` (§9 rows), `SECURITY.md` / `docs/security.md`, `docs/ci/toolchain-image.md` (new runbook — incl. the N8 one-time package-visibility step), `CONTRIBUTING.md`, `renovate.json` comment, `Makefile` (if not in C2). -- **Dependencies:** Commits 1–5. -- **Validation gate:** `grep -rn "xcaddy\|no-cache-filter\|cold build\|full cold build\|10-14m\|12-14 min" .github/` reconciled; markdown lint; `docs-writer` review; full CI green; DoD §5 all boxes checked. +### Commit 5 — Enable E2E, coverage, docs + +- **Type:** `docs: document management-API authorization model and account-creation flow` +- **Scope:** + - Un-`fixme` the Commit 1 specs; adjust selectors/fixtures to the shipped + behavior; run targeted specs (firefox). + - File two follow-up issues (out of scope here): (1) "per-IP rate limit / + throttle middleware for `/api/v1/auth/*`" (`/auth/register` removed, + `/auth/login` already has account lockout); (2) "hide disabled + create/edit/delete controls for `role=user` on READ-classified admin pages + (Access Lists, Certificates, DNS Providers, Security Headers, Domains, + Remote Servers, Hecate tunnels)" — cosmetic; the API already returns `403` + (spec §7 item 1). + - Docs: `ARCHITECTURE.md` (Security Architecture / Auth & Authorization — + `managementAdmin` boundary; no public self-registration; bootstrap + + invite model), `SECURITY.md` (Authentication & Authorization section), + `docs/security.md`, `docs/features/access-control.md`, `docs/features.md`, + `docs/features/crowdsec.md`, `docs/features/custom-plugins.md` / + `plugin-security.md`. +- **Files:** the Commit 1 spec files (remove `fixme`); the docs listed above. +- **Depends on:** Commits 2-4. +- **Validation gate (full DoD):** + `npx playwright test crowdsec-admin-authz authorization-rbac public-registration-removed auth-api-enforcement --project=firefox` all green; + `bash scripts/local-patch-report.sh` (artifacts present, patch coverage green); + `lefthook run pre-commit` (CodeQL Go+JS) 0 high/critical; `make trivy` clean; + `make lint-fast` + `make lint-backend` clean; + `scripts/go-test-coverage.sh` ≥ 85 %; `scripts/frontend-test-coverage.sh` ≥ 85 %; + `cd frontend && npm run type-check && npm run build`; + `cd backend && go build ./...`; `go test ./...` + `npx vitest run` zero + failures; debug/print cleanup. -### PR-level rollback / contingency +--- -- **Rollback:** revert the single merged commit. The `charon-toolchain` package stays in GHCR unreferenced (`container-prune.yml` ages it out). `security-weekly-rebuild.yml` reverts to its prior behavior. The Dockerfile reverts to inline `caddy-builder`/`crowdsec-builder` with `--no-cache-filter` — **identical security posture to today**. Zero runtime/app-image content change (same recipe), so nothing to migrate or re-release. -- **Contingency (partial):** - - Freshness guard misbehaves post-merge → make `verify-toolchain-pin` non-required (repo setting); the daily `--no-cache --pull` toolchain rebuild + Trivy gate + N5 assertion still protect the guarantee. - - Selector stage breaks one workflow → set that workflow's `--build-arg CADDY_BUILDER_SRC=caddy-inline` as a temporary escape hatch (its `--no-cache-filter caddy-inline` was removed in Commit 4 but can be re-added to that one workflow) — no full revert. - - GHCR unavailable for a release → the release build fails fast; run it again, or fleet-flip the selector build-args to `caddy-inline` via a one-line workflow edit. -- **Forward-fix preferred over revert** for anything touching the security guarantee (CLAUDE.md: long-term fix over quick patch). +### Rollback & contingency (PR-wide) + +- **Per-commit revert:** Commits 2, 3, 4 are individually revertable. + - Revert **Commit 3** alone if the Part B sweep regresses a `role=user` + workflow found late — Commit 2 (the actual advisory fix) and Commit 4 still + stand and ship value. + - Revert **Commit 4** alone (restore the register route) without affecting + the authz fixes, if an external consumer of `/auth/register` is discovered + that can't migrate to `/setup` in time — though the advisory title itself + frames public registration as the root enabler, so this should be a last + resort with a tracking issue. +- **Minimum shippable:** Commits 1-2 + docs = the advisory is closed. Parts B/C + can be dropped from the PR (update this spec + PR description) if they need + more time — but the intent is to land all three together. +- **No migration to roll back** — zero schema changes. +- **Feature-flag option (contingency, not in the default plan):** if reviewers + want a kill switch for Part C rather than a hard delete, gate the register + route behind a `Setting` (`auth.public_registration_enabled`, default + `false`) instead of removing it. Adds surface; only if explicitly requested. +- **Embargo:** keep the GHSA id, "CrowdSec", route paths, and + "authorization bypass / public registration" out of every commit subject and + any changelog-visible line. The PR description MAY reference the advisory + (repo private, pre-disclosure) — confirm with the maintainer before opening. --- -## 13. Handoff +## 10. Commit Message Conventions (per `CLAUDE.md`) + +- Security-relevant commits use `fix(security):` with a **deliberately vague** + subject — category of issue + category of mitigation only. Never name the + vulnerability class, the component ("CrowdSec", "plugins"), the attack vector + ("public registration"), or any route path. + - Commit 2: `fix(security): tighten authorization checks on privileged API routes` + - Commit 3: `fix(security): apply deny-by-default authorization on management API subroutes` + - Commit 4: `fix(security): reduce unauthenticated API surface` +- Non-security commits: `test:` (Commit 1), `docs:` (Commit 5). +- `fix:` triggers Docker builds (intended here). +- Every commit message ends with: + ``` + Claude-Session: https://claude.ai/code/session_01Wm1jzKSdvz2LCusQC2qokM + ``` +- PR description ends with: + ``` + https://claude.ai/code/session_01Wm1jzKSdvz2LCusQC2qokM + ``` + +--- -On approval: route to **supervisor** for plan review; iterate here until approved; then present to the user for explicit go-ahead before implementation. Implementation is CI/build-only → delegate commit-by-commit primarily to **devops** (with **docs-writer** for Commit 6), each commit gated as above, then **supervisor** re-review, then **qa-security** last against `SECURITY.md` + DoD. +## 11. Handoff + +- Next: `supervisor` review of this spec → iterate → user approval → implement + Commits 1-5 in order via `backend-dev` / `frontend-dev` (each commit passes + its gate before the next starts) → `supervisor` implementation review → + `qa-security` audit last → `docs-writer`. +- Key references for implementers: + - Advisory root cause: `backend/internal/api/routes/routes.go:838`, `:373-374`; + correct pattern at `:796-797` and `:1011-1012`; per-route arg precedent at + `:457`. + - `backend/internal/api/middleware/auth.go` (`RequireRole`, + `RequireManagementAccess`). + - `backend/internal/api/handlers/permission_helpers.go` (`requireAdmin`, + `isAdmin`). + - Part C targets: `backend/internal/api/handlers/auth_handler.go:238-256` + (delete `RegisterRequest` + `Register`), `routes.go:295` (delete route), + `backend/internal/services/auth_service.go:31` (**keep**), + `backend/internal/api/handlers/user_handler.go:141` (`Setup` — the retained + bootstrap path). + - Test refs to fix: `routes_test.go:162,215,335`; + `additional_coverage_test.go:717-732`; + `backend/integration/crowdsec_lapi_integration_test.go:52-59`. + - Existing email-invite (the supported post-bootstrap path, unchanged): + `backend/internal/api/handlers/user_handler.go` (`InviteUser` / `ValidateInvite` + / `AcceptInvite`), `backend/internal/models/user.go` (invite fields), + `frontend/src/pages/AcceptInvite.tsx`, `frontend/src/api/users.ts`. + - Frontend gating pattern to mirror: `frontend/src/components/RequireRole.tsx`, + `frontend/src/App.tsx:120,126`, `frontend/src/components/Layout.tsx:127`. + - Test harness: `backend/internal/api/routes/routes_test.go` + (`TestRegister_*`, `materializeRoutePath`, `publicMutationAllowlist`), + `tests/security-enforcement/authorization-rbac.spec.ts` + (`loginAndGetToken`, `TEST_USERS`). diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index 8e23ebcc7..81408a0a2 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -1,362 +1,167 @@ -# QA & Security Report — Prebuilt Caddy/CrowdSec Toolchain Image +# QA & Security Report — Management-API Authorization Hardening (GHSA-3gc6-295r-xm5m) -**PR**: #1300 — `feat(ci): prebuilt Caddy/CrowdSec toolchain image to fix Docker-build timeouts` -**Branch**: `feat/prebuilt-toolchain-image` → base `main` (draft) -**Branch tip audited**: `22e9c722` (working tree clean, rebased on `origin/main` `cc65e634`) -**Reviewed by**: qa-security agent (final pipeline pass) -**Date**: 2026-09-08 -**Spec**: `docs/plans/current_spec.md` (Rev 2 + §3.4.3 "Rev 2.1") +- **Feature branch:** `development` +- **Commits audited:** `9cf79091`, `dd05dd7c`, `21135f42`, `6a7cd24f`, `b73dd82a`, `2ac09dc1` (all after `3055a913`) +- **Plan:** `docs/plans/current_spec.md` (§3.2.2 route table, §5 Acceptance Criteria) +- **Date:** 2026-09-08 +- **Verdict:** **GHSA-3gc6-295r-xm5m: FIXED.** All Definition-of-Done gates pass. No blocking issues. --- -## Verdict: PASS WITH FOLLOW-UPS - -Clear to bring PR #1300 out of draft. No blocking security or QA issues. Four -non-blocking follow-ups and two residual supply-chain risks the merger should -accept knowingly (enumerated at the end). - -The change is CI/build-infrastructure only — no Go or TypeScript application code, -no `backend/internal/models/**`, no GORM queries, no migrations, no frontend -surface. The new executable code is three shell scripts covered by a 17-test bats -suite. All CI checks on the tip are green. - ---- - -## 1. Build-integrity / CVE-recurrence guarantee — VERIFIED - -`--no-cache-filter caddy-inline,crowdsec-inline` (the CVE-2026-84304 recurrence -guard that forced from-source rebuilds on every CVE-gate PR) is removed. Every -compensating link claimed in the brief exists and is wired: - -| Link | Where | Enforced? | -|---|---|---| -| (a) Content-hash key | `scripts/toolchain-key.sh` — SHA-256 over both inline stage bodies + 16 consumed version ARGs + the two pinned xcaddy plugins + `tonistiigi/xx` pin + digest-pinned `golang:*-alpine` bases + `sha256(.trivyignore)` + `SCHEMA_VERSION=2` | Yes — 10 bats tests assert determinism + per-input sensitivity + fail-loud on broken extraction | -| (b) Daily forced rebuild | `toolchain-image.yml` `on.schedule: '0 6 * * *'`; plan step sets `no_cache="--no-cache --pull"` for `schedule`/`force_rebuild`; `--target toolchain-runtime` recompiles `caddy-inline` + `crowdsec-inline` from source | Yes | -| (c) `verify-toolchain-pin.sh` per-PR check | `quality-checks.yml` job `verify-toolchain-pin` (installs regctl, maps trust env, runs the script) | Yes — passing on the tip; failure-closed on same-repo (see §2). **Branch-protection required-status enrolment is a merger check — see follow-up F1.** | -| (d) LABEL ↔ recipe-key check | `docker-build.yml` step "Verify pinned toolchain image matches the recipe (N5)" — `docker pull @PIN_DIGEST`, reads `io.charon.toolchain.key` LABEL, compares to freshly recomputed `toolchain-key.sh` | Yes | -| (e) Blocking weekly Trivy CRITICAL/HIGH | `toolchain-image.yml` `trivy-scan` job: `severity: CRITICAL,HIGH`, `exit-code: '1'`, `continue-on-error: ${{ github.event_name == 'pull_request' }}`. `security-weekly-rebuild.yml` reaches it via `workflow_call`, where `github.event_name` resolves to the caller's `schedule`/`workflow_dispatch` → `continue-on-error:false` → blocking | Yes | - -**Additional independent link** not in the brief: `docker-build.yml` `build-amd64` -/ `build-arm64` and `nightly-build.yml` pull the toolchain by **immutable -`@sha256:` digest** (`FROM ${CHARON_TOOLCHAIN_IMAGE}@${CHARON_TOOLCHAIN_DIGEST}`), -then the merged app image is Syft-SBOM'd, `actions/attest`-attested, Trivy- and -Grype-scanned, and Cosign-signed — so any poisoned content still has to survive -the app-image scan gates. - -**Doc-overclaim check — PASS.** Both `SECURITY.md` ("Build Integrity — Bundled -Caddy / CrowdSec Toolchain") and `ARCHITECTURE.md` ("Supply-chain hardening" -callout) retain the caveat verbatim and accurately: - -> "…it does **not** close the pre-existing gap where an upstream security fix to a -> genuinely *unpinned* transitive Go dependency is not picked up because nothing -> raises the MVS lower bound — that is unchanged, and is closed only by a human -> adding an explicit `go get @` pin (the recipe already carries ~40)." - -Both files scope the guarantee to "pinned-dependency drift and base-image drift" -only. No overclaim. `docs/ci/toolchain-image.md` "Roll back the whole feature" -correctly states security posture and app-image content are byte-identical to the -pre-PR inline path. - ---- - -## 2. `verify-toolchain-pin.sh` robustness — FAILURE-CLOSED, no bypass found - -**Trust classification** (`SAME_REPO`): defaults to `1` (trusted / failure-closed) -and only degrades to `0` (tag-only + `::warning::`) when -`GITHUB_EVENT_NAME == pull_request` **and** -`GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME != GITHUB_REPOSITORY`. Unknown / -unset → trusted. This is the safe polarity: the only way to *reach* the degraded -path is to be a genuine fork PR (whose token cannot read the private package -anyway); anything ambiguous fails closed. - -**Bypass attempts:** - -- **Env-var spoofing of `GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME`** — the - two env vars are mapped in each workflow from trusted GitHub contexts - (`${{ github.event.pull_request.head.repo.full_name }}`, `${{ github.repository }}`), - not from anything a fork PR author controls. A fork PR cannot alter the base - workflow that runs. A same-repo branch *could* edit `quality-checks.yml` to - mis-map them, but that requires write access (already a trusted actor) and is - visible in the PR diff. Not a new weakness. -- **Fork → convince script it's same-repo** — would only make it *stricter* - (failure-closed digest check); the fork runner has no `GITHUB_TOKEN` with - `packages:read` on the base repo, so it fails closed. No trust gained. -- **Same-repo → convince script it's a fork** — needs `head.repo.full_name != - repository`, impossible for a real same-repo PR without editing the workflow - (trusted-actor, diff-visible). -- **Hand-edited `CHARON_TOOLCHAIN_DIGEST` that still passes** — on the trusted - path the script resolves `:$KEY` via `regctl image digest` and requires - `REMOTE_DIGEST == PINNED_DIGEST`. The only digest that passes is the one GHCR - actually serves for that content-addressed tag. Defence-in-depth: `docker-build.yml` - LABEL check rejects a digest that points at a *different-recipe* toolchain - image. -- **TOCTOU between `imagetools inspect` / `regctl image digest` and the app - build's `FROM …@digest`** — not exploitable for injection. The app build - consumes an immutable `@sha256:` reference; re-tagging `:$KEY` afterwards cannot - change what `@digest` resolves to. Worst case is a spurious check failure - (false positive), never a silent poisoned pull. - -**`bats scripts/tests/` result: 17/17 PASS** (local, `Bats 1.13.0`; also green in -CI job "Toolchain key / freshness-guard scripts (bats)"). - -Failure modes **actually asserted** by `verify-toolchain-pin.bats`: - -| Assertion | Covered | -|---|---| -| fork PR + matching tag → `exit 0` + `::warning::Fork PR` | ✅ | -| mismatched tag (any trust level) → `exit 1`, actionable message | ✅ | -| same-repo `push` + `regctl` absent → `exit 1` (failure-closed) | ✅ | -| same-repo `push` + `GHCR_READ_TOKEN` unset → `exit 1` (failure-closed) | ✅ | -| same-repo PR + GHCR digest ≠ pinned digest → `exit 1` ("hand-edited or stale") | ✅ | -| same-repo PR + GHCR digest == pinned digest → `exit 0` ("verified (same-repo)") | ✅ | -| `workflow_dispatch` treated as trusted same-repo (fails closed on missing regctl) | ✅ | - -Failure-closed branches present in the script but **not** directly asserted (see -follow-up F2): - -- `PINNED_DIGEST` empty on a same-repo run → `exit 1`. -- `:$KEY` present but `regctl image digest` returns non-zero (unresolvable in - GHCR) → `exit 1` ("does not resolve in GHCR"). The bats `regctl` stub always - succeeds, so this specific exit path is uncovered. - -`toolchain-key.bats` (10 tests) covers determinism, whitespace-stability of edits -*outside* the two inline stages, and sensitivity to: a `go get` line inside -`caddy-inline`, `CADDY_VERSION`, `CADDY_GEOIP2_VERSION` (B4 plugin pin), the -digest-pinned `golang` base (N4), and `.trivyignore`; plus two fail-loud cases -(stage removed, stage truncated to a stub). Not asserted: sensitivity to a -`tonistiigi/xx` pin move, an `ALPINE_IMAGE` move, or a `SCHEMA_VERSION` bump — -all three *are* in the hashed input set; the gap is test-only (F2). - ---- - -## 3. Determinism fix (§3.4.3 Rev 2.1) — does NOT weaken app-image posture - -`toolchain-image.yml` builds with `--provenance=false --sbom=false`, fixed -`SOURCE_DATE_EPOCH=1700000000`, `--output type=image,push=true,rewrite-timestamp=true`. - -- **App-image supply-chain posture is unaffected.** Verified: `docker-build.yml` - `merge-and-publish` generates the app image's own SBOM (`anchore/sbom-action` - syft `v1.51.1`, with a pinned-syft fallback), attests it (`actions/attest` - `v4.2.2`), and Cosign-signs the merged digest — all against the final `charon` - app-image digest, not the toolchain image. `nightly-build.yml` retains - `provenance: true` / `sbom: true`. `grep` across `.github/workflows/` for any - consumer of the toolchain image's attestations: **none** — nothing runs - `cosign verify-attestation` / SBOM-diff against `charon-toolchain`. Disabling - provenance/SBOM on an internal build *input* that nobody verifies is correct; - it is what makes "same recipe key ⇒ identical manifest-list digest" hold and - stops `sync-pin-on-pr` from looping. - -- **"Skip build if `:$KEY` already published"** (`toolchain-image.yml` "Decide - build plan"): on a **non-forced same-repo** run, if - `imagetools inspect ${TOOLCHAIN_IMAGE}:${KEY}` resolves, `should_build=false` - and the existing digest is reused / pinned. This does trust the current - content of the mutable `:$KEY` tag. Mitigations: (i) writing that tag requires - `packages: write` on the package = trusted maintainer; fork PRs never reach - this path (no login → `type=cacheonly`); (ii) the daily run is `forced=true`, - which bypasses skip-if-published, rebuilds deterministically, and — via - `open-bump-pr` — surfaces any digest discrepancy as a `feat(security)` bot PR, - so a poisoned tag self-heals within ~24 h; (iii) the app build ultimately pins - an immutable `@digest` and the resulting app image is Trivy/Grype-scanned and - signed. **Residual R1** (accept-knowingly): a maintainer-level credential - compromise could, within a one-day window, get a poisoned `:$KEY` digest - pinned via `sync-pin-on-pr` without that PR performing a from-source rebuild. - The pre-PR `--no-cache-filter` behaviour rebuilt from source on every CVE-gate - PR; this PR trades that for the daily deterministic rebuild + freshness guard. - -- **Who holds `packages: write` on `ghcr.io/wikid82/charon-toolchain`:** - - `toolchain-image.yml` → job `build-toolchain` (workflow-level - `permissions: packages: write`). GHCR login **and** push are gated - `if: steps.trust.outputs.same_repo == 'true'`; forks produce `type=cacheonly` - (no push). - - `security-weekly-rebuild.yml` → job `toolchain-rebuild`, which is - `uses: ./.github/workflows/toolchain-image.yml` with - `permissions: packages: write` (+ `contents/pull-requests/issues: write` for - the bump-PR job). Same underlying workflow; caller event is - `schedule`/`workflow_dispatch` ⇒ same-repo. - - `docker-build.yml` (`build-amd64`/`build-arm64`/`merge-and-publish`), - `nightly-build.yml`, `orthrus-build.yml` hold `packages: write` but target - the `charon` / `charon-agent` images — they only **read** (`FROM …@digest`) - the toolchain image, never push to it. - No fork-reachable job can write the toolchain package. +## 1. Definition of Done — gate-by-gate + +| # | Gate | Result | Numbers | +|---|------|--------|---------| +| 1 | Backend coverage (`scripts/go-test-coverage.sh`) | **PASS** | Statement 92.0%, line 88.7% vs gate 87% | +| 2 | Frontend coverage (`scripts/frontend-test-coverage.sh`) | **PASS** | Statements 89.63% (8131/9071), lines 90.83% (7642/8413) vs gate 87% | +| 3 | Local patch-coverage preflight (`scripts/local-patch-report.sh`) | **PASS** | strict mode; overall/backend patch coverage 100.0% (109/109 changed backend lines); frontend/agent 0 changed lines. `test-results/local-patch-report.{md,json}` produced. | +| 4 | CodeQL Go + JS (`lefthook run codeql`) | **PASS (feature)** | Go scan 45.9s, JS scan 55.0s. JS: 0 results. Go: 4 results, **all pre-existing and outside feature-modified code** (see §3). Local findings-gate script aborted on missing `yq` only; CI runs it unconditionally. | +| 5 | Trivy (`trivy fs`, container/deps) | **PASS (feature)** | 0 CRITICAL. 0 dependency CVEs. Zero dependency/manifest changes in the feature (`go.mod`/`go.sum`/`package*.json` untouched). HIGH findings are a pre-existing Dockerfile `USER` misconfig (Dockerfile not in feature diff), a third-party `node_modules/comlink/Dockerfile`, and a gitignored local test-artifact private key — none feature-attributable. | +| 6 | GORM security scan | **N/A (verified)** | `git diff 3055a913..HEAD -- backend/internal/models/` is empty. No model/GORM/migration change. Scan not required. | +| 7 | Full backend tests (`cd backend && go test ./...`) | **PASS** | Exit 0, zero failures across all packages. | +| 8 | Full frontend tests (`npx vitest run`) + `npm run type-check` | **PASS** | 267 files, 3363 passed, 4 skipped, 2 todo, 0 failures. `tsc --noEmit` clean. | +| 9 | Targeted E2E (`--project=security-tests`: `crowdsec-admin-authz`, `public-registration-removed`, `authorization-rbac`) | **PASS** | **110 passed, 0 failed, 0 skipped, 0 fixme** (12.5s). Ran against a fresh `charon:local` container; host `:8080` (held by `nextcloud-aio-mastercontainer`) worked around with a throwaway gitignored `.docker/compose/docker-compose.override.yml` port remap (`8085:8080`), removed after the run. No nextcloud disruption. | +| 10 | Build (`go build ./...`, `npm run build`) | **PASS** | Both clean. | +| 11 | Lint (`make lint-fast` / staticcheck) | **PASS (feature)** | 2 `govet` findings, **both confirmed pre-existing**: `cmd/api/main.go:261` (err shadow, from `f6361dc8` 2026-03-04) and `internal/api/handlers/docker_handler.go:47` (`reflect.Ptr` inline, present at `3055a913`). Neither file is in the feature diff. No other findings; staticcheck clean. | +| 12 | Debug / cleanup scan of feature diff | **PASS** | No `fmt.Println`, `console.log`, `debugger`, stray `TODO/FIXME`, or commented-out blocks in added lines. `b73dd82a` removed 4 now-unused imports. | + +Notes: +- `scripts/local-patch-report.sh` requires `agent/coverage.txt` to exist even though the `agent/` module is untouched by this feature; it was generated with `scripts/agent-test-coverage.sh` (agent module: 82.6% stmt / 75.3% line, its own gate 65%) before the patch report would run. --- -## 4. New third-party action `iarekylew00t/regctl-installer` — verified, low risk - -`quality-checks.yml` `verify-toolchain-pin` job: -`uses: iarekylew00t/regctl-installer@c2202c17a65fe59371c71ecc169c9e58c3710a15 # v4.0.16` - -- **SHA ↔ release: VERIFIED.** Annotated tag `v4.0.16` → tag object - `f14118b1…` → **points at commit `c2202c17a65fe59371c71ecc169c9e58c3710a15`** - (message "chore: Bumping version to v4.0.16", tagger 2026-08-05). The pin is - exact and matches the tag comment. -- **Action source at the pinned SHA:** `action.yml` is a compiled - `using: node24` / `main: dist/index.js` action. Declared purpose: download the - `regctl` release (default `latest` — here left default, so it resolves newest - at run time) and, with `verify: true` (default, left on), **cosign-verify the - downloaded binary's signature**. Inputs are `regctl-release`, `verify`, - `cache`, `token` (`${{ github.token }}` default) — all consistent with a - GitHub-API release downloader; nothing in `action.yml` indicates behaviour - beyond install + verify. `dist/index.js` is a minified bundle and was not - line-audited. -- **Blast radius:** runs only in the `verify-toolchain-pin` job, whose - `permissions` are `contents: read` + `packages: read` — no write scope, no - secrets beyond the read-only `GITHUB_TOKEN`. -- **`curl | sha256sum -c` vs this action:** an inline pinned-hash install would - remove a compiled-JS third-party action from the trust chain, but it also - drops the cosign signature check the action performs and needs manual hash - bumps (staleness risk). Given the minimal job permissions, **not materially - safer** — noting it (F3) as an optional hardening, not a defect. If adopted, - pin `regctl-release` to an exact version too (currently `latest`). +## 2. Security audit + +### 2.1 Advisory closed — GHSA-3gc6-295r-xm5m: **FIXED** + +`crowdsecHandler.RegisterRoutes(managementAdmin)` (`routes.go:863`). `managementAdmin` = +`management.Group("/")` with `RequireManagementAccess()` (inherited) **+** `RequireRole(models.RoleAdmin)`. + +Independently confirmed (unit `TestRegister_CrowdsecAdminRoutesRequireAdminRole` + E2E `crowdsec-admin-authz.spec.ts`): + +| Caller | `/admin/crowdsec/stop`, `/bouncer/key`, `/ban`, `/file` | +|--------|--------| +| unauthenticated | **401** | +| `role=user` (valid session) | **403** on every route — cannot read the bouncer key, cannot stop CrowdSec, cannot ban/unban, cannot read config files | +| `role=admin` | reaches handler (never 401/403) | + +The `role=user` token is proven still valid on a user-allowed route in the same test, so the 403 is the new admin guard, not a broken session. + +### 2.2 Bug class closed — deny-by-default across the `management` group + +Reviewed the §3.2.2 table application in `routes.go` independently. Every state-changing `/api/v1/` route is +either (a) on `managementAdmin`, (b) on `securityAdmin`/`authenticatedAdmin`, (c) carries a per-route +`middleware.RequireRole(models.RoleAdmin)` argument, or (d) on a reviewed allowlist with a per-entry +justification. `TestManagementGroup_MutationsAreAdminGuarded` walks every `POST/PUT/PATCH/DELETE` under +`/api/v1/` (~90 mutating routes exercised) and asserts `role=user` → 403 / `role=admin` → not-403 unless +allowlisted; `TestManagementGroup_RouteInventoryNoDuplicates` proves the read/admin splits did not +double-register or orphan any path. Both pass. + +Allowlist entries scrutinised — all legitimate, none present merely to pass the test: + +- **`PUT /users/:id` self-service** — `UpdateUser` has an explicit non-admin branch that returns + `403 "Cannot modify role or enabled status"` when `req.Role != "" || req.Enabled != nil`, and + `403 "Admin access required"` when acting on another user's record. `b73dd82a`'s + `TestUserHandler_UpdateUser_NonAdminSelfCannotEscalatePrivilegedFields` asserts the **persisted** + record (not just status) — role stays `user`, `enabled` unchanged, other user's name unchanged. + Real guard, real test. E2E `authorization-rbac.spec.ts:485` also covers it. +- **proxy-hosts / proxy-groups / themes / uptime monitors** — object-level authz (`PermittedHosts` / + forward-auth) or explicitly all-management-user features; unchanged by design. +- **security-headers calculators (`/score`, `/csp/validate`, `/csp/build`) and `/access-lists/:id/test`** — + verified by source inspection to perform **no DB writes** (pure calculators / dry-run). +- **public/emergency allowlist** — `/auth/login`, `/setup`, `/invite/accept`, `/security/events`, + `/emergency/*` run their own auth schemes (unauthenticated bootstrap, IP + `X-Emergency-Token`), no + session-role semantics. + +**Security-headers preset entries in `adminHandlerRejectsByDesign`** (supervisor flag): `POST/PUT/DELETE +/security/headers/profiles*` and `POST /security/headers/presets/apply` are registered on +`securityHeadersAdmin := managementAdmin.Group("/security/headers")` — the admin route-group guard is +**structurally present and confirmed by code inspection**. For `PUT`/`DELETE /profiles/:id` the test skips +only the *admin-side positive* probe because the materialised id (`1`) is a seeded read-only preset the +handler then rejects with 403 — the `role=user` → 403 assertion still runs and passes. `POST /profiles` +and `POST /presets/apply` receive the full both-sided assertion and pass, positively verifying the admin +path. + +### 2.3 Privileged reads left on the bare `management` group + +The enforcement test only covers mutations; walked the GET/read routes still on `management` (the `read` +group in the `RegisterRoutes(read, admin)` splits) for secret/PII exposure to `role=user`: + +| Read | Model protection | +|------|------------------| +| `GET /remote-servers`, `/remote-servers/:uuid` | `RemoteServer` struct carries **no** password/key/passphrase field — host/port/metadata only | +| `GET /hecate/status`, `/hecate/tunnels`, `/hecate/tunnels/:uuid` | `TunnelConfig.EncryptedCredentials` is `json:"-"`; only non-secret `configuration` serialised | +| `GET /orthrus/agents`, `/orthrus/agents/:uuid` | `OrthrusAgent.AuthKeyHash` is `json:"-"` ("never exposed"); bootstrap token only via `/snippets`, which **moved to admin** | +| `GET /dns-providers`, `/dns-providers/:id`, `/dns-providers/:id/credentials*` | `DNSProvider.CredentialsEncrypted` and `DNSProviderCredential.CredentialsEncrypted` are `json:"-"` | +| `GET /certificates`, `/certificates/:uuid` | metadata only; private-key material only via `POST /certificates/:uuid/export`, which is admin-gated | + +These reads were already `role=user`-reachable before this feature (Q7-accepted). The one PII-bearing +read class — audit logs (other users' emails, source IPs, security-event detail) — was **moved to +`managementAdmin`** (`GET /audit-logs`, `/audit-logs/:uuid`, `GET /dns-providers/:id/audit-logs`; +`routes.go:438-439,537`), with an E2E assertion that `role=user` gets 403. No new secret/PII exposure. + +### 2.4 `passthrough` role + +`RequireManagementAccess()` still aborts `403` for `role == RolePassthrough` and is inherited by both +`management` and `managementAdmin`. `managementAdmin` additionally applies `RequireRole(admin)`, which +also rejects `passthrough` (`userRole != admin`). No new path is reachable by a `passthrough` user. + +### 2.5 Public-registration removal — no residual account-creation path + +- `POST /api/v1/auth/register` route, `AuthHandler.Register`, and `RegisterRequest` are deleted; + `POST`/`GET /api/v1/auth/register` → **404** (unit `TestRegister_PublicRegistrationEndpointRemoved`, + E2E `public-registration-removed.spec.ts`). +- `AuthService.Register` is retained but has **zero non-test callers** (`grep` across `backend/**/*.go` + excluding `_test.go`): no route, no service wiring. Documented as internal/test-only. +- `POST /setup` self-closes: `UserHandler.Setup` returns `403 "Setup already completed"` when any user + exists, with both a pre-transaction check and a post-transaction re-count for the concurrent case. +- Invite-accept requires a valid admin-issued token: `AcceptInvite` looks up the exact `invite_token` + (404 if absent), enforces `InviteExpires` (410) and `InviteStatus == "pending"` (409), and clears the + token on success. `InviteUser` is admin-only (`requireAdmin`). + +### 2.6 Docs consistency + +`SECURITY.md` ("Authentication & Authorization"), `docs/security.md` ("Accounts & Roles"), and +`ARCHITECTURE.md` ("Management API Authentication & Authorization") all describe the shipped behavior +accurately: 3-role model, `admin` required for the privileged areas, structural admin-only route group + +deny-by-default CI test, no public self-registration, `/setup` bootstrap + admin invite/add. No +contradictions; no governance "stricter wins" conflict. --- -## 5. Private `charon-toolchain` — every build path covered, forks still build +## 3. Pre-existing findings (not introduced by this feature — informational) -**`uses: ./.github/actions/build-charon-image` — 6 call sites, all pass both -`builder-src` (fork ternary) and `ghcr-token`:** - -| Workflow | `builder-src` | `ghcr-token` | -|---|---|---| -| `security-pr.yml:158` | fork ternary → `inline` \| `prebuilt` | `secrets.GITHUB_TOKEN` | -| `supply-chain-pr.yml:253` | fork ternary | `secrets.GITHUB_TOKEN` | -| `cerberus-integration.yml:35` | fork ternary | `secrets.GITHUB_TOKEN` | -| `crowdsec-integration.yml:35` | fork ternary | `secrets.GITHUB_TOKEN` | -| `waf-integration.yml:35` | fork ternary | `secrets.GITHUB_TOKEN` | -| `rate-limit-integration.yml:35` | fork ternary | `secrets.GITHUB_TOKEN` | - -All six also gained `permissions: packages: read`. The composite action logs in -to GHCR only `if: inputs.builder-src != 'inline' && inputs.ghcr-token != ''`, and -rejects an invalid `builder-src` with `exit 1`. - -**Raw `docker buildx build` / `build-push-action` app-image paths:** - -| Workflow / job | Toolchain source | GHCR login | -|---|---|---| -| `docker-build.yml` `build-amd64` / `build-arm64` | `CADDY_BUILDER_SRC` / `CROWDSEC_BUILDER_SRC` env = fork ternary (`*-inline` for foreign head repo, else `toolchain-prebuilt`) | pre-existing "Log in to GitHub Container Registry" step (`secrets.GITHUB_TOKEN`) | -| `e2e-tests-split.yml` `build` | build-args fork ternary | added `Log in to GHCR` step, `if: image_source == 'build' && head.repo.full_name == github.repository` | -| `nightly-build.yml` | hard-coded `toolchain-prebuilt` (schedule/same-repo only — correct) | pre-existing login-action + `packages: write` | -| `toolchain-image.yml` | builds the toolchain itself (`--target toolchain-runtime`, `caddy-inline`/`crowdsec-inline` from source) | login `if: same_repo == 'true'` | -| `orthrus-build.yml` | builds `agent/Dockerfile` only — **does not use the root Dockerfile / toolchain image**; no change needed | n/a | - -**Fork PR path:** every ternary resolves `head.repo.full_name != '' && -head.repo.full_name != github.repository` → `caddy-inline` / `crowdsec-inline`, -i.e. compile the byte-identical recipe from source (~14 min). `make build-offline` -(new) does the same locally. No fork build path depends on pulling the private -image. **Fork PRs still build.** ✅ - -**Empty-head-repo guard:** the `head.repo.full_name != ''` conjunct means `push` -and non-PR events (empty `head.repo.full_name`) correctly resolve to -`toolchain-prebuilt`, not accidentally to `inline`. +| Source | Finding | Evidence it pre-dates `3055a913` | +|--------|---------|----------------------------------| +| CodeQL Go | `go/cookie-secure-not-set` `auth_handler.go:198` (sev 4.0) | line dates to `88763c79` (2026-08-04); carries an explicit `// codeql[go/cookie-secure-not-set]` reviewed-suppression comment | +| CodeQL Go | `go/log-injection` `remote_server_handler.go:148` ×2 (sev 6.1) | `c.JSON(...)` in the `Get` handler, from `f6361dc8` (2026-03-04); feature only changed this file's `RegisterRoutes` signature | +| CodeQL Go | `go/log-injection` `services/uptime_service.go:1551` (sev 6.1) | file not in the feature diff at all | +| golangci `govet` | `cmd/api/main.go:261` err shadow | `f6361dc8` (2026-03-04); `main.go` not in feature diff | +| golangci `govet` | `docker_handler.go:47` `reflect.Ptr` should be inlined `reflect.Pointer` | present at `3055a913` (`80bdc0e3`); the feature fixed the *same* issue in `orthrus_handler.go` but not here | +| Trivy secret | `backend/internal/api/routes/keys/hecate-ca.{key,crt}` flagged as private key | gitignored (`.gitignore:157 *.key`), never committed; local artifact from routes tests that call `orthrus.NewInternalCA` with an unset `cfg.DatabasePath` (writes into the source tree). Files dated Jul/Aug, predate this work. | --- -## 6. Trivy — clean, no new suppressions - -- **`.trivyignore`: UNCHANGED in this PR.** `git log origin/main..tip -- .trivyignore` - → no commits; 257 non-blank lines, identical to `main`. **No new blanket - suppressions.** (`.trivyignore`'s `sha256` is itself a `toolchain-key.sh` - input, so any future edit forces a toolchain rebuild + re-pin.) -- **CI Trivy runs on the tip — all green:** - - "Trivy scan (toolchain image)" — `toolchain-image.yml` `trivy-scan`, - `CRITICAL,HIGH`, `exit-code 1` — **pass**. - - "Trivy Binary Scan" — `security-pr.yml` — **pass**. - - "Security Scan PR Image" — app image built from the pinned toolchain — - **pass**. - - "Verify Supply Chain" — **pass**; "grype" — **pass**; "Semgrep SAST" / - "Semgrep OSS" / "semgrep-cloud-platform" — **pass**. - - Top-level "Trivy" shows `NEUTRAL / skipping` — this is the pre-existing - always-on external check that no-ops on this event path, not a regression. - - Zero unignored CRITICAL/HIGH on both the toolchain image and the app image - built from it (the two `exit-code: '1'` gates passed). -- Local Trivy was **not** re-run: the toolchain image is a private GHCR package - and this environment has no GHCR credentials; CI ran it with proper auth. - Per CLAUDE.md this is a CI-scoped (`ci:`/`feat(ci)`) change and CI runs Trivy - unconditionally, so nothing is skipped. +## 4. Recommendations (non-blocking) + +1. Fix the two pre-existing `govet` findings in a follow-up `chore:` — `docker_handler.go:47` + (`reflect.Ptr` → `reflect.Pointer`, mirroring the fix this feature already applied in + `orthrus_handler.go`) and `cmd/api/main.go:261` (rename the shadowed `err`). They currently make + `make lint-fast` exit non-zero. +2. Make `scripts/local-patch-report.sh` tolerate a missing `agent/coverage.txt` (warn + treat agent + scope as 0 changed lines) instead of aborting `input_missing`, so the preflight is runnable without a + separate agent-coverage run when the agent module is untouched. +3. Test hygiene: give the `routes` package tests that construct `config.Config{}` an explicit temp + `DatabasePath` so `orthrus.NewInternalCA` stops writing `keys/hecate-ca.*` into + `backend/internal/api/routes/`. +4. Frontend patch-coverage scope reported 0 changed lines for `App.tsx` / `Layout.tsx` (these are outside + the vitest coverage instrumentation set). The guard behavior is covered instead by the new + `Layout.test.tsx` (116 lines) and the E2E redirect/nav-hiding assertions + (`authorization-rbac.spec.ts:467-492`). No action required, noted for traceability. --- -## 7. Definition of Done (CI-scoped change) - -| DoD item | Status | -|---|---| -| Targeted Playwright E2E (touched specs) | **N/A to run locally** — no FE/BE/spec files changed. CI full E2E on tip `22e9c722` is **legit and complete**: "Prepare Application Image" (which now exercises the `toolchain-prebuilt` `COPY --from` path on a same-repo PR) **pass**; all shards **pass** — Chromium 1–4 + Security Enforcement, Firefox 1–4 + Security Enforcement, WebKit 1–4 + Security Enforcement; "E2E Test Results (Final)" **pass**. A broken pin/image would have failed the image build, not silently passed. | -| GORM security scan (`scan-gorm-security.sh`) | **N/A** — no `backend/internal/models/**`, no GORM queries, no migrations in the diff. | -| `local-patch-report.sh` / Go+TS patch coverage | **N/A** — no Go/TS lines changed. `codecov/patch` check on PR: **pass** (0 changed coverable lines). New executable code is shell, covered by 17 bats tests (see §2). | -| Frontend `npm run type-check` / `npm run build` / FE coverage 85% | **N/A** — no `frontend/` files touched. | -| Backend `go build ./...` / Go coverage 85% | **N/A** — no `backend/` files touched. "Backend (Go)" / "Agent (Go)" CI: **pass** (unchanged). | -| staticcheck / golangci-lint | **N/A** (no Go). Substituted by `shellcheck --severity=error` on the 3 new scripts — **clean locally** (also clean at default severity) and in CI job "Toolchain key / freshness-guard scripts (bats)"; `actionlint` on all 12 changed workflows — **clean locally** (`exit 0`). | -| CodeQL Go / JS | Green on tip ("CodeQL analysis (go)" + "(javascript-typescript)" **pass**). Effectively N/A (no Go/JS/TS source changed) but ran. | -| `bats scripts/tests/` | **17/17 PASS** locally + CI. | -| Build verification, `docker build` both `builder-src` modes | `toolchain-prebuilt` path: exercised & green across `build-amd64`, `build-arm64`, "Prepare Application Image", and all 6 integration image builds on this same-repo PR. `caddy-inline`/`crowdsec-inline` full-app path: **not exercised on a same-repo PR** (fork-only) — see **F4 / R2**. The inline *stage bodies themselves* are compiled from source on every `toolchain-image.yml` run (daily + tracked-path PRs) via `--target toolchain-runtime`, and passed on this PR ("Build & publish toolchain image" **pass**). | -| No debug leftovers in new scripts | **Clean** — `grep -nE 'TODO|FIXME|XXX|DEBUG|set -x|console.log|fmt.Print'` over the 3 scripts + 2 bats files + fixture helper → no matches. All three scripts use `set -euo pipefail`. | - ---- - -## Follow-ups (non-blocking) - -- **F1 — Confirm required-status enrolment.** Verify branch protection for `main` - (and `development`) lists **"Toolchain pin freshness (verify-toolchain-pin)"** - and **"Toolchain key / freshness-guard scripts (bats)"** as required checks. - The failure-closed guarantee in §1(c) / §2 only bites if the check is required; - the code is correct but enrolment is a repo-settings action outside this diff. -- **F2 — Close two bats coverage gaps** in `verify-toolchain-pin.bats`: (i) - same-repo run with `regctl` present but `image digest` failing (unresolvable - `:$KEY`) → `exit 1`; (ii) same-repo run with `CHARON_TOOLCHAIN_DIGEST` empty → - `exit 1`. And in `toolchain-key.bats`: sensitivity to a `tonistiigi/xx` pin - move and an `ALPINE_IMAGE` move (both are hashed inputs, currently untested). -- **F3 — (optional) `regctl-installer` hardening.** Either accept as-is (minimal - job perms, cosign verify on) or replace with a pinned-hash `curl | sha256sum -c` - install; if kept, pin `regctl-release` to an exact version rather than the - default `latest`. -- **F4 — Add periodic coverage of the offline/inline app build.** A scheduled or - label-gated job running `make build-offline` (or at minimum - `docker build --build-arg CADDY_BUILDER_SRC=caddy-inline - --build-arg CROWDSEC_BUILDER_SRC=crowdsec-inline --check .`) so the - `FROM ${CADDY_BUILDER_SRC} AS caddy-builder` selector + final-stage assembly on - the fork path can't silently rot between fork PRs. -- **F5 — Stale note in `docs/ci/toolchain-image.md`.** The "One-time bootstrap - notes" paragraph still says *"`COPY scripts/ /app/scripts/` copies the new - shell scripts into the runtime image … No `.dockerignore` … change is needed"*, - which the `.dockerignore` follow-up commit `22e9c722` (excludes - `scripts/tests/`, `scripts/toolchain-key.sh`, `scripts/verify-toolchain-pin.sh`, - `scripts/lib/dockerfile-stage.sh` from the build context) now contradicts. - One-paragraph doc fix. - -## Residual supply-chain risk — accept knowingly - -- **R1 — one-day poisoned-tag window.** An actor with `packages: write` on - `ghcr.io/wikid82/charon-toolchain` (maintainer-level) could push a poisoned - image to the mutable `:$KEY` tag; a non-forced same-repo PR that recomputes the - same key would skip the rebuild and `sync-pin-on-pr` could pin that digest - without a from-source rebuild *in that PR*. Bounded by: fork PRs cannot reach - the path; the daily `--no-cache --pull` deterministic rebuild + `open-bump-pr` - self-heal within ~24 h; the resulting app image is still Trivy/Grype-scanned, - SBOM-attested and Cosign-signed. Net change vs pre-PR: the "every CVE-gate PR - rebuilds Caddy/CrowdSec from source" property is replaced by "daily - deterministic rebuild + per-PR freshness guard + immutable digest pin." -- **R2 — fork/offline `caddy-inline`+`crowdsec-inline` *whole-app* build is not - CI-exercised on same-repo PRs.** The stage bodies are compiled daily by - `toolchain-image.yml`; only the `FROM ${ARG} AS caddy-builder` indirection and - the final-stage COPY wiring on the inline path go unverified until a fork PR or - a manual `make build-offline`. Low severity (small surface, `toolchain-key.sh` - sanity-checks the stages exist and contain a build step). F4 closes it. -- **R3 — the toolchain image itself is digest-pinned but not Cosign-signed.** - Acceptable: it is built by the repo's own Actions, pulled by immutable digest, - and recipe→digest is bound by the freshness guard + LABEL check; the shipped - app image carries the signature/attestation. - ---- +## 5. Blocking issues -## Scans run for this audit - -- `bats scripts/tests/toolchain-key.bats scripts/tests/verify-toolchain-pin.bats` → **17/17 pass** (`Bats 1.13.0`) -- `shellcheck --severity=error` + default severity on `scripts/toolchain-key.sh`, `scripts/verify-toolchain-pin.sh`, `scripts/lib/dockerfile-stage.sh` → **clean** -- `actionlint` on the 12 changed workflow files → **clean (exit 0)** -- Debug-leftover grep over the 3 scripts + 2 bats + fixture → **clean** -- `git log origin/main..tip -- .trivyignore` → **no changes** -- `gh api` verification: `regctl-installer` tag `v4.0.16` → commit `c2202c17…` → **exact match** -- `gh pr checks 1300` on tip `22e9c722` → **no failing / cancelled checks** (all pass or intentionally skipped) -- Diff review of `Dockerfile`, `toolchain-image.yml`, `build-charon-image/action.yml`, and the 11 other changed workflows; `SECURITY.md`, `ARCHITECTURE.md`, `docs/ci/toolchain-image.md` -- GORM security scan — **not run (N/A: no models/queries/migrations)** -- Local Trivy / CodeQL — **deferred to CI** (CI-scoped change; both ran green with proper credentials) +**None.** The feature meets every Definition-of-Done gate and closes GHSA-3gc6-295r-xm5m. Recommended for +merge. diff --git a/docs/security.md b/docs/security.md index 814fb4c8a..8dbcb92bc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -17,6 +17,29 @@ Want the quick reference? See . --- +## Accounts & Roles + +Charon has two kinds of account: + +- **Administrator** — full control. Only an admin can change security-sensitive + configuration: CrowdSec and the firewall, access lists, security headers, + certificates and the credentials used to issue them, DNS-provider logins, SSH + details for remote servers, tunnels and agents, app settings, plugins, and the + user list. +- **Standard user** — manages proxy hosts. A standard user can still open and + read most settings pages, but the actions that change the areas above are + admin-only; Charon refuses them with a "forbidden" response. A few screens + (CrowdSec controls, audit log, remote agent management, encryption management) + are hidden from standard users entirely. + +**Creating accounts.** There is no public sign-up page and no anonymous +registration request. The first administrator is created on the one-time setup +screen the first time you open a new instance. After that, an existing admin adds +every account from **Settings → Users**, either by emailing an invite link or by +creating the user directly. + +--- + ## What Is Cerberus? Think of Cerberus as a guard dog for your websites. It has three heads (in Greek mythology), and each head watches for different threats: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cac41bdbf..9b61041f7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -83,7 +83,7 @@ export default function App() { } /> } /> } /> - } /> + } /> {/* Legacy redirect for old Remote Servers bookmarks */} @@ -101,13 +101,13 @@ export default function App() { } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> - } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0af2f86c1..19efc1046 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -97,7 +97,7 @@ export default function Layout({ children }: LayoutProps) { { name: t('navigation.remoteServers'), path: '/hecate/remote-servers', icon: '🖥️' }, { name: t('navigation.tunnels'), path: '/hecate/tunnels', icon: '🌐' }, { name: t('navigation.providers'), path: '/hecate/providers', icon: '🔑' }, - { name: t('navigation.agent'), path: '/hecate/agent', icon: '🤖' }, + ...(user?.role === 'admin' ? [{ name: t('navigation.agent'), path: '/hecate/agent', icon: '🤖' }] : []), ], }, { name: t('navigation.domains'), path: '/domains', icon: '🌍' }, @@ -109,12 +109,12 @@ export default function Layout({ children }: LayoutProps) { { name: t('navigation.uptime'), path: '/uptime', icon: '📈' }, { name: t('navigation.cerberus'), path: '/security', icon: '🛡️', children: [ { name: t('navigation.dashboard'), path: '/security', icon: '🛡️' }, - { name: t('navigation.crowdsec'), path: '/security/crowdsec', icon: '🛡️' }, + ...(user?.role === 'admin' ? [{ name: t('navigation.crowdsec'), path: '/security/crowdsec', icon: '🛡️' }] : []), { name: t('navigation.accessLists'), path: '/security/access-lists', icon: '🔒' }, { name: t('navigation.rateLimiting'), path: '/security/rate-limiting', icon: '⚡' }, { name: t('navigation.waf'), path: '/security/waf', icon: '🛡️' }, { name: t('navigation.securityHeaders'), path: '/security/headers', icon: '🔐' }, - { name: t('navigation.encryption'), path: '/security/encryption', icon: '🔑' }, + ...(user?.role === 'admin' ? [{ name: t('navigation.encryption'), path: '/security/encryption', icon: '🔑' }] : []), ]}, { name: t('navigation.settings'), diff --git a/frontend/src/components/__tests__/Layout.test.tsx b/frontend/src/components/__tests__/Layout.test.tsx index 455b8231d..85da92b20 100644 --- a/frontend/src/components/__tests__/Layout.test.tsx +++ b/frontend/src/components/__tests__/Layout.test.tsx @@ -12,6 +12,9 @@ import Layout from '../Layout' const mockLogout = vi.fn() +// Mutable auth state so individual tests can exercise role-based nav gating. +let mockUser: { role: string } | undefined + vi.mock('../../hooks/useMediaQuery', () => ({ useMediaQuery: vi.fn().mockReturnValue(false), })) @@ -20,6 +23,7 @@ vi.mock('../../hooks/useMediaQuery', () => ({ vi.mock('../../hooks/useAuth', () => ({ useAuth: () => ({ logout: mockLogout, + user: mockUser, }), })) @@ -78,6 +82,7 @@ const renderWithProviders = (children: ReactNode) => { describe('Layout', () => { beforeEach(() => { vi.clearAllMocks() + mockUser = { role: 'admin' } localStorage.clear() localStorage.setItem('sidebarCollapsed', 'false') // Default: all features enabled @@ -560,4 +565,115 @@ describe('Layout', () => { expect(link).not.toHaveAttribute('aria-current') }) }) + + describe('CrowdSec nav gating by role', () => { + const expandCerberus = async () => { + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: /cerberus/i })) + } + + it('shows the CrowdSec security nav item for an admin', async () => { + mockUser = { role: 'admin' } + renderWithProviders( + +
Test Content
+
+ ) + + await expandCerberus() + + const links = await screen.findAllByRole('link', { name: 'CrowdSec' }) + expect(links.some((l) => l.getAttribute('href') === '/security/crowdsec')).toBe(true) + }) + + it('hides the CrowdSec security nav item for a non-admin user', async () => { + mockUser = { role: 'user' } + renderWithProviders( + +
Test Content
+
+ ) + + await expandCerberus() + + // Sibling Cerberus items still render for a non-admin... + expect(await screen.findByRole('link', { name: 'Access Lists' })).toBeInTheDocument() + // ...but the CrowdSec security config link is gone. + const links = screen.queryAllByRole('link', { name: 'CrowdSec' }) + expect(links.some((l) => l.getAttribute('href') === '/security/crowdsec')).toBe(false) + }) + }) + + describe('admin-only nav gating by role', () => { + const expandHecate = async () => { + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: /hecate/i })) + } + const expandCerberus = async () => { + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: /cerberus/i })) + } + + it('shows the Hecate Agent nav child for an admin', async () => { + mockUser = { role: 'admin' } + renderWithProviders( + +
Test Content
+
+ ) + + await expandHecate() + + const links = await screen.findAllByRole('link', { name: 'Agent' }) + expect(links.some((l) => l.getAttribute('href') === '/hecate/agent')).toBe(true) + }) + + it('hides the Hecate Agent nav child for a non-admin user', async () => { + mockUser = { role: 'user' } + renderWithProviders( + +
Test Content
+
+ ) + + await expandHecate() + + // Sibling Hecate items still render for a non-admin... + expect(await screen.findByRole('link', { name: 'Tunnels' })).toBeInTheDocument() + // ...but the agent-management link is gone. + const links = screen.queryAllByRole('link', { name: 'Agent' }) + expect(links.some((l) => l.getAttribute('href') === '/hecate/agent')).toBe(false) + }) + + it('shows the Encryption nav child for an admin', async () => { + mockUser = { role: 'admin' } + renderWithProviders( + +
Test Content
+
+ ) + + await expandCerberus() + + const links = await screen.findAllByRole('link', { name: 'Encryption' }) + expect(links.some((l) => l.getAttribute('href') === '/security/encryption')).toBe(true) + }) + + it('hides the Encryption nav child for a non-admin user', async () => { + mockUser = { role: 'user' } + renderWithProviders( + +
Test Content
+
+ ) + + await expandCerberus() + + // Sibling Cerberus items still render for a non-admin... + expect(await screen.findByRole('link', { name: 'Access Lists' })).toBeInTheDocument() + // ...but the Encryption link is gone. + const links = screen.queryAllByRole('link', { name: 'Encryption' }) + expect(links.some((l) => l.getAttribute('href') === '/security/encryption')).toBe(false) + }) + }) }) diff --git a/scripts/cerberus_integration.sh b/scripts/cerberus_integration.sh index e1eeed493..483217cc1 100755 --- a/scripts/cerberus_integration.sh +++ b/scripts/cerberus_integration.sh @@ -234,14 +234,14 @@ done echo "" # ============================================================================ -# Step 3: Register user and authenticate +# Step 3: Set up admin user and authenticate # ============================================================================ -log_info "Registering admin user and logging in..." +log_info "Setting up admin user and logging in..." TMP_COOKIE=$(mktemp) curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"cerberus-test@example.local","password":"password123","name":"Cerberus Tester"}' \ - "http://localhost:${API_PORT}/api/v1/auth/register" >/dev/null 2>&1 || true + "http://localhost:${API_PORT}/api/v1/setup" >/dev/null 2>&1 || true curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"cerberus-test@example.local","password":"password123"}' \ diff --git a/scripts/coraza_integration.sh b/scripts/coraza_integration.sh index 87cabe6ac..1959c7a5f 100755 --- a/scripts/coraza_integration.sh +++ b/scripts/coraza_integration.sh @@ -145,7 +145,7 @@ for i in {1..30}; do sleep 1 done -echo "Skipping unauthenticated ruleset creation (will register and create with cookie later)..." +echo "Skipping unauthenticated ruleset creation (will set up admin and create with cookie later)..." echo "Creating a backend container for proxy host..." # ensure the overlay network exists (docker-compose uses containers_default) CREATED_NETWORK=0 @@ -176,9 +176,9 @@ for i in {1..20}; do sleep 1 done -echo "Registering admin user and logging in to retrieve session cookie..." +echo "Setting up admin user and logging in to retrieve session cookie..." TMP_COOKIE=$(mktemp) -curl -s -X POST -H "Content-Type: application/json" -d '{"email":"integration@example.local","password":"password123","name":"Integration Tester"}' http://localhost:8080/api/v1/auth/register >/dev/null || true +curl -s -X POST -H "Content-Type: application/json" -d '{"email":"integration@example.local","password":"password123","name":"Integration Tester"}' http://localhost:8080/api/v1/setup >/dev/null || true curl -s -X POST -H "Content-Type: application/json" -d '{"email":"integration@example.local","password":"password123"}' -c ${TMP_COOKIE} http://localhost:8080/api/v1/auth/login >/dev/null echo "Creating proxy host 'integration.local' pointing to backend..." diff --git a/scripts/crowdsec_decision_integration.sh b/scripts/crowdsec_decision_integration.sh index 1e8920b24..70f13221b 100755 --- a/scripts/crowdsec_decision_integration.sh +++ b/scripts/crowdsec_decision_integration.sh @@ -197,14 +197,14 @@ done echo "" # ============================================================================ -# Step 3: Register user and authenticate +# Step 3: Set up admin user and authenticate # ============================================================================ -log_info "Registering admin user and logging in..." +log_info "Setting up admin user and logging in..." TMP_COOKIE=$(mktemp) curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"crowdsec@example.local","password":"password123","name":"CrowdSec Tester"}' \ - "http://localhost:${API_PORT}/api/v1/auth/register" >/dev/null 2>&1 || true + "http://localhost:${API_PORT}/api/v1/setup" >/dev/null 2>&1 || true curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"crowdsec@example.local","password":"password123"}' \ diff --git a/scripts/crowdsec_integration.sh b/scripts/crowdsec_integration.sh index 798bdc4fd..617cc5941 100755 --- a/scripts/crowdsec_integration.sh +++ b/scripts/crowdsec_integration.sh @@ -60,9 +60,9 @@ if [ "${API_READY}" != "true" ]; then exit 1 fi -echo "Registering admin user and logging in..." +echo "Setting up admin user and logging in..." TMP_COOKIE=$(mktemp) -curl -s -X POST -H "Content-Type: application/json" -d '{"email":"integration@example.local","password":"password123","name":"Integration Tester"}' http://localhost:8080/api/v1/auth/register >/dev/null || true +curl -s -X POST -H "Content-Type: application/json" -d '{"email":"integration@example.local","password":"password123","name":"Integration Tester"}' http://localhost:8080/api/v1/setup >/dev/null || true curl -s -X POST -H "Content-Type: application/json" -d '{"email":"integration@example.local","password":"password123"}' -c ${TMP_COOKIE} http://localhost:8080/api/v1/auth/login >/dev/null || true # Check hub availability first diff --git a/scripts/debug_rate_limit.sh b/scripts/debug_rate_limit.sh index a5107583d..f5eb49e0d 100755 --- a/scripts/debug_rate_limit.sh +++ b/scripts/debug_rate_limit.sh @@ -14,14 +14,14 @@ docker run -d --name charon-debug \ sleep 10 echo "" -echo "=== Registering user ===" +echo "=== Setting up admin user ===" curl -s -X POST -H "Content-Type: application/json" \ - -d '{"email":"debug@test.local","password":"pass123","name":"Debug"}' \ - http://localhost:8280/api/v1/auth/register >/dev/null || true + -d '{"email":"debug@test.local","password":"pass1234","name":"Debug"}' \ + http://localhost:8280/api/v1/setup >/dev/null || true echo "=== Logging in ===" TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ - -d '{"email":"debug@test.local","password":"pass123"}' \ + -d '{"email":"debug@test.local","password":"pass1234"}' \ -c /tmp/debug-cookie \ http://localhost:8280/api/v1/auth/login | jq -r '.token // empty') diff --git a/scripts/qa-test-auth-certificates.sh b/scripts/qa-test-auth-certificates.sh index a28cf2290..9db977819 100755 --- a/scripts/qa-test-auth-certificates.sh +++ b/scripts/qa-test-auth-certificates.sh @@ -50,8 +50,8 @@ section "Phase 1: Certificate Page Authentication Tests" # Test 1.1: Login and Cookie Verification echo -e "${YELLOW}Test 1.1: Login and Cookie Verification${NC}" -# First, ensure test user exists (idempotent) -curl -s -X POST "$API_URL/auth/register" \ +# First, ensure the initial admin user exists (idempotent) +curl -s -X POST "$API_URL/setup" \ -H "Content-Type: application/json" \ -d '{"email":"qa-test@example.com","password":"QATestPass123!","name":"QA Test User"}' > /dev/null 2>&1 diff --git a/scripts/rate_limit_integration.sh b/scripts/rate_limit_integration.sh index bc0bba80f..d607563b5 100755 --- a/scripts/rate_limit_integration.sh +++ b/scripts/rate_limit_integration.sh @@ -201,14 +201,14 @@ for i in {1..45}; do done # ============================================================================ -# Step 4: Register user and authenticate +# Step 4: Set up admin user and authenticate # ============================================================================ echo "" -echo "Registering admin user and logging in..." +echo "Setting up admin user and logging in..." TMP_COOKIE=$(mktemp) curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"ratelimit@example.local","password":"password123","name":"Rate Limit Tester"}' \ - http://localhost:8280/api/v1/auth/register >/dev/null 2>&1 || true + http://localhost:8280/api/v1/setup >/dev/null 2>&1 || true LOGIN_STATUS=$(curl -s -w "\n%{http_code}" -X POST -H "Content-Type: application/json" \ -d '{"email":"ratelimit@example.local","password":"password123"}' \ diff --git a/scripts/waf_integration.sh b/scripts/waf_integration.sh index 69814c4aa..b7975a3b8 100755 --- a/scripts/waf_integration.sh +++ b/scripts/waf_integration.sh @@ -248,14 +248,14 @@ done echo "" # ============================================================================ -# Step 3: Register user and authenticate +# Step 3: Set up admin user and authenticate # ============================================================================ -log_info "Registering admin user and logging in..." +log_info "Setting up admin user and logging in..." TMP_COOKIE=$(mktemp) curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"waf-test@example.local","password":"password123","name":"WAF Tester"}' \ - "http://localhost:${API_PORT}/api/v1/auth/register" >/dev/null 2>&1 || true + "http://localhost:${API_PORT}/api/v1/setup" >/dev/null 2>&1 || true curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"waf-test@example.local","password":"password123"}' \ diff --git a/tests/security-enforcement/authorization-rbac.spec.ts b/tests/security-enforcement/authorization-rbac.spec.ts index c5164b21f..da53a424c 100644 --- a/tests/security-enforcement/authorization-rbac.spec.ts +++ b/tests/security-enforcement/authorization-rbac.spec.ts @@ -3,6 +3,9 @@ */ import { test, expect, request as playwrightRequest } from '@playwright/test'; +import { STORAGE_STATE } from '../constants'; +import { TestDataManager } from '../utils/TestDataManager'; +import { TEST_PASSWORD } from '../fixtures/auth-fixtures'; const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080'; @@ -267,3 +270,240 @@ test.describe('Cerberus ACL Role-Based Access Control', () => { }); }); }); + +/** + * Privileged management-API authorization hardening (GHSA-3gc6-295r-xm5m, Part B). + * + * The `management` route group is a de-facto "any authenticated non-passthrough + * user" group. Part B moves every state-changing / privileged route behind an + * explicit `RequireRole(admin)` guard (a dedicated `managementAdmin` subgroup or + * a per-route middleware arg) while keeping the reads that back `role=user` + * screens on `management`. + * + * Shipped behaviour (Part B — `managementAdmin` subgroup + per-route + * `RequireRole(admin)` args + frontend `RequireRole` guards): + * - `role=user` -> 403 on the privileged mutations enumerated below. + * - `role=user` -> 200 still on the reads enumerated below (guard against + * over-restriction — these back non-admin pages). + * - `role=user` is redirected away from the admin-only UI routes and their nav + * entries are hidden. + * - `role=user` cannot self-escalate to admin through the self-service + * `PUT /api/v1/users/:id` route. + * + * Runs only under `--project=security-tests`. + */ + +const PLACEHOLDER_UUID = '00000000-0000-0000-0000-000000000000'; +const PLACEHOLDER_ID = '1'; + +/** Privileged mutations that MUST return 403 for a `role=user` token (spec §3.2.2). */ +const PRIVILEGED_MUTATIONS: Array<{ + method: 'post' | 'put' | 'patch' | 'delete'; + path: string; + data?: Record; +}> = [ + // Plugin enable/disable/reload (row 3 — confirmed 2nd live instance). + { method: 'post', path: `/api/v1/admin/plugins/${PLACEHOLDER_ID}/enable` }, + { method: 'post', path: `/api/v1/admin/plugins/${PLACEHOLDER_ID}/disable` }, + { method: 'post', path: '/api/v1/admin/plugins/reload' }, + // Remote-server mutations (rows 11/12 — SSH targets + credentials). + { method: 'post', path: '/api/v1/remote-servers', data: { name: 'e2e', host: '203.0.113.20', port: 22 } }, + { method: 'put', path: `/api/v1/remote-servers/${PLACEHOLDER_UUID}`, data: { name: 'e2e' } }, + { method: 'delete', path: `/api/v1/remote-servers/${PLACEHOLDER_UUID}` }, + { method: 'post', path: '/api/v1/remote-servers/test', data: { host: '203.0.113.20', port: 22 } }, + // Hecate tunnel mutations (row 14b — provider credentials + topology). + { method: 'post', path: '/api/v1/hecate/tunnels', data: { name: 'e2e' } }, + { method: 'post', path: `/api/v1/hecate/tunnels/${PLACEHOLDER_UUID}/start` }, + // Orthrus agent mutations (row 15b — agent provisioning + bootstrap tokens). + { method: 'post', path: '/api/v1/orthrus/agents', data: { name: 'e2e' } }, + // Certificate export ships private-key material (row 19). + { method: 'post', path: `/api/v1/certificates/${PLACEHOLDER_UUID}/export`, data: {} }, + // DNS-provider mutations + credential tests (rows 17b — API credentials + ACME). + { method: 'post', path: '/api/v1/dns-providers', data: { name: 'e2e', type: 'cloudflare' } }, + { method: 'post', path: '/api/v1/dns-providers/test', data: { type: 'cloudflare', credentials: {} } }, + { method: 'post', path: `/api/v1/dns-providers/${PLACEHOLDER_ID}/test`, data: {} }, + // Notification test / preview (row 33b — sends messages / renders with config). + { method: 'post', path: '/api/v1/notifications/providers/test', data: { type: 'webhook' } }, + { method: 'post', path: '/api/v1/notifications/providers/preview', data: { type: 'webhook' } }, + { method: 'post', path: '/api/v1/notifications/external-templates/preview', data: {} }, + // Access-list mutations (row 21 — ACLs are a security control). + { method: 'post', path: '/api/v1/access-lists', data: { name: 'e2e', rules: [] } }, + { method: 'put', path: `/api/v1/access-lists/${PLACEHOLDER_ID}`, data: { name: 'e2e' } }, + { method: 'delete', path: `/api/v1/access-lists/${PLACEHOLDER_ID}` }, + // Domain mutations (row 30). + { method: 'post', path: '/api/v1/domains', data: { name: 'e2e.example.com' } }, + { method: 'delete', path: `/api/v1/domains/${PLACEHOLDER_ID}` }, + // Settings + feature-flag mutations (rows 23/24). + { method: 'patch', path: '/api/v1/settings', data: { 'app.name': 'e2e' } }, + { method: 'put', path: '/api/v1/feature-flags', data: {} }, +]; + +/** Privileged reads that MUST also return 403 for a `role=user` token (rows 15b/28). */ +const PRIVILEGED_READS: string[] = [ + '/api/v1/orthrus/agents/' + PLACEHOLDER_UUID + '/snippets', + '/api/v1/audit-logs', + '/api/v1/audit-logs/' + PLACEHOLDER_UUID, +]; + +/** + * Reads that MUST still return 200 for a `role=user` token — they back + * `role=user`-reachable pages and must not regress (spec §3.2.2 "READ (stays)"). + */ +const USER_REACHABLE_READS: string[] = [ + '/api/v1/orthrus/agents', + '/api/v1/hecate/status', + '/api/v1/hecate/tunnels', + '/api/v1/remote-servers', + '/api/v1/certificates', + '/api/v1/dns-providers', + '/api/v1/access-lists', + '/api/v1/admin/plugins', +]; + +/** Admin-only UI routes a `role=user` must be redirected away from. */ +const ADMIN_ONLY_UI_ROUTES: string[] = [ + '/security/crowdsec', + '/security/audit-logs', + '/hecate/agent', + '/security/encryption', +]; + +test.describe('Privileged management-API authorization (GHSA-3gc6-295r-xm5m)', () => { + let testData: TestDataManager; + let adminApiContext: any; + let adminContext: any; + let userContext: any; + let adminToken: string; + let userToken: string; + + test.beforeAll(async () => { + // Admin-authenticated context from the shared setup session — used to mint + // the per-suite fixture users via the existing TestDataManager helper. + adminApiContext = await playwrightRequest.newContext({ baseURL: BASE_URL, storageState: STORAGE_STATE }); + testData = new TestDataManager(adminApiContext, 'privileged-authz-rbac'); + + const userRecord = await testData.createUser({ + name: `Privileged AuthZ User ${Date.now()}`, + email: 'privileged-authz-user@test.local', + password: TEST_PASSWORD, + role: 'user', + }); + userToken = userRecord.token; + + const adminRecord = await testData.createUser({ + name: `Privileged AuthZ Admin ${Date.now()}`, + email: 'privileged-authz-admin@test.local', + password: TEST_PASSWORD, + role: 'admin', + }); + adminToken = adminRecord.token; + + expect(userToken, 'role=user fixture token').toBeTruthy(); + expect(adminToken, 'role=admin fixture token').toBeTruthy(); + + adminContext = await playwrightRequest.newContext({ baseURL: BASE_URL }); + userContext = await playwrightRequest.newContext({ baseURL: BASE_URL }); + }); + + test.afterAll(async () => { + await testData?.cleanup(); + await adminApiContext?.dispose(); + await adminContext?.dispose(); + await userContext?.dispose(); + }); + + for (const route of PRIVILEGED_MUTATIONS) { + test(`role=user is denied (403) on ${route.method.toUpperCase()} ${route.path}`, async () => { + const response = await userContext[route.method](route.path, { + headers: { Authorization: `Bearer ${userToken}` }, + ...(route.data ? { data: route.data } : {}), + }); + expect(response.status()).toBe(403); + }); + + test(`role=admin reaches the handler (not 403/401) on ${route.method.toUpperCase()} ${route.path}`, async () => { + const response = await adminContext[route.method](route.path, { + headers: { Authorization: `Bearer ${adminToken}` }, + ...(route.data ? { data: route.data } : {}), + }); + expect(response.status()).not.toBe(401); + expect(response.status()).not.toBe(403); + }); + } + + for (const path of PRIVILEGED_READS) { + test(`role=user is denied (403) on GET ${path}`, async () => { + const response = await userContext.get(path, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + expect(response.status()).toBe(403); + }); + } + + for (const path of USER_REACHABLE_READS) { + test(`role=user can still read (200) GET ${path} — no over-restriction`, async () => { + const response = await userContext.get(path, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + expect(response.status()).toBe(200); + }); + } + + /** + * Drop the shared-setup admin cookie and boot the SPA as the `role=user` + * fixture (the app authenticates from the `charon_auth_token` localStorage + * bearer, which `AuthMiddleware` prefers over the cookie). + */ + async function actAsRegularUser(page: import('@playwright/test').Page): Promise { + await page.context().clearCookies(); + await page.goto('/'); + await page.evaluate((token) => { + window.localStorage.setItem('charon_auth_token', token); + }, userToken); + await page.reload(); + } + + for (const uiRoute of ADMIN_ONLY_UI_ROUTES) { + test(`role=user is redirected away from ${uiRoute}`, async ({ page }) => { + await actAsRegularUser(page); + + await page.goto(uiRoute); + await expect(page).toHaveURL((url) => url.pathname !== uiRoute); + await expect(page.getByRole('main')).toBeVisible(); + }); + } + + test('admin-only nav entries are hidden for role=user', async ({ page }) => { + await actAsRegularUser(page); + + const nav = page.getByRole('navigation'); + for (const name of [/crowdsec/i, /audit log/i, /agent/i, /encryption/i]) { + await expect(nav.getByRole('link', { name })).toHaveCount(0); + } + }); + + test('role=user cannot self-escalate to admin via PUT /api/v1/users/:id', async () => { + // Self-service name/password edits through this route are allowed; a role + // change embedded in the body must be rejected and must not take effect. + const meResponse = await userContext.get('/api/v1/auth/me', { + headers: { Authorization: `Bearer ${userToken}` }, + }); + expect(meResponse.status()).toBe(200); + const me = await meResponse.json(); + const ownId = me.user_id ?? me.id; + expect(ownId, 'own numeric user id from /auth/me').toBeTruthy(); + + const escalation = await userContext.put(`/api/v1/users/${ownId}`, { + headers: { Authorization: `Bearer ${userToken}` }, + data: { name: me.name ?? 'Privileged AuthZ User', role: 'admin' }, + }); + expect(escalation.status()).toBe(403); + + const afterResponse = await userContext.get('/api/v1/auth/me', { + headers: { Authorization: `Bearer ${userToken}` }, + }); + expect(afterResponse.status()).toBe(200); + const after = await afterResponse.json(); + expect(after.role).toBe('user'); + }); +}); diff --git a/tests/security-enforcement/crowdsec-admin-authz.spec.ts b/tests/security-enforcement/crowdsec-admin-authz.spec.ts new file mode 100644 index 000000000..e1e149e72 --- /dev/null +++ b/tests/security-enforcement/crowdsec-admin-authz.spec.ts @@ -0,0 +1,166 @@ +/** + * CrowdSec Admin API Authorization Enforcement (GHSA-3gc6-295r-xm5m) + * + * Advisory: a low-privilege `role=user` account (previously obtainable via the + * public `POST /auth/register` endpoint) could reach the entire + * `/api/v1/admin/crowdsec/*` surface because those routes were mounted on the + * bare `management` group, guarded only by `RequireManagementAccess()` which + * rejects `role=passthrough` only. + * + * Shipped behaviour (Part A — `crowdsecHandler.RegisterRoutes(managementAdmin)` + * with `RequireRole(admin)`): + * - `role=user` -> 403 on every CrowdSec admin route. + * - The same `role=user` token is still a valid session (200 on a genuinely + * `role=user`-allowed endpoint). + * - `role=admin` reaches the handler (never 401/403; 200/404/500 depending on + * whether the CrowdSec LAPI is running in the test environment). + * - The `/security/crowdsec` UI route redirects a non-admin and the nav entry + * is hidden for `role=user`. + * + * Runs only under `--project=security-tests` (the browser projects `testIgnore` + * this directory). + */ + +import { test, expect } from '../fixtures/test'; +import { request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import { STORAGE_STATE } from '../constants'; +import { TestDataManager } from '../utils/TestDataManager'; +import { TEST_PASSWORD } from '../fixtures/auth-fixtures'; + +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080'; + +/** CrowdSec admin routes a non-admin must never reach (spec §3.1.4). */ +const CROWDSEC_ADMIN_ROUTES: Array<{ + method: 'get' | 'post'; + path: string; + data?: Record; +}> = [ + { method: 'post', path: '/api/v1/admin/crowdsec/stop' }, + { method: 'get', path: '/api/v1/admin/crowdsec/bouncer/key' }, + { method: 'post', path: '/api/v1/admin/crowdsec/ban', data: { ip: '203.0.113.10', duration: '1h', reason: 'e2e' } }, + { method: 'get', path: '/api/v1/admin/crowdsec/file?path=acquis.yaml' }, +]; + +test.describe('CrowdSec Admin API Authorization (GHSA-3gc6-295r-xm5m)', () => { + let testData: TestDataManager; + let adminApiContext: APIRequestContext; + let adminContext: APIRequestContext; + let userContext: APIRequestContext; + let anonContext: APIRequestContext; + let adminToken: string; + let userToken: string; + + test.beforeAll(async () => { + // Admin-authenticated context from the shared setup session — used to mint + // the per-suite fixture users via the existing TestDataManager helper. + adminApiContext = await request.newContext({ baseURL: BASE_URL, storageState: STORAGE_STATE }); + testData = new TestDataManager(adminApiContext, 'crowdsec-admin-authz'); + + const userRecord = await testData.createUser({ + name: `CrowdSec AuthZ User ${Date.now()}`, + email: 'crowdsec-authz-user@test.local', + password: TEST_PASSWORD, + role: 'user', + }); + userToken = userRecord.token; + + const adminRecord = await testData.createUser({ + name: `CrowdSec AuthZ Admin ${Date.now()}`, + email: 'crowdsec-authz-admin@test.local', + password: TEST_PASSWORD, + role: 'admin', + }); + adminToken = adminRecord.token; + + expect(userToken, 'role=user fixture token').toBeTruthy(); + expect(adminToken, 'role=admin fixture token').toBeTruthy(); + + adminContext = await request.newContext({ baseURL: BASE_URL }); + userContext = await request.newContext({ baseURL: BASE_URL }); + anonContext = await request.newContext({ + baseURL: BASE_URL, + storageState: { cookies: [], origins: [] }, + }); + }); + + test.afterAll(async () => { + await testData?.cleanup(); + await adminApiContext?.dispose(); + await adminContext?.dispose(); + await userContext?.dispose(); + await anonContext?.dispose(); + }); + + test('control: the role=user token is a valid session (200 on a user-allowed endpoint)', async () => { + const response = await userContext.get('/api/v1/proxy-hosts', { + headers: { Authorization: `Bearer ${userToken}` }, + }); + expect(response.status()).toBe(200); + }); + + for (const route of CROWDSEC_ADMIN_ROUTES) { + test(`role=user is denied (403) on ${route.method.toUpperCase()} ${route.path}`, async () => { + const response = await userContext[route.method](route.path, { + headers: { Authorization: `Bearer ${userToken}` }, + ...(route.data ? { data: route.data } : {}), + }); + expect(response.status()).toBe(403); + }); + + test(`unauthenticated is rejected (401) on ${route.method.toUpperCase()} ${route.path}`, async () => { + const response = await anonContext[route.method](route.path, { + ...(route.data ? { data: route.data } : {}), + }); + expect(response.status()).toBe(401); + }); + } + + test('role=admin reaches the CrowdSec handler (never 401/403)', async () => { + for (const path of ['/api/v1/admin/crowdsec/status', '/api/v1/admin/crowdsec/bouncer/key']) { + const response = await adminContext.get(path, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(response.status()).not.toBe(401); + expect(response.status()).not.toBe(403); + } + }); + + test('role=admin can invoke a CrowdSec mutation (never 401/403)', async () => { + // Assertion corrected vs. the original fixme draft: it used + // `POST /admin/crowdsec/ban`, whose handler shells out to `cscli decisions + // add` and blocks indefinitely when no CrowdSec LAPI is reachable (the + // local E2E compose ships no CrowdSec service). `POST /admin/crowdsec/stop` + // is an equivalent privileged, state-changing CrowdSec route that + // exercises the same `managementAdmin` authorization path without an + // external dependency. + const response = await adminContext.post('/api/v1/admin/crowdsec/stop', { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(response.status()).not.toBe(401); + expect(response.status()).not.toBe(403); + }); + + test('the /security/crowdsec UI route blocks a non-admin', async ({ page }) => { + await test.step('authenticate the browser session as role=user', async () => { + await page.context().clearCookies(); + await page.goto('/'); + await page.evaluate((token) => { + window.localStorage.setItem('charon_auth_token', token); + }, userToken); + await page.reload(); + }); + + await test.step('navigating directly to /security/crowdsec redirects away', async () => { + await page.goto('/security/crowdsec'); + await expect(page).toHaveURL((url) => !url.pathname.startsWith('/security/crowdsec')); + await expect(page.getByRole('main')).toBeVisible(); + }); + + await test.step('the CrowdSec nav entry is not shown to role=user', async () => { + await expect( + page.getByRole('navigation').getByRole('link', { name: /crowdsec/i }) + ).toHaveCount(0); + }); + }); +}); diff --git a/tests/security-enforcement/public-registration-removed.spec.ts b/tests/security-enforcement/public-registration-removed.spec.ts new file mode 100644 index 000000000..227744b04 --- /dev/null +++ b/tests/security-enforcement/public-registration-removed.spec.ts @@ -0,0 +1,165 @@ +/** + * Public Registration Endpoint Removed (GHSA-3gc6-295r-xm5m, Part C) + * + * Shipped behaviour: + * - `POST /api/v1/auth/register` no longer exists -> 404 (route deleted). + * - Any method on `/api/v1/auth/register` -> 404. + * - First-admin bootstrap via `POST /api/v1/setup` still works when setup is + * required; on an already-bootstrapped instance it returns 403 + * "Setup already completed". + * - Post-bootstrap account creation is served by the existing admin surfaces: + * * `POST /api/v1/users` (direct create), and + * * the admin invite flow `POST /api/v1/users/invite` -> + * `GET /api/v1/invite/validate` -> `POST /api/v1/invite/accept`. + * + * NOTE ON THE INVITE FLOW COVERAGE (corrected vs. the original fixme draft): + * The raw invite token is never returned by any HTTP response in shipped + * builds — `InviteUser` redacts `invite_url` to "" / "[REDACTED]" + * (`redactInviteURL`, shipped since 2026-02) and `PreviewInviteURL` returns a + * placeholder `SAMPLE_TOKEN_PREVIEW`. The token only reaches an invitee via a + * configured-SMTP email. A full validate->accept->login round-trip therefore + * cannot be driven end-to-end over HTTP in the E2E environment. This spec + * instead asserts the invite endpoints exist, are admin-guarded, create a + * pending user, and reject invalid tokens — and separately proves the + * admin direct-create path yields a working `role=user` login (the concrete + * replacement for public self-registration). + * + * Runs only under `--project=security-tests`. + */ + +import { test, expect } from '../fixtures/test'; +import { request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import { STORAGE_STATE } from '../constants'; +import { TEST_PASSWORD } from '../fixtures/auth-fixtures'; + +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080'; + +test.describe('Public registration endpoint removed (GHSA-3gc6-295r-xm5m)', () => { + let anonContext: APIRequestContext; + let adminContext: APIRequestContext; + + test.beforeAll(async () => { + anonContext = await request.newContext({ + baseURL: BASE_URL, + storageState: { cookies: [], origins: [] }, + }); + // Admin-authenticated via the shared setup session (cookie auth). + adminContext = await request.newContext({ baseURL: BASE_URL, storageState: STORAGE_STATE }); + }); + + test.afterAll(async () => { + await anonContext?.dispose(); + await adminContext?.dispose(); + }); + + test('POST /api/v1/auth/register returns 404 (route deleted)', async () => { + const response = await anonContext.post('/api/v1/auth/register', { + data: { email: `attacker-${Date.now()}@example.com`, password: 'AttackerPass123!', name: 'Attacker' }, + }); + expect(response.status()).toBe(404); + }); + + test('GET /api/v1/auth/register returns 404 (route deleted for every method)', async () => { + const response = await anonContext.get('/api/v1/auth/register'); + expect(response.status()).toBe(404); + }); + + test('first-admin bootstrap via POST /api/v1/setup still works / stays closed', async () => { + const statusResponse = await anonContext.get('/api/v1/setup'); + expect(statusResponse.status()).toBe(200); + const status = await statusResponse.json(); + expect(typeof status.setupRequired).toBe('boolean'); + + if (status.setupRequired) { + const setupResponse = await anonContext.post('/api/v1/setup', { + data: { name: 'First Admin', email: 'first-admin@test.local', password: 'FirstAdminPass123!' }, + }); + expect(setupResponse.status()).toBe(201); + } else { + // Already bootstrapped (the E2E setup fixture created the first admin): + // the endpoint stays closed. + const setupResponse = await anonContext.post('/api/v1/setup', { + data: { name: 'Second Admin', email: 'second-admin@test.local', password: 'SecondAdminPass123!' }, + }); + expect(setupResponse.status()).toBe(403); + const body = await setupResponse.json(); + expect(String(body.error)).toMatch(/already completed/i); + } + }); + + test('the admin invite endpoints remain available and reject invalid tokens', async () => { + const inviteeEmail = `invitee-${Date.now()}@test.local`; + + await test.step('admin can issue an invite for a role=user account', async () => { + const response = await adminContext.post('/api/v1/users/invite', { + data: { email: inviteeEmail, role: 'user' }, + }); + expect(response.status()).toBe(201); + const body = await response.json(); + expect(body.role).toBe('user'); + // Token material is masked in the response — never returned raw. + expect(body.invite_token_masked).toBe('********'); + expect(body.invite_url ?? '').not.toContain('token='); + }); + + await test.step('the invitee now exists as a pending, disabled user', async () => { + const response = await adminContext.get('/api/v1/users'); + expect(response.status()).toBe(200); + const users = await response.json(); + const invitee = users.find((u: { email?: string }) => u.email === inviteeEmail); + expect(invitee, 'invited user present in the user list').toBeTruthy(); + expect(invitee.invite_status).toBe('pending'); + expect(invitee.enabled).toBe(false); + }); + + await test.step('the public invite-validation endpoint exists and guards its input', async () => { + const missing = await anonContext.get('/api/v1/invite/validate'); + expect(missing.status()).toBe(400); + + const bogus = await anonContext.get('/api/v1/invite/validate', { + params: { token: 'bogus-token-that-does-not-exist' }, + }); + expect(bogus.status()).toBe(404); + }); + + await test.step('the public invite-accept endpoint rejects an unknown token', async () => { + const response = await anonContext.post('/api/v1/invite/accept', { + data: { token: 'bogus-token-that-does-not-exist', name: 'Nope', password: 'NopePass123!' }, + }); + expect(response.status()).toBe(404); + }); + }); + + test('an admin-created account works as a non-admin (self-registration replacement)', async () => { + const email = `direct-user-${Date.now()}@test.local`; + + const createResponse = await adminContext.post('/api/v1/users', { + data: { name: 'Direct User', email, password: TEST_PASSWORD, role: 'user' }, + }); + expect(createResponse.status()).toBe(201); + + const loginContext = await request.newContext({ + baseURL: BASE_URL, + storageState: { cookies: [], origins: [] }, + }); + try { + const loginResponse = await loginContext.post('/api/v1/auth/login', { + data: { email, password: TEST_PASSWORD }, + }); + expect(loginResponse.status()).toBe(200); + const loginBody = await loginResponse.json(); + const token = loginBody.token || loginBody.access_token; + expect(token).toBeTruthy(); + + const meResponse = await loginContext.get('/api/v1/auth/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(meResponse.status()).toBe(200); + const me = await meResponse.json(); + expect(me.role).toBe('user'); + } finally { + await loginContext.dispose(); + } + }); +});