feat: break-glass security reset
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
This commit is contained in:
274
backend/internal/api/handlers/emergency_handler.go
Normal file
274
backend/internal/api/handlers/emergency_handler.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
// EmergencyTokenEnvVar is the environment variable name for the emergency token
|
||||
EmergencyTokenEnvVar = "CHARON_EMERGENCY_TOKEN"
|
||||
|
||||
// EmergencyTokenHeader is the HTTP header name for the emergency token
|
||||
EmergencyTokenHeader = "X-Emergency-Token"
|
||||
|
||||
// MinTokenLength is the minimum required length for the emergency token
|
||||
MinTokenLength = 32
|
||||
|
||||
// RateLimitWindow is the time window for rate limiting
|
||||
RateLimitWindow = time.Minute
|
||||
|
||||
// MaxAttemptsPerWindow is the maximum number of attempts allowed per IP per window
|
||||
MaxAttemptsPerWindow = 5
|
||||
)
|
||||
|
||||
// rateLimitEntry tracks rate limiting state for an IP
|
||||
type rateLimitEntry struct {
|
||||
attempts int
|
||||
windowEnd time.Time
|
||||
}
|
||||
|
||||
// EmergencyHandler handles emergency security reset operations
|
||||
type EmergencyHandler struct {
|
||||
db *gorm.DB
|
||||
securityService *services.SecurityService
|
||||
|
||||
// Rate limiting state
|
||||
rateLimitMu sync.Mutex
|
||||
rateLimits map[string]*rateLimitEntry
|
||||
}
|
||||
|
||||
// NewEmergencyHandler creates a new EmergencyHandler
|
||||
func NewEmergencyHandler(db *gorm.DB) *EmergencyHandler {
|
||||
return &EmergencyHandler{
|
||||
db: db,
|
||||
securityService: services.NewSecurityService(db),
|
||||
rateLimits: make(map[string]*rateLimitEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityReset disables all security modules for emergency lockout recovery.
|
||||
// This endpoint bypasses Cerberus middleware and should be registered BEFORE
|
||||
// the middleware is applied in routes.go.
|
||||
//
|
||||
// Security measures:
|
||||
// - Requires CHARON_EMERGENCY_TOKEN env var to be configured (min 32 chars)
|
||||
// - Requires X-Emergency-Token header to match (timing-safe comparison)
|
||||
// - Rate limited to 5 attempts per minute per IP
|
||||
// - All attempts (success and failure) are logged to audit trail
|
||||
func (h *EmergencyHandler) SecurityReset(c *gin.Context) {
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
// Check rate limit first (before any token validation)
|
||||
if !h.checkRateLimit(clientIP) {
|
||||
h.logAudit(clientIP, "emergency_reset_rate_limited", "Rate limit exceeded")
|
||||
log.WithFields(log.Fields{
|
||||
"ip": clientIP,
|
||||
"action": "emergency_reset_rate_limited",
|
||||
}).Warn("Emergency reset rate limit exceeded")
|
||||
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate limit exceeded",
|
||||
"message": "Too many attempts. Please wait before trying again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if emergency token is configured
|
||||
configuredToken := os.Getenv(EmergencyTokenEnvVar)
|
||||
if configuredToken == "" {
|
||||
h.logAudit(clientIP, "emergency_reset_not_configured", "Emergency token not configured")
|
||||
log.WithFields(log.Fields{
|
||||
"ip": clientIP,
|
||||
"action": "emergency_reset_not_configured",
|
||||
}).Warn("Emergency reset attempted but token not configured")
|
||||
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": "not configured",
|
||||
"message": "Emergency reset is not configured. Set CHARON_EMERGENCY_TOKEN environment variable.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token length
|
||||
if len(configuredToken) < MinTokenLength {
|
||||
h.logAudit(clientIP, "emergency_reset_invalid_config", "Configured token too short")
|
||||
log.WithFields(log.Fields{
|
||||
"ip": clientIP,
|
||||
"action": "emergency_reset_invalid_config",
|
||||
}).Error("Emergency token configured but too short")
|
||||
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": "not configured",
|
||||
"message": "Emergency token is configured but does not meet minimum length requirements.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get token from header
|
||||
providedToken := c.GetHeader(EmergencyTokenHeader)
|
||||
if providedToken == "" {
|
||||
h.logAudit(clientIP, "emergency_reset_missing_token", "No token provided in header")
|
||||
log.WithFields(log.Fields{
|
||||
"ip": clientIP,
|
||||
"action": "emergency_reset_missing_token",
|
||||
}).Warn("Emergency reset attempted without token")
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "unauthorized",
|
||||
"message": "Emergency token required in X-Emergency-Token header.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Timing-safe token comparison to prevent timing attacks
|
||||
if !constantTimeCompare(configuredToken, providedToken) {
|
||||
h.logAudit(clientIP, "emergency_reset_invalid_token", "Invalid token provided")
|
||||
log.WithFields(log.Fields{
|
||||
"ip": clientIP,
|
||||
"action": "emergency_reset_invalid_token",
|
||||
}).Warn("Emergency reset attempted with invalid token")
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "unauthorized",
|
||||
"message": "Invalid emergency token.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Token is valid - disable all security modules
|
||||
disabledModules, err := h.disableAllSecurityModules()
|
||||
if err != nil {
|
||||
h.logAudit(clientIP, "emergency_reset_failed", fmt.Sprintf("Failed to disable modules: %v", err))
|
||||
log.WithFields(log.Fields{
|
||||
"ip": clientIP,
|
||||
"action": "emergency_reset_failed",
|
||||
"error": err.Error(),
|
||||
}).Error("Emergency reset failed to disable security modules")
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "internal error",
|
||||
"message": "Failed to disable security modules. Check server logs.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Log successful reset
|
||||
h.logAudit(clientIP, "emergency_reset_success", fmt.Sprintf("Disabled modules: %v", disabledModules))
|
||||
log.WithFields(log.Fields{
|
||||
"ip": clientIP,
|
||||
"action": "emergency_reset_success",
|
||||
"disabled_modules": disabledModules,
|
||||
}).Warn("EMERGENCY SECURITY RESET: All security modules disabled")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "All security modules have been disabled. Please reconfigure security settings.",
|
||||
"disabled_modules": disabledModules,
|
||||
})
|
||||
}
|
||||
|
||||
// checkRateLimit returns true if the request is allowed, false if rate limited
|
||||
func (h *EmergencyHandler) checkRateLimit(ip string) bool {
|
||||
h.rateLimitMu.Lock()
|
||||
defer h.rateLimitMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
entry, exists := h.rateLimits[ip]
|
||||
|
||||
if !exists || now.After(entry.windowEnd) {
|
||||
// New window
|
||||
h.rateLimits[ip] = &rateLimitEntry{
|
||||
attempts: 1,
|
||||
windowEnd: now.Add(RateLimitWindow),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Within existing window
|
||||
if entry.attempts >= MaxAttemptsPerWindow {
|
||||
return false
|
||||
}
|
||||
|
||||
entry.attempts++
|
||||
return true
|
||||
}
|
||||
|
||||
// disableAllSecurityModules disables Cerberus, ACL, WAF, Rate Limit, and CrowdSec
|
||||
func (h *EmergencyHandler) disableAllSecurityModules() ([]string, error) {
|
||||
disabledModules := []string{}
|
||||
|
||||
// Settings to disable
|
||||
securitySettings := map[string]string{
|
||||
"feature.cerberus.enabled": "false",
|
||||
"security.acl.enabled": "false",
|
||||
"security.waf.enabled": "false",
|
||||
"security.rate_limit.enabled": "false",
|
||||
"security.crowdsec.enabled": "false",
|
||||
}
|
||||
|
||||
// Disable each module via settings
|
||||
for key, value := range securitySettings {
|
||||
setting := models.Setting{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Category: "security",
|
||||
Type: "bool",
|
||||
}
|
||||
|
||||
if err := h.db.Where(models.Setting{Key: key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
|
||||
return disabledModules, fmt.Errorf("failed to disable %s: %w", key, err)
|
||||
}
|
||||
disabledModules = append(disabledModules, key)
|
||||
}
|
||||
|
||||
// Also update the SecurityConfig record if it exists
|
||||
var securityConfig models.SecurityConfig
|
||||
if err := h.db.Where("name = ?", "default").First(&securityConfig).Error; err == nil {
|
||||
securityConfig.Enabled = false
|
||||
securityConfig.WAFMode = "disabled"
|
||||
securityConfig.RateLimitMode = "disabled"
|
||||
securityConfig.RateLimitEnable = false
|
||||
securityConfig.CrowdSecMode = "disabled"
|
||||
|
||||
if err := h.db.Save(&securityConfig).Error; err != nil {
|
||||
log.WithError(err).Warn("Failed to update SecurityConfig record during emergency reset")
|
||||
}
|
||||
}
|
||||
|
||||
return disabledModules, nil
|
||||
}
|
||||
|
||||
// logAudit logs an emergency action to the security audit trail
|
||||
func (h *EmergencyHandler) logAudit(actor, action, details string) {
|
||||
if h.securityService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
audit := &models.SecurityAudit{
|
||||
Actor: actor,
|
||||
Action: action,
|
||||
Details: details,
|
||||
}
|
||||
|
||||
if err := h.securityService.LogAudit(audit); err != nil {
|
||||
log.WithError(err).Error("Failed to log emergency audit event")
|
||||
}
|
||||
}
|
||||
|
||||
// constantTimeCompare performs a timing-safe string comparison
|
||||
func constantTimeCompare(a, b string) bool {
|
||||
// Use crypto/subtle for timing-safe comparison
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
350
backend/internal/api/handlers/emergency_handler_test.go
Normal file
350
backend/internal/api/handlers/emergency_handler_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
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")
|
||||
}
|
||||
@@ -101,6 +101,12 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
promhttp.HandlerFor(reg, promhttp.HandlerOpts{}).ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
// Emergency endpoint - MUST be registered BEFORE Cerberus middleware
|
||||
// This endpoint bypasses all security checks for lockout recovery
|
||||
// Requires CHARON_EMERGENCY_TOKEN env var to be configured
|
||||
emergencyHandler := handlers.NewEmergencyHandler(db)
|
||||
router.POST("/api/v1/emergency/security-reset", emergencyHandler.SecurityReset)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
|
||||
// Cerberus middleware applies the optional security suite checks (WAF, ACL, CrowdSec)
|
||||
|
||||
Reference in New Issue
Block a user