Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7e816a5
test: add fixme e2e specs for privileged-route authz and removal of p…
Wikid82 Sep 8, 2026
6db813b
fix(security): tighten authorization checks on privileged API routes
Wikid82 Sep 8, 2026
8f5b1bf
fix(security): apply deny-by-default authorization on management API …
Wikid82 Sep 9, 2026
8a1f5df
fix(security): reduce unauthenticated API surface
Wikid82 Sep 9, 2026
8eb20bd
docs: document management-API authorization model and account-creatio…
Wikid82 Sep 9, 2026
337f53e
refactor(security): tidy authorization hardening and add self-service…
Wikid82 Sep 9, 2026
b58bd52
test: enable e2e coverage for privileged-route authorization and regi…
Wikid82 Sep 9, 2026
c8acaf1
docs: add qa security report for management-API authorization hardening
Wikid82 Sep 9, 2026
887194e
docs: record management-API authorization hardening plan
Wikid82 Sep 9, 2026
9445600
fix(ci): repair integration test bootstrap after auth surface reduction
Wikid82 Sep 9, 2026
01a6a5c
fix(deps): update gRPC version to 1.83.2
Wikid82 Sep 7, 2026
b18d4af
chore(docker): sync toolchain pin to development's gRPC 1.83.2 recipe
Wikid82 Sep 8, 2026
8168732
fix(security): enforce admin authorization on management API + retire…
Wikid82 Sep 9, 2026
8583987
chore(main): release 0.40.2
github-actions[bot] Sep 9, 2026
cb7bdbe
chore(main): release 0.40.2 (#1319)
Wikid82 Sep 9, 2026
ca16934
chore: re-anchor go/log-injection CodeQL suppression after route-grou…
Wikid82 Sep 9, 2026
bda03ce
chore: re-anchor go/log-injection CodeQL suppression after route-grou…
Wikid82 Sep 9, 2026
0a7bf4e
Merge remote-tracking branch 'origin/main' into chore/propagate-main-…
Wikid82 Sep 9, 2026
0340b86
Merge remote-tracking branch 'origin/development' into chore/propagat…
Wikid82 Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/codeql/codeql-suppressions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.40.1"
".": "0.40.2"
}
44 changes: 42 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down Expand Up @@ -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:**
Expand Down Expand Up @@ -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)

Expand Down
26 changes: 25 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions backend/integration/crowdsec_lapi_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
26 changes: 0 additions & 26 deletions backend/internal/api/handlers/additional_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 0 additions & 22 deletions backend/internal/api/handlers/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 0 additions & 59 deletions backend/internal/api/handlers/auth_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
14 changes: 7 additions & 7 deletions backend/internal/api/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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{
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading