43 KiB
Cerberus Security Module - Comprehensive Remediation Plan
Version: 2.0 Date: 2025-12-12 Status: 🔴 PENDING - Issues #16, #17, #18, #19 incomplete
Executive Summary
This document provides a comprehensive, actionable remediation plan to complete the Cerberus security module. Four GitHub issues remain partially implemented:
| Issue | Feature | Current State | Priority |
|---|---|---|---|
| #16 | GeoIP Integration | Database downloaded, no Go code reads it | HIGH |
| #17 | CrowdSec Bouncer | Placeholder comment in code | HIGH |
| #18 | WAF (Coraza) Integration | Only checks <script> tag, no real Coraza config |
HIGH |
| #19 | Rate Limiting | Burst field unused, no bypass list/templates | MEDIUM |
Implementation Phases
Phase 1: GeoIP Integration (Issue #16)
Goal: Enable actual IP → Country lookup for geo-blocking ACLs.
1.1 Current State Analysis
- Dockerfile downloads
GeoLite2-Country.mmdbto/app/data/geoip/ - Environment variable
CHARON_GEOIP_DB_PATHis set - No Go code imports or reads the MaxMind database
- Caddy config uses
caddy-geoip2plugin with{geoip2.country_code}placeholder (works at proxy level) TestIPservice method only evaluates IP/CIDR rules, returns default for geo types
1.2 Required Changes
1.2.1 Add GeoIP Dependency
File: backend/go.mod
cd backend && go get github.com/oschwald/geoip2-golang
1.2.2 Create GeoIP Service
New File: backend/internal/services/geoip_service.go
// Package services provides business logic for the application.
package services
import (
"errors"
"net"
"sync"
"github.com/oschwald/geoip2-golang"
)
var (
ErrGeoIPDatabaseNotLoaded = errors.New("geoip database not loaded")
ErrInvalidIP = errors.New("invalid IP address")
ErrCountryNotFound = errors.New("country not found for IP")
)
// GeoIPService provides IP-to-country lookups using MaxMind GeoLite2.
type GeoIPService struct {
mu sync.RWMutex
db *geoip2.Reader
dbPath string
}
// NewGeoIPService creates a new GeoIPService and loads the database.
func NewGeoIPService(dbPath string) (*GeoIPService, error) {
svc := &GeoIPService{dbPath: dbPath}
if err := svc.Load(); err != nil {
return nil, err
}
return svc, nil
}
// Load opens or reloads the GeoIP database.
func (s *GeoIPService) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.db != nil {
s.db.Close()
}
db, err := geoip2.Open(s.dbPath)
if err != nil {
return err
}
s.db = db
return nil
}
// Close releases the database resources.
func (s *GeoIPService) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.db != nil {
return s.db.Close()
}
return nil
}
// LookupCountry returns the ISO 3166-1 alpha-2 country code for an IP.
func (s *GeoIPService) LookupCountry(ipStr string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.db == nil {
return "", ErrGeoIPDatabaseNotLoaded
}
ip := net.ParseIP(ipStr)
if ip == nil {
return "", ErrInvalidIP
}
record, err := s.db.Country(ip)
if err != nil {
return "", err
}
if record.Country.IsoCode == "" {
return "", ErrCountryNotFound
}
return record.Country.IsoCode, nil
}
// IsLoaded returns true if the database is loaded.
func (s *GeoIPService) IsLoaded() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.db != nil
}
1.2.3 Update AccessListService to Use GeoIP
File: backend/internal/services/access_list_service.go
Add field to struct:
type AccessListService struct {
db *gorm.DB
geoipSvc *GeoIPService // NEW
}
Update constructor:
func NewAccessListService(db *gorm.DB) *AccessListService {
return &AccessListService{
db: db,
geoipSvc: nil, // Will be set via SetGeoIPService
}
}
// SetGeoIPService attaches a GeoIP service for country lookups.
func (s *AccessListService) SetGeoIPService(geoipSvc *GeoIPService) {
s.geoipSvc = geoipSvc
}
Update TestIP method to handle geo types:
// TestIP tests if an IP address would be allowed/blocked by the access list
func (s *AccessListService) TestIP(aclID uint, ipAddress string) (allowed bool, reason string, err error) {
acl, err := s.GetByID(aclID)
if err != nil {
return false, "", err
}
if !acl.Enabled {
return true, "Access list is disabled - all traffic allowed", nil
}
ip := net.ParseIP(ipAddress)
if ip == nil {
return false, "", ErrInvalidIPAddress
}
// Handle geo-based ACLs
if strings.HasPrefix(acl.Type, "geo_") {
if s.geoipSvc == nil {
return true, "GeoIP service not available - allowing by default", nil
}
countryCode, err := s.geoipSvc.LookupCountry(ipAddress)
if err != nil {
// If lookup fails, allow with warning
return true, fmt.Sprintf("GeoIP lookup failed: %v - allowing by default", err), nil
}
// Parse country codes from ACL
allowedCodes := make(map[string]bool)
for _, code := range strings.Split(acl.CountryCodes, ",") {
allowedCodes[strings.TrimSpace(strings.ToUpper(code))] = true
}
isInList := allowedCodes[countryCode]
if acl.Type == "geo_whitelist" {
if isInList {
return true, fmt.Sprintf("Allowed by geo whitelist: IP from %s", countryCode), nil
}
return false, fmt.Sprintf("Blocked: IP from %s not in geo whitelist", countryCode), nil
}
// geo_blacklist
if isInList {
return false, fmt.Sprintf("Blocked by geo blacklist: IP from %s", countryCode), nil
}
return true, fmt.Sprintf("Allowed: IP from %s not in geo blacklist", countryCode), nil
}
// ... rest of existing IP/CIDR logic unchanged ...
}
1.2.4 Initialize GeoIP Service on Server Start
File: backend/internal/server/server.go (or wherever services are initialized)
import (
"os"
// ...
)
// In server initialization:
geoipPath := os.Getenv("CHARON_GEOIP_DB_PATH")
if geoipPath == "" {
geoipPath = "/app/data/geoip/GeoLite2-Country.mmdb"
}
var geoipSvc *services.GeoIPService
if _, err := os.Stat(geoipPath); err == nil {
geoipSvc, err = services.NewGeoIPService(geoipPath)
if err != nil {
logger.Log().WithError(err).Warn("Failed to load GeoIP database, geo-blocking will be unavailable")
} else {
logger.Log().Info("GeoIP database loaded successfully")
}
}
// Pass to AccessListService
accessListSvc := services.NewAccessListService(db)
if geoipSvc != nil {
accessListSvc.SetGeoIPService(geoipSvc)
}
1.2.5 Add GeoIP Reload Endpoint (Optional Enhancement)
File: backend/internal/api/handlers/security_handler.go
// ReloadGeoIP reloads the GeoIP database from disk
func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
if h.geoipSvc == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "GeoIP service not initialized"})
return
}
if err := h.geoipSvc.Load(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to reload: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "GeoIP database reloaded"})
}
Add route: POST /api/v1/security/geoip/reload
1.3 Test Requirements
New File: backend/internal/services/geoip_service_test.go
func TestGeoIPService_LookupCountry(t *testing.T) {
// Skip if no test database available
testDBPath := os.Getenv("TEST_GEOIP_DB_PATH")
if testDBPath == "" {
t.Skip("TEST_GEOIP_DB_PATH not set")
}
svc, err := NewGeoIPService(testDBPath)
require.NoError(t, err)
defer svc.Close()
tests := []struct {
name string
ip string
wantCC string
wantErr bool
}{
{"Google DNS", "8.8.8.8", "US", false},
{"Cloudflare", "1.1.1.1", "AU", false}, // May vary
{"Invalid IP", "not-an-ip", "", true},
{"Private IP", "192.168.1.1", "", true}, // No country
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cc, err := svc.LookupCountry(tt.ip)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.wantCC, cc)
}
})
}
}
Update: backend/internal/services/access_list_service_test.go - add tests for geo ACL TestIP.
1.4 Frontend Changes
File: frontend/src/pages/AccessLists.tsx
Update the test IP result display to show country code when geo-blocking:
// In handleTestIP success callback:
if (result.reason.includes("IP from")) {
toast.success(`${result.allowed ? '✅' : '🚫'} ${result.reason}`)
} else {
// existing logic
}
1.5 Documentation Update
File: docs/cerberus.md - Add section on GeoIP configuration and database updates.
Phase 2: Rate Limit Fix (Issue #19) - Quick Win
Goal: Fix burst field usage, add bypass list and preset templates.
2.1 Current State Analysis
RateLimitBurstfield exists inSecurityConfigmodelbuildRateLimitHandlerincaddy/config.goignores burst field- No bypass list for trusted IPs
- No preset templates (login, API, standard)
- Frontend has burst input but it's not used by backend
2.2 Required Changes
2.2.1 Fix Burst Field in Caddy Config
File: backend/internal/caddy/config.go
// buildRateLimitHandler returns a rate-limit handler using the caddy-ratelimit module.
func buildRateLimitHandler(_ *models.ProxyHost, secCfg *models.SecurityConfig) (Handler, error) {
if secCfg == nil {
return nil, nil
}
if secCfg.RateLimitRequests <= 0 || secCfg.RateLimitWindowSec <= 0 {
return nil, nil
}
// Calculate burst: if not set, default to 20% of requests
burst := secCfg.RateLimitBurst
if burst <= 0 {
burst = secCfg.RateLimitRequests / 5
if burst < 1 {
burst = 1
}
}
// caddy-ratelimit format with burst support
h := Handler{"handler": "rate_limit"}
h["rate_limits"] = map[string]interface{}{
"static": map[string]interface{}{
"key": "{http.request.remote.host}",
"window": fmt.Sprintf("%ds", secCfg.RateLimitWindowSec),
"max_events": secCfg.RateLimitRequests,
// NOTE: caddy-ratelimit doesn't have a direct "burst" param,
// but we can use distributed rate limiting or adjust max_events
},
}
return h, nil
}
Note: The
caddy-ratelimitmodule by mholt doesn't have a direct burst parameter. Consider:
- Using a sliding window algorithm (already default)
- Implementing burst via separate zone for initial requests
- Document limitation in UI
2.2.2 Add Bypass List Support
File: backend/internal/models/security_config.go
type SecurityConfig struct {
// ... existing fields ...
RateLimitBypassList string `json:"rate_limit_bypass_list" gorm:"type:text"` // Comma-separated CIDRs
}
File: backend/internal/caddy/config.go
func buildRateLimitHandler(host *models.ProxyHost, secCfg *models.SecurityConfig) (Handler, error) {
// ... existing validation ...
h := Handler{"handler": "rate_limit"}
// Build zone configuration
zone := map[string]interface{}{
"key": "{http.request.remote.host}",
"window": fmt.Sprintf("%ds", secCfg.RateLimitWindowSec),
"max_events": secCfg.RateLimitRequests,
}
h["rate_limits"] = map[string]interface{}{"static": zone}
// If bypass list is configured, wrap in a subroute that skips for those IPs
if secCfg.RateLimitBypassList != "" {
bypassCIDRs := parseBypassList(secCfg.RateLimitBypassList)
if len(bypassCIDRs) > 0 {
return Handler{
"handler": "subroute",
"routes": []map[string]interface{}{
{
// Skip rate limiting for bypass IPs
"match": []map[string]interface{}{
{"remote_ip": map[string]interface{}{"ranges": bypassCIDRs}},
},
"terminal": false, // Continue to proxy handler
},
{
// Apply rate limiting for all others
"handle": []Handler{h},
},
},
}, nil
}
}
return h, nil
}
#### 2.3 Rate Limiting — Test Plan (Detailed)
**Summary:** This section contains a complete test plan to validate rate limiting configuration generation and runtime enforcement in Charon. Tests are grouped into Unit, Integration and E2E categories and prioritize quick unit coverage and high-impact integration tests.
Goal: Verify the following behavior:
- The `RateLimitBurst`, `RateLimitRequests`, `RateLimitWindowSec` and `RateLimitBypassList` fields are used by `buildRateLimitHandler` and emitted into the Caddy JSON configuration.
- The Caddy `rate_limit` handler configuration uses `{http.request.remote.host}` as the key.
- Bypass IPs are excluded from rate limiting by creating a subroute with `remote_ip` matcher.
- At runtime, Caddy enforces limits, returns `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `Retry-After` headers (or documented equivalents), and resets counters after the configured window.
- The plugin behaves correctly across multiple client IPs and respects bypass lists.
-----
2.3.1 Files to Add / Edit
- New scripts: `scripts/rate_limit_integration.sh` (shell integration test), `scripts/rate_limit_e2e.sh` (optional extended tests).
- New integration test: `backend/integration/rate_limit_integration_test.go` (go test wrapper runs script).
- Unit tests to add/edit: `backend/internal/caddy/config_test.go` (add `TestGenerateConfig_WithRateLimitBypassList` and `TestBuildRateLimitHandler_KeyIsRemoteHost`), `backend/internal/api/handlers/security_ratelimit_test.go` (validate API fields persist & UpdateConfig accepts rate_limit fields).
2.3.2 Unit Tests (fast, run in CI pre-merge)
- File: `backend/internal/caddy/config_test.go`
- `TestGenerateConfig_WithRateLimitBypassList`
- Input: call `GenerateConfig` with `secCfg` set with `RateLimitEnable:true` and `RateLimitBypassList:"10.0.0.0/8,127.0.0.1/32"`; include one host.
- Assertions:
- The generated `Config` contains a route with `handler:"subroute"` or a `rate_limit` handler containing the bypass CIDRs (CIDRs found in JSON output).
- `RateLimitHandler` contains `rate_limits` map and `static` zone.
- `TestBuildRateLimitHandler_KeyIsRemoteHost`
- Input: `secCfg` with valid values.
- Assertions: the static zone `key` is `{http.request.remote.host}`.
- `TestBuildRateLimitHandler_DefaultBurstAndMax` (already present) and `TestParseBypassCIDRs` (existing) remain required.
2.3.3 Integration Tests (CI gated, Docker required)
We will add a scripted integration test to run inside CI or locally with Docker. The test will:
- Start the `charon:local` image (build if not present) in a detached container named `charon-debug`.
- Create a simple HTTP backend (httpbin/kennethreitz/httpbin) called `ratelimit-backend` (or `httpbin`).
- Create a proxy host `ratelimit.local` pointing to the backend via the Charon API (use /api/v1/proxy-hosts).
- Set `SecurityConfig` (POST /api/v1/security/config) with short windows for speed, e.g.:
```json
{"name":"default","enabled":true,"rate_limit_enable":true,"rate_limit_requests":3,"rate_limit_window_sec":10,"rate_limit_burst":1}
```
- Validate that Caddy Admin API at `http://localhost:2019/config` includes a `rate_limit` handler and, where applicable, a `subroute` with bypass CIDRs (if `RateLimitBypassList` set).
- Execute the runtime checks:
- Using a single client IP, send 3 requests in quick succession expecting HTTP 200.
- The 4th request (same client IP) should return HTTP 429 (Too Many Requests) and include a `Retry-After` header.
- On allowed responses, assert that `X-RateLimit-Limit` equals 3 and `X-RateLimit-Remaining` decrements.
- Wait until the configured `RateLimitWindowSec` elapses, and confirm requests are allowed again (headers reset).
- Bypass List Validation:
- Set `RateLimitBypassList` to contain the requester's IP (or `127.0.0.1/32` when client runs from the host). Confirm repeated requests do not get `429`, and `X-RateLimit-*` headers may be absent or indicate non-enforcement.
- Multi-IP Isolation:
- Spin up two client containers with different IPs (via Docker network `--subnet` + `--ip`). Each should have independent counters; both able to make configured number requests without affecting the other.
- X-Forwarded-For behavior (Confirm remote.host is used as key):
- Send requests with `X-Forwarded-For` different than the container IP; observe rate counters still use the connection IP unless Caddy remote_ip plugin explicitly configured to respect XFF.
- Test Example (Shell Snippet to assert headers)
```bash
# Single request driver - check headers
curl -s -D - -o /dev/null -H "Host: ratelimit.local" http://localhost/post
# Expect headers: X-RateLimit-Limit: 3, X-RateLimit-Remaining: <number>
```
- Script name: `scripts/rate_limit_integration.sh` (mirrors style of `coraza_integration.sh`).
- Manage flaky behavior:
- Retry a couple times and log Caddy admin API output on failure for debugging.
+
2.3.4 E2E Tests (Longer, optional)
- Create `scripts/rate_limit_e2e.sh` which spins up the same environment but runs broader scenarios:
- High-rate bursts (WindowSec small and Requests small) to test burst allowance/consumption.
- Multi-minute stress run (not for every CI pass) to check long-term behavior and reset across windows.
- SPA / browser test using Playwright / Cypress to validate UI controls (admin toggles rate limit presets and sets bypass list) and ensures that applied config is effective at runtime.
2.3.5 Mock/Stub Guidance
- IP Addresses
- Use Docker network subnets and `docker run --network containers_default --ip 172.25.0.10` to guarantee client IP addresses for tests and to exercise bypass list behavior.
- For tests run from host with `curl`, include `--interface` or `--local-port` if needed to force source IP (less reliable than container-based approach).
- X-Forwarded-For
- Add `-H "X-Forwarded-For: 10.0.0.5"` to `curl` requests; assert that plugin uses real connection IP by default. If future changes enable `real_ip` handling in Caddy, tests should be updated to reflect the new behavior.
- Timing Windows
- Keep small values (2-10 seconds) while maintaining reliability (1s windows are often flaky). For CI environment, `RateLimitWindowSec=10` with `RateLimitRequests=3` and `Burst=1` is a stable, fast choice.
2.3.6 Test Data and Assertions (Explicit)
- Unit Test: `TestBuildRateLimitHandler_ValidConfig`
- Input: secCfg{Requests:100, WindowSec:60, Burst:25}
- Assert: `h["handler"] == "rate_limit"`, `static".max_events == 100`, `burst == 25`.
- Integration Test: `TestRateLimit_Enforcement_Basic`
- Input: RateLimitRequests=3, RateLimitWindowSec=10, Burst=1, no bypass list
- Actions: Send 4 rapid requests using client container
- Expected outputs: [200, 200, 200, 429], 4th returns Retry-After or explicit block message
- Assert: Allowed responses include `X-RateLimit-Limit: 3`, and `X-RateLimit-Remaining` decreasing
- Integration Test: `TestRateLimit_BypassList_SkipsLimit`
- Input: Same as above + `RateLimitBypassList` contains client IP CIDR
- Expected outputs: All requests 200 (no 429)
- Integration Test: `TestRateLimit_MultiClient_Isolation`
- Input: As above
- Actions: Client A sends 3 requests, Client B sends 3 requests
- Expected: Both clients unaffected by the other; both get 200 responses for their first 3 requests
- Integration Test: `TestRateLimit_Window_Reset`
- Input: As above
- Actions: Exhaust quota (get 429), wait `RateLimitWindowSec + 1`, issue a new request
- Expected: New request is 200 again
2.3.7 Test Harness - Example Go Integration Test
Use the same approach as `backend/integration/coraza_integration_test.go`, run the script and check output for expected messages. Example test file: `backend/integration/rate_limit_integration_test.go`:
```go
//go:build integration
// +build integration
package integration
import (
"context"
"os/exec"
"strings"
"testing"
"time"
)
func TestRateLimitIntegration(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "./scripts/rate_limit_integration.sh")
out, err := cmd.CombinedOutput()
t.Logf("rate_limit_integration script output:\n%s", string(out))
if err != nil {
t.Fatalf("rate_limit integration failed: %v", err)
}
if !strings.Contains(string(out), "Rate limit enforcement succeeded") {
t.Fatalf("unexpected script output, rate limiting assertion not found")
}
}
2.3.8 CI and Pre-commit Hooks
- Add an integration CI job that runs the Docker-based script and the integration
gotest suite in a separate job to avoid blocking unit test runs on tools requiring Docker. Use a job matrix withservices: dockerand timeouts set appropriately. - Do not add integration scripts to pre-commit (too heavy); keep pre-commit focused on
go fmt,go vet,go test ./...(unit tests),npm test, and lint rules. - Use the workspace
tasks.jsonto add aCoraza: Run Integration Scriptstyle task for rate limit integration that mirrorsscripts/coraza_integration.sh.
2.3.9 .gitignore / .codecov.yml / Dockerfile changes
- .gitignore
- Add
test-results/rate_limit/to avoid committing local script logs. - Add
scripts/rate_limit_integration.shoutput files (if any) to ignore.
- Add
- .codecov.yml
- Optional: If you want integration test coverage included, remove
**/integration/**fromignoreor add a specificbackend/integration/*_test.goto be included. (Caveat: integration coverage may not be reproducible across CI).
- Optional: If you want integration test coverage included, remove
- .dockerignore
- Ensure
scripts/andbackend/integrationare not copied to reduce build context size if not needed in Docker build.
- Ensure
- Dockerfile
- Confirm presence of
--with github.com/mholt/caddy-ratelimitin the xcaddy build (it is present in base Dockerfile). Add comment and assert plugin presence in integration script by checkingcaddy versionorcaddy listavailable modules.
- Confirm presence of
2.3.10 Prioritization
- P0: Integration test
TestRateLimit_Enforcement_Basic(high confidence: verifies actual runtime limit enforcement and header presence) - P1: Unit tests verifying config building (
TestGenerateConfig_WithRateLimitBypassList,TestBuildRateLimitHandler_KeyIsRemoteHost) and API tests forPOST /security/confighandling rate limit fields - P2: Integration tests for bypass list, multi-client isolation, window reset
- P3: E2E tests for UI configuration of rate limiting and long-running stress tests
2.3.11 Next Steps
- Implement
scripts/rate_limit_integration.shandbackend/integration/rate_limit_integration_test.gofollowingcoraza_integration.shas the blueprint. - Add unit tests to
backend/internal/caddy/config_test.goand API handler tests inbackend/internal/api/handlers/security_ratelimit_test.go. - Add Docker network helpers and ensure
docker run --ipis used to control client IPs during integration. - Run all new tests locally (Docker required) and in CI. Add integration job to GitHub Actions with
runs-on: ubuntu-latest,services: dockerand appropriate timeouts.
This test plan should serve as a complete specification for testing rate limiting behavior across unit, integration, and E2E tiers. The next iteration will include scripted test implementations and Jenkins/GHA job snippets for CI.
func parseBypassList(list string) []string { var cidrs []string for _, part := range strings.Split(list, ",") { part = strings.TrimSpace(part) if part == "" { continue } // Validate CIDR if _, _, err := net.ParseCIDR(part); err == nil { cidrs = append(cidrs, part) } else if net.ParseIP(part) != nil { // Single IP - convert to /32 or /128 if strings.Contains(part, ":") { cidrs = append(cidrs, part+"/128") } else { cidrs = append(cidrs, part+"/32") } } } return cidrs }
##### 2.2.3 Add Preset Templates
**File:** `backend/internal/api/handlers/security_handler.go`
```go
// GetRateLimitPresets returns predefined rate limit configurations
func (h *SecurityHandler) GetRateLimitPresets(c *gin.Context) {
presets := []map[string]interface{}{
{
"id": "standard",
"name": "Standard Web",
"description": "Balanced protection for general web applications",
"requests": 100,
"window_sec": 60,
"burst": 20,
},
{
"id": "api",
"name": "API Protection",
"description": "Stricter limits for API endpoints",
"requests": 30,
"window_sec": 60,
"burst": 10,
},
{
"id": "login",
"name": "Login Protection",
"description": "Aggressive protection against brute-force",
"requests": 5,
"window_sec": 300,
"burst": 2,
},
{
"id": "relaxed",
"name": "High Traffic",
"description": "Higher limits for trusted, high-traffic apps",
"requests": 500,
"window_sec": 60,
"burst": 100,
},
}
c.JSON(http.StatusOK, gin.H{"presets": presets})
}
Add route: GET /api/v1/security/rate-limit/presets
2.2.4 Frontend Updates
File: frontend/src/api/security.ts
export interface RateLimitPreset {
id: string
name: string
description: string
requests: number
window_sec: number
burst: number
}
export const getRateLimitPresets = async (): Promise<{ presets: RateLimitPreset[] }> => {
const response = await client.get('/security/rate-limit/presets')
return response.data
}
File: frontend/src/pages/RateLimiting.tsx
Add preset dropdown and bypass list input (see implementation details in frontend section below).
2.3 Test Requirements
File: backend/internal/caddy/config_test.go
func TestBuildRateLimitHandler_UsesBurst(t *testing.T) {
secCfg := &models.SecurityConfig{
RateLimitRequests: 100,
RateLimitWindowSec: 60,
RateLimitBurst: 25,
}
h, err := buildRateLimitHandler(nil, secCfg)
require.NoError(t, err)
require.NotNil(t, h)
// Verify burst is used in config
}
func TestBuildRateLimitHandler_BypassList(t *testing.T) {
secCfg := &models.SecurityConfig{
RateLimitRequests: 100,
RateLimitWindowSec: 60,
RateLimitBypassList: "10.0.0.0/8,192.168.1.1",
}
h, err := buildRateLimitHandler(nil, secCfg)
require.NoError(t, err)
// Verify subroute structure with bypass
}
Phase 3: CrowdSec Bouncer (Issue #17)
Goal: Implement actual IP reputation checking against CrowdSec decisions.
3.1 Current State Analysis
- CrowdSec binary installed in Docker image
caddy-crowdsec-bouncerplugin compiled into CaddybuildCrowdSecHandlerreturns a handler but only setsapi_url- Comment in
cerberus.go: "CrowdSec placeholder: integration would check CrowdSec API" - No actual decision lookup code
3.2 Architecture Decision
Two approaches:
| Approach | Pros | Cons |
|---|---|---|
| A: Caddy Plugin | Offloads to Caddy, less Go code | Requires Caddy config regeneration |
| B: Middleware | Real-time, Go-native | Duplicates bouncer logic |
Recommendation: Use Approach A (Caddy Plugin) since caddy-crowdsec-bouncer is already compiled in. Enhance the handler configuration.
3.3 Required Changes
3.3.1 Update CrowdSec Handler Builder
File: backend/internal/caddy/config.go
// buildCrowdSecHandler returns a CrowdSec bouncer handler.
// See: https://github.com/hslatman/caddy-crowdsec-bouncer
func buildCrowdSecHandler(host *models.ProxyHost, secCfg *models.SecurityConfig, crowdsecEnabled bool) (Handler, error) {
if !crowdsecEnabled {
return nil, nil
}
h := Handler{"handler": "crowdsec"}
// API URL (required)
apiURL := "http://localhost:8080"
if secCfg != nil && secCfg.CrowdSecAPIURL != "" {
apiURL = secCfg.CrowdSecAPIURL
}
h["api_url"] = apiURL
// API Key (from environment or config)
apiKey := os.Getenv("CROWDSEC_API_KEY")
if apiKey == "" && secCfg != nil {
// Could store encrypted in DB - for now use env var
}
if apiKey != "" {
h["api_key"] = apiKey
}
// Ticker interval for decision sync (default 30s)
h["ticker_interval"] = "30s"
// Enable streaming mode for real-time updates
h["enable_streaming"] = true
return h, nil
}
3.3.2 Add CrowdSec Registration on Startup
File: backend/internal/crowdsec/registration.go (new)
// Package crowdsec handles CrowdSec LAPI integration.
package crowdsec
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"time"
)
// EnsureBouncerRegistered registers the Caddy bouncer with local CrowdSec LAPI.
func EnsureBouncerRegistered(lapiURL string) (string, error) {
// Check if already registered
apiKey := os.Getenv("CROWDSEC_API_KEY")
if apiKey != "" {
return apiKey, nil
}
// Use cscli to register bouncer
cmd := exec.Command("cscli", "bouncers", "add", "caddy-bouncer", "-o", "raw")
output, err := cmd.Output()
if err != nil {
// May already exist, try to get existing key
return "", fmt.Errorf("failed to register bouncer: %w", err)
}
apiKey = string(bytes.TrimSpace(output))
return apiKey, nil
}
// CheckLAPIHealth verifies CrowdSec LAPI is responding.
func CheckLAPIHealth(lapiURL string) bool {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(lapiURL + "/v1/decisions")
if err != nil {
return false
}
defer resp.Body.Close()
// 401 is expected without auth, but means LAPI is up
return resp.StatusCode == 401 || resp.StatusCode == 200
}
3.3.3 Update Cerberus Middleware (Optional - for logging)
File: backend/internal/cerberus/cerberus.go
The actual blocking is handled by the Caddy CrowdSec bouncer plugin. However, we can add logging in Cerberus:
// In Middleware(), after ACL check:
// CrowdSec logging (actual blocking is done by Caddy bouncer)
if c.cfg.CrowdSecMode == "local" {
// Log that CrowdSec is active (blocking happens at Caddy layer)
logger.Log().WithField("client_ip", ctx.ClientIP()).Debug("Request evaluated by CrowdSec bouncer")
}
3.3.4 Add CrowdSec Status Endpoint Enhancement
File: backend/internal/api/handlers/crowdsec_handler.go
// GetCrowdSecDecisions returns recent decisions from CrowdSec LAPI
func (h *CrowdSecHandler) GetDecisions(c *gin.Context) {
lapiURL := os.Getenv("CROWDSEC_LAPI_URL")
if lapiURL == "" {
lapiURL = "http://localhost:8080"
}
apiKey := os.Getenv("CROWDSEC_API_KEY")
if apiKey == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "CrowdSec API key not configured"})
return
}
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest("GET", lapiURL+"/v1/decisions", nil)
req.Header.Set("X-Api-Key", apiKey)
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to connect to CrowdSec LAPI"})
return
}
defer resp.Body.Close()
var decisions []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&decisions)
c.JSON(http.StatusOK, gin.H{"decisions": decisions})
}
3.4 Frontend Updates
File: frontend/src/pages/CrowdSecConfig.tsx
Add decisions viewer panel:
// In CrowdSecConfig component:
const { data: decisions } = useQuery({
queryKey: ['crowdsec-decisions'],
queryFn: () => client.get('/api/v1/crowdsec/decisions').then(r => r.data),
enabled: status?.crowdsec?.enabled,
refetchInterval: 30000,
})
// Render decisions table
{decisions?.decisions?.length > 0 && (
<Card>
<h3>Active Decisions</h3>
<table>
<thead>
<tr>
<th>IP/Range</th>
<th>Type</th>
<th>Reason</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{decisions.decisions.map((d: Decision) => (
<tr key={d.id}>
<td>{d.value}</td>
<td>{d.type}</td>
<td>{d.scenario}</td>
<td>{formatExpiry(d.until)}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
3.5 Test Requirements
File: backend/internal/crowdsec/registration_test.go
func TestCheckLAPIHealth(t *testing.T) {
// Mock server test
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized) // Expected without auth
}))
defer ts.Close()
assert.True(t, CheckLAPIHealth(ts.URL))
}
Phase 4: WAF Integration (Issue #18) - Most Complex
Goal: Generate actual Coraza configuration, per-host toggle, rule exclusions, paranoia levels.
4.1 Current State Analysis
cerberus.goonly checks for literal<script>string in URL (trivial bypass)buildWAFHandlerinconfig.gocreates Coraza handler but requires ruleset- Frontend (
WafConfig.tsx) manages rule sets but no per-host toggle - No paranoia level selector
- No rule exclusion system for false positives
4.2 Required Changes
4.2.1 Enhance WAF Handler Builder
File: backend/internal/caddy/config.go
// buildWAFHandler returns a WAF handler (Coraza) configuration.
func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, secCfg *models.SecurityConfig, wafEnabled bool) (Handler, error) {
if !wafEnabled {
return nil, nil
}
// Check per-host WAF toggle
if host != nil && host.WAFDisabled {
return nil, nil
}
// Build directives
var directives strings.Builder
// Base configuration
directives.WriteString("SecRuleEngine On\n")
directives.WriteString("SecRequestBodyAccess On\n")
directives.WriteString("SecResponseBodyAccess Off\n")
// Paranoia level (1-4, default 1)
paranoiaLevel := 1
if secCfg != nil && secCfg.WAFParanoiaLevel > 0 && secCfg.WAFParanoiaLevel <= 4 {
paranoiaLevel = secCfg.WAFParanoiaLevel
}
directives.WriteString(fmt.Sprintf("SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", paranoiaLevel))
// Mode: block or monitor
if secCfg != nil && secCfg.WAFMode == "monitor" {
directives.WriteString("SecRuleEngine DetectionOnly\n")
}
// Include ruleset files
for _, rs := range rulesets {
if path, ok := rulesetPaths[rs.Name]; ok && path != "" {
directives.WriteString(fmt.Sprintf("Include %s\n", path))
}
}
// Apply exclusions
if secCfg != nil && secCfg.WAFExclusions != "" {
var exclusions []WAFExclusion
if err := json.Unmarshal([]byte(secCfg.WAFExclusions), &exclusions); err == nil {
for _, ex := range exclusions {
// Generate SecRuleRemoveById or SecRuleUpdateTargetById
if ex.RuleID > 0 {
if ex.Target != "" {
directives.WriteString(fmt.Sprintf("SecRuleUpdateTargetById %d \"!%s\"\n", ex.RuleID, ex.Target))
} else {
directives.WriteString(fmt.Sprintf("SecRuleRemoveById %d\n", ex.RuleID))
}
}
}
}
}
h := Handler{
"handler": "waf",
"directives": directives.String(),
}
return h, nil
}
// WAFExclusion represents a rule exclusion for false positives
type WAFExclusion struct {
RuleID int `json:"rule_id"`
Target string `json:"target,omitempty"` // e.g., "ARGS:password"
Description string `json:"description,omitempty"`
}
4.2.2 Add Per-Host WAF Toggle
File: backend/internal/models/proxy_host.go
type ProxyHost struct {
// ... existing fields ...
WAFDisabled bool `json:"waf_disabled" gorm:"default:false"` // Override global WAF
}
4.2.3 Add Paranoia Level and Exclusions to SecurityConfig
File: backend/internal/models/security_config.go
type SecurityConfig struct {
// ... existing fields ...
WAFParanoiaLevel int `json:"waf_paranoia_level" gorm:"default:1"` // 1-4
WAFExclusions string `json:"waf_exclusions" gorm:"type:text"` // JSON array of exclusions
}
4.2.4 Remove Naive Check from Cerberus Middleware
File: backend/internal/cerberus/cerberus.go
The current <script> check is misleading. Replace with proper logging:
// WAF is handled by Coraza at the Caddy layer
// Log WAF status for debugging
if c.cfg.WAFMode != "" && c.cfg.WAFMode != "disabled" {
logger.Log().WithFields(map[string]interface{}{
"source": "waf",
"mode": c.cfg.WAFMode,
"client_ip": ctx.ClientIP(),
}).Debug("Request subject to WAF inspection")
}
4.2.5 Add Rule Exclusion Management Endpoints
File: backend/internal/api/handlers/security_handler.go
// GetWAFExclusions returns current WAF rule exclusions
func (h *SecurityHandler) GetWAFExclusions(c *gin.Context) {
cfg, err := h.svc.Get()
if err != nil && err != services.ErrSecurityConfigNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get config"})
return
}
var exclusions []WAFExclusion
if cfg != nil && cfg.WAFExclusions != "" {
json.Unmarshal([]byte(cfg.WAFExclusions), &exclusions)
}
c.JSON(http.StatusOK, gin.H{"exclusions": exclusions})
}
// AddWAFExclusion adds a rule exclusion
func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
var exclusion WAFExclusion
if err := c.ShouldBindJSON(&exclusion); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
cfg, _ := h.svc.Get()
if cfg == nil {
cfg = &models.SecurityConfig{Name: "default"}
}
var exclusions []WAFExclusion
if cfg.WAFExclusions != "" {
json.Unmarshal([]byte(cfg.WAFExclusions), &exclusions)
}
exclusions = append(exclusions, exclusion)
data, _ := json.Marshal(exclusions)
cfg.WAFExclusions = string(data)
if err := h.svc.Upsert(cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save"})
return
}
// Apply to Caddy
if h.caddyManager != nil {
h.caddyManager.ApplyConfig(c.Request.Context())
}
c.JSON(http.StatusOK, gin.H{"exclusion": exclusion})
}
Routes:
GET /api/v1/security/waf/exclusionsPOST /api/v1/security/waf/exclusionsDELETE /api/v1/security/waf/exclusions/:id
4.3 Frontend Updates
4.3.1 Update WafConfig Page
File: frontend/src/pages/WafConfig.tsx
Add:
- Paranoia level selector (1-4 with descriptions)
- Exclusions management panel
- Per-host override indicator
// Paranoia level selector
const PARANOIA_LEVELS = [
{ level: 1, name: 'Low', description: 'Minimal false positives, basic protection' },
{ level: 2, name: 'Medium', description: 'Balanced protection, some false positives' },
{ level: 3, name: 'High', description: 'Strong protection, requires tuning' },
{ level: 4, name: 'Paranoid', description: 'Maximum protection, many false positives' },
]
// In render:
<div>
<label>Paranoia Level</label>
<select value={paranoiaLevel} onChange={...}>
{PARANOIA_LEVELS.map(p => (
<option key={p.level} value={p.level}>
{p.level} - {p.name}
</option>
))}
</select>
<p className="text-xs text-gray-500">
{PARANOIA_LEVELS.find(p => p.level === paranoiaLevel)?.description}
</p>
</div>
4.3.2 Add Per-Host WAF Toggle to Proxy Host Form
File: frontend/src/pages/ProxyHostForm.tsx (or similar)
<Switch
label="Disable WAF for this host"
checked={formData.waf_disabled}
onChange={(e) => setFormData({...formData, waf_disabled: e.target.checked})}
helperText="Override global WAF settings for this specific host"
/>
4.4 Test Requirements
File: backend/internal/caddy/config_waf_test.go
func TestBuildWAFHandler_ParanoiaLevel(t *testing.T) {
secCfg := &models.SecurityConfig{
WAFMode: "block",
WAFParanoiaLevel: 2,
}
h, err := buildWAFHandler(nil, nil, nil, secCfg, true)
require.NoError(t, err)
directives := h["directives"].(string)
assert.Contains(t, directives, "tx.paranoia_level=2")
}
func TestBuildWAFHandler_Exclusions(t *testing.T) {
exclusions := []WAFExclusion{
{RuleID: 942100, Description: "SQL injection false positive"},
}
data, _ := json.Marshal(exclusions)
secCfg := &models.SecurityConfig{
WAFMode: "block",
WAFExclusions: string(data),
}
h, err := buildWAFHandler(nil, nil, nil, secCfg, true)
require.NoError(t, err)
directives := h["directives"].(string)
assert.Contains(t, directives, "SecRuleRemoveById 942100")
}
func TestBuildWAFHandler_PerHostDisabled(t *testing.T) {
host := &models.ProxyHost{WAFDisabled: true}
secCfg := &models.SecurityConfig{WAFMode: "block"}
h, err := buildWAFHandler(host, nil, nil, secCfg, true)
require.NoError(t, err)
assert.Nil(t, h)
}
File Change Summary
New Files
| File | Purpose |
|---|---|
backend/internal/services/geoip_service.go |
GeoIP lookup service |
backend/internal/services/geoip_service_test.go |
GeoIP unit tests |
backend/internal/crowdsec/registration.go |
CrowdSec bouncer registration |
backend/internal/crowdsec/registration_test.go |
CrowdSec registration tests |
Modified Files
| File | Changes |
|---|---|
backend/go.mod |
Add github.com/oschwald/geoip2-golang |
backend/internal/models/security_config.go |
Add WAFParanoiaLevel, WAFExclusions, RateLimitBypassList |
backend/internal/models/proxy_host.go |
Add WAFDisabled |
backend/internal/services/access_list_service.go |
Add GeoIP integration to TestIP |
backend/internal/caddy/config.go |
Fix burst, add bypass, enhance WAF handler |
backend/internal/cerberus/cerberus.go |
Remove naive <script> check, add logging |
backend/internal/api/handlers/security_handler.go |
Add presets, exclusions, GeoIP reload endpoints |
backend/internal/server/server.go |
Initialize GeoIP service |
frontend/src/api/security.ts |
Add new API types |
frontend/src/pages/RateLimiting.tsx |
Add presets, bypass list UI |
frontend/src/pages/WafConfig.tsx |
Add paranoia selector, exclusions UI |
frontend/src/pages/AccessLists.tsx |
Enhance geo test result display |
Database Migrations
Auto-migrate will handle new fields. For explicit migration:
-- Add new SecurityConfig fields
ALTER TABLE security_configs ADD COLUMN waf_paranoia_level INTEGER DEFAULT 1;
ALTER TABLE security_configs ADD COLUMN waf_exclusions TEXT;
ALTER TABLE security_configs ADD COLUMN rate_limit_bypass_list TEXT;
-- Add ProxyHost WAF toggle
ALTER TABLE proxy_hosts ADD COLUMN waf_disabled BOOLEAN DEFAULT FALSE;
Implementation Order
- Phase 2 (Rate Limit) - Quickest, enables immediate value
- Phase 1 (GeoIP) - Most user-requested feature
- Phase 4 (WAF) - Most complex but highest security impact
- Phase 3 (CrowdSec) - Requires external service coordination
Definition of Done Checklist
For each phase:
- All new code has unit tests with >85% coverage
- Pre-commit hooks pass (
pre-commit run --all-files) - Backend compiles without warnings (
go build ./...) - Frontend builds without errors (
npm run build) - Integration tests pass (if applicable)
- Documentation updated in
docs/cerberus.md - API changes reflected in
docs/api.md - Feature documented in
docs/features.md
Risk Assessment
| Risk | Impact | Mitigation |
|---|---|---|
| GeoIP database missing | Geo-blocking fails silently | Graceful fallback with warning |
| CrowdSec LAPI unavailable | No IP reputation | Caddy continues without bouncer |
| WAF exclusions break rules | Security gap | Validate rule IDs, log exclusions |
| Rate limit bypass abused | DDoS possible | Audit bypass list, alert on changes |
Appendix: API Routes Summary
| Method | Endpoint | Phase |
|---|---|---|
| POST | /api/v1/security/geoip/reload |
1 |
| GET | /api/v1/security/rate-limit/presets |
2 |
| GET | /api/v1/crowdsec/decisions |
3 |
| GET | /api/v1/security/waf/exclusions |
4 |
| POST | /api/v1/security/waf/exclusions |
4 |
| DELETE | /api/v1/security/waf/exclusions/:id |
4 |