892b89fc9d
Implement dual-registry container publishing to both GHCR and Docker Hub
for maximum distribution reach. Add emergency security reset endpoint
("break-glass" mechanism) to recover from ACL lockout situations.
Key changes:
Docker Hub + GHCR dual publishing with Cosign signing and SBOM
Emergency reset endpoint POST /api/v1/emergency/security-reset
Token-based authentication bypasses Cerberus middleware
Rate limited (5/hour) with audit logging
30 new security enforcement E2E tests covering ACL, WAF, CrowdSec,
Rate Limiting, Security Headers, and Combined scenarios
Fixed container startup permission issue (tmpfs directory ownership)
Playwright config updated with testIgnore for browser projects
Security: Token via CHARON_EMERGENCY_TOKEN env var (32+ chars recommended)
Tests: 689 passed, 86% backend coverage, 85% frontend coverage
351 lines
9.9 KiB
Go
351 lines
9.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
)
|
|
|
|
func setupEmergencyTestDB(t *testing.T) *gorm.DB {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
|
|
err = db.AutoMigrate(
|
|
&models.Setting{},
|
|
&models.SecurityConfig{},
|
|
&models.SecurityAudit{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return db
|
|
}
|
|
|
|
func setupEmergencyRouter(handler *EmergencyHandler) *gin.Engine {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.POST("/api/v1/emergency/security-reset", handler.SecurityReset)
|
|
return router
|
|
}
|
|
|
|
func TestEmergencySecurityReset_Success(t *testing.T) {
|
|
// Setup
|
|
db := setupEmergencyTestDB(t)
|
|
handler := NewEmergencyHandler(db)
|
|
router := setupEmergencyRouter(handler)
|
|
|
|
// Configure valid token
|
|
validToken := "this-is-a-valid-emergency-token-with-32-chars-minimum"
|
|
os.Setenv(EmergencyTokenEnvVar, validToken)
|
|
defer os.Unsetenv(EmergencyTokenEnvVar)
|
|
|
|
// Create initial security config to verify it gets disabled
|
|
secConfig := models.SecurityConfig{
|
|
Name: "default",
|
|
Enabled: true,
|
|
WAFMode: "enabled",
|
|
RateLimitMode: "enabled",
|
|
RateLimitEnable: true,
|
|
CrowdSecMode: "local",
|
|
}
|
|
require.NoError(t, db.Create(&secConfig).Error)
|
|
|
|
// Make request with valid token
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
req.Header.Set(EmergencyTokenHeader, validToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Assert response
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(t, response["success"].(bool))
|
|
assert.NotNil(t, response["disabled_modules"])
|
|
disabledModules := response["disabled_modules"].([]interface{})
|
|
assert.GreaterOrEqual(t, len(disabledModules), 5)
|
|
|
|
// Verify settings were updated
|
|
var setting models.Setting
|
|
err = db.Where("key = ?", "feature.cerberus.enabled").First(&setting).Error
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "false", setting.Value)
|
|
|
|
// Verify SecurityConfig was updated
|
|
var updatedConfig models.SecurityConfig
|
|
err = db.Where("name = ?", "default").First(&updatedConfig).Error
|
|
require.NoError(t, err)
|
|
assert.False(t, updatedConfig.Enabled)
|
|
assert.Equal(t, "disabled", updatedConfig.WAFMode)
|
|
|
|
// Note: Audit logging is async via SecurityService channel, tested separately
|
|
}
|
|
|
|
func TestEmergencySecurityReset_InvalidToken(t *testing.T) {
|
|
// Setup
|
|
db := setupEmergencyTestDB(t)
|
|
handler := NewEmergencyHandler(db)
|
|
router := setupEmergencyRouter(handler)
|
|
|
|
// Configure valid token
|
|
validToken := "this-is-a-valid-emergency-token-with-32-chars-minimum"
|
|
os.Setenv(EmergencyTokenEnvVar, validToken)
|
|
defer os.Unsetenv(EmergencyTokenEnvVar)
|
|
|
|
// Make request with invalid token
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
req.Header.Set(EmergencyTokenHeader, "wrong-token")
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Assert response
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "unauthorized", response["error"])
|
|
|
|
// Note: Audit logging is async via SecurityService channel, tested separately
|
|
}
|
|
|
|
func TestEmergencySecurityReset_MissingToken(t *testing.T) {
|
|
// Setup
|
|
db := setupEmergencyTestDB(t)
|
|
handler := NewEmergencyHandler(db)
|
|
router := setupEmergencyRouter(handler)
|
|
|
|
// Configure valid token
|
|
validToken := "this-is-a-valid-emergency-token-with-32-chars-minimum"
|
|
os.Setenv(EmergencyTokenEnvVar, validToken)
|
|
defer os.Unsetenv(EmergencyTokenEnvVar)
|
|
|
|
// Make request without token header
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Assert response
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "unauthorized", response["error"])
|
|
assert.Contains(t, response["message"], "required")
|
|
|
|
// Note: Audit logging is async via SecurityService channel, tested separately
|
|
}
|
|
|
|
func TestEmergencySecurityReset_NotConfigured(t *testing.T) {
|
|
// Setup
|
|
db := setupEmergencyTestDB(t)
|
|
handler := NewEmergencyHandler(db)
|
|
router := setupEmergencyRouter(handler)
|
|
|
|
// Ensure token is not configured
|
|
os.Unsetenv(EmergencyTokenEnvVar)
|
|
|
|
// Make request
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
req.Header.Set(EmergencyTokenHeader, "any-token")
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Assert response
|
|
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
|
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "not configured", response["error"])
|
|
assert.Contains(t, response["message"], "CHARON_EMERGENCY_TOKEN")
|
|
|
|
// Note: Audit logging is async via SecurityService channel, tested separately
|
|
}
|
|
|
|
func TestEmergencySecurityReset_TokenTooShort(t *testing.T) {
|
|
// Setup
|
|
db := setupEmergencyTestDB(t)
|
|
handler := NewEmergencyHandler(db)
|
|
router := setupEmergencyRouter(handler)
|
|
|
|
// Configure token that is too short
|
|
shortToken := "too-short"
|
|
os.Setenv(EmergencyTokenEnvVar, shortToken)
|
|
defer os.Unsetenv(EmergencyTokenEnvVar)
|
|
|
|
// Make request
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
req.Header.Set(EmergencyTokenHeader, shortToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Assert response
|
|
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
|
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "not configured", response["error"])
|
|
assert.Contains(t, response["message"], "minimum length")
|
|
}
|
|
|
|
func TestEmergencySecurityReset_RateLimit(t *testing.T) {
|
|
// Setup
|
|
db := setupEmergencyTestDB(t)
|
|
handler := NewEmergencyHandler(db)
|
|
router := setupEmergencyRouter(handler)
|
|
|
|
// Configure valid token
|
|
validToken := "this-is-a-valid-emergency-token-with-32-chars-minimum"
|
|
os.Setenv(EmergencyTokenEnvVar, validToken)
|
|
defer os.Unsetenv(EmergencyTokenEnvVar)
|
|
|
|
// Make 5 requests (the limit)
|
|
for i := 0; i < MaxAttemptsPerWindow; i++ {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
req.Header.Set(EmergencyTokenHeader, "wrong-token")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
// These should all be 401 Unauthorized (invalid token), not rate limited yet
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code, "Request %d should be 401", i+1)
|
|
}
|
|
|
|
// 6th request should be rate limited
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
req.Header.Set(EmergencyTokenHeader, "wrong-token")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Assert rate limit response
|
|
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "rate limit exceeded", response["error"])
|
|
|
|
// Note: Audit logging is async via SecurityService channel, tested separately
|
|
}
|
|
|
|
func TestEmergencySecurityReset_RateLimitWithValidToken(t *testing.T) {
|
|
// Setup
|
|
db := setupEmergencyTestDB(t)
|
|
handler := NewEmergencyHandler(db)
|
|
router := setupEmergencyRouter(handler)
|
|
|
|
// Configure valid token
|
|
validToken := "this-is-a-valid-emergency-token-with-32-chars-minimum"
|
|
os.Setenv(EmergencyTokenEnvVar, validToken)
|
|
defer os.Unsetenv(EmergencyTokenEnvVar)
|
|
|
|
// Exhaust rate limit with invalid tokens
|
|
for i := 0; i < MaxAttemptsPerWindow; i++ {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
req.Header.Set(EmergencyTokenHeader, "wrong-token")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
}
|
|
|
|
// Even with valid token, should be rate limited
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil)
|
|
req.Header.Set(EmergencyTokenHeader, validToken)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Assert rate limit response (rate limiting happens before token validation)
|
|
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
}
|
|
|
|
func TestConstantTimeCompare(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
a string
|
|
b string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "equal strings",
|
|
a: "hello-world-token",
|
|
b: "hello-world-token",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "different strings",
|
|
a: "hello-world-token",
|
|
b: "goodbye-world-token",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "different lengths",
|
|
a: "short",
|
|
b: "much-longer-string",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "empty strings",
|
|
a: "",
|
|
b: "",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "one empty",
|
|
a: "not-empty",
|
|
b: "",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := constantTimeCompare(tt.a, tt.b)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckRateLimit(t *testing.T) {
|
|
db := setupEmergencyTestDB(t)
|
|
handler := NewEmergencyHandler(db)
|
|
|
|
ip := "192.168.1.100"
|
|
|
|
// First MaxAttemptsPerWindow attempts should pass
|
|
for i := 0; i < MaxAttemptsPerWindow; i++ {
|
|
allowed := handler.checkRateLimit(ip)
|
|
assert.True(t, allowed, "Attempt %d should be allowed", i+1)
|
|
}
|
|
|
|
// Next attempt should be blocked
|
|
allowed := handler.checkRateLimit(ip)
|
|
assert.False(t, allowed, "Attempt after limit should be blocked")
|
|
|
|
// Different IP should still be allowed
|
|
differentIP := "192.168.1.101"
|
|
allowed = handler.checkRateLimit(differentIP)
|
|
assert.True(t, allowed, "Different IP should be allowed")
|
|
}
|