- Implemented certificate parsing for PEM, DER, and PFX formats. - Added functions to validate key matches and certificate chains. - Introduced metadata extraction for certificates including common name, domains, and issuer organization. - Created unit tests for all new functionalities to ensure reliability and correctness.
1568 lines
65 KiB
Go
1568 lines
65 KiB
Go
package caddy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/config"
|
|
"github.com/Wikid82/charon/backend/internal/crypto"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/stretchr/testify/assert"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestManager_ListSnapshots_ReadDirError(t *testing.T) {
|
|
// Use a path that does not exist
|
|
tmp := t.TempDir()
|
|
// create manager with a non-existent subdir
|
|
manager := NewManager(nil, nil, filepath.Join(tmp, "nope"), "", false, config.SecurityConfig{})
|
|
_, err := manager.listSnapshots()
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestManager_RotateSnapshots_NoOp(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
|
|
// No snapshots exist; should be no error
|
|
err := manager.rotateSnapshots(10)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestManager_Rollback_NoSnapshots(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
|
|
err := manager.rollback(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "no snapshots available")
|
|
}
|
|
|
|
func TestManager_Rollback_UnmarshalError(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
// Write a non-JSON file with .json extension
|
|
p := filepath.Join(tmp, "config-123.json")
|
|
// #nosec G306 -- Test fixture invalid JSON file
|
|
_ = os.WriteFile(p, []byte("not json"), 0o644)
|
|
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
|
|
// Reader error should happen before client.Load
|
|
err := manager.rollback(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unmarshal snapshot")
|
|
}
|
|
|
|
func TestManager_Rollback_LoadSnapshotFail(t *testing.T) {
|
|
// Create a valid JSON file and set client to return error for /load
|
|
tmp := t.TempDir()
|
|
p := filepath.Join(tmp, "config-123.json")
|
|
// #nosec G306 -- Test fixture file with standard read permissions
|
|
_ = os.WriteFile(p, []byte(`{"apps":{"http":{}}}`), 0o644)
|
|
|
|
// Mock client that returns error on Load
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
badClient := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL))
|
|
manager := NewManager(badClient, nil, tmp, "", false, config.SecurityConfig{})
|
|
err := manager.rollback(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "load snapshot")
|
|
}
|
|
|
|
func TestManager_SaveSnapshot_WriteError(t *testing.T) {
|
|
// Create a file at path to use as configDir, so writes fail
|
|
tmp := t.TempDir()
|
|
notDir := filepath.Join(tmp, "file-not-dir")
|
|
_ = os.WriteFile(notDir, []byte("data"), 0o600)
|
|
manager := NewManager(nil, nil, notDir, "", false, config.SecurityConfig{})
|
|
_, err := manager.saveSnapshot(&Config{})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "write snapshot")
|
|
}
|
|
|
|
func TestBackupCaddyfile_MkdirAllFailure(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
originalFile := filepath.Join(tmp, "Caddyfile")
|
|
_ = os.WriteFile(originalFile, []byte("original"), 0o600)
|
|
// Create a file where the backup dir should be to cause MkdirAll to fail
|
|
badDir := filepath.Join(tmp, "notadir")
|
|
_ = os.WriteFile(badDir, []byte("data"), 0o600)
|
|
|
|
_, err := BackupCaddyfile(originalFile, badDir)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// Note: Deletion failure for rotateSnapshots is difficult to reliably simulate across environments
|
|
// (tests run as root in CI and local dev containers). If needed, add platform-specific tests.
|
|
|
|
func TestManager_SaveSnapshot_Success(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
|
|
path, err := manager.saveSnapshot(&Config{})
|
|
assert.NoError(t, err)
|
|
assert.FileExists(t, path)
|
|
}
|
|
|
|
func TestManager_ApplyConfig_WithSettings(t *testing.T) {
|
|
// Mock Caddy Admin API
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
// Setup DB
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
|
|
|
// Create settings for acme email and ssl provider
|
|
db.Create(&models.Setting{Key: "caddy.acme_email", Value: "admin@example.com"})
|
|
db.Create(&models.Setting{Key: "caddy.ssl_provider", Value: "zerossl"})
|
|
|
|
// Setup Manager
|
|
tmpDir := t.TempDir()
|
|
client := NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL))
|
|
manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
|
|
|
|
// Create a host
|
|
host := models.ProxyHost{
|
|
DomainNames: "example.com",
|
|
ForwardHost: "127.0.0.1",
|
|
ForwardPort: 8080,
|
|
}
|
|
db.Create(&host)
|
|
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.NoError(t, err)
|
|
|
|
// Verify config was saved to DB
|
|
var caddyConfig models.CaddyConfig
|
|
err = db.First(&caddyConfig).Error
|
|
assert.NoError(t, err)
|
|
assert.True(t, caddyConfig.Success)
|
|
}
|
|
|
|
// Skipping rotate snapshot-on-apply warning test — rotation errors are non-fatal and environment
|
|
// dependent. We cover rotateSnapshots failure separately below.
|
|
|
|
func TestManager_RotateSnapshots_ListDirError(t *testing.T) {
|
|
manager := NewManager(nil, nil, filepath.Join(t.TempDir(), "nope"), "", false, config.SecurityConfig{})
|
|
err := manager.rotateSnapshots(10)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestManager_RotateSnapshots_DeletesOld(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
// create 5 snapshot files with different timestamps
|
|
for i := 1; i <= 5; i++ {
|
|
name := fmt.Sprintf("config-%d.json", i)
|
|
p := filepath.Join(tmp, name)
|
|
_ = os.WriteFile(p, []byte("{}"), 0o600)
|
|
// tweak mod time
|
|
_ = os.Chtimes(p, time.Now().Add(time.Duration(i)*time.Second), time.Now().Add(time.Duration(i)*time.Second))
|
|
}
|
|
|
|
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
|
|
// Keep last 2 snapshots
|
|
err := manager.rotateSnapshots(2)
|
|
assert.NoError(t, err)
|
|
|
|
// Ensure only 2 files remain
|
|
files, _ := os.ReadDir(tmp)
|
|
var cnt int
|
|
for _, f := range files {
|
|
if filepath.Ext(f.Name()) == ".json" {
|
|
cnt++
|
|
}
|
|
}
|
|
assert.Equal(t, 2, cnt)
|
|
}
|
|
|
|
func TestManager_ApplyConfig_RotateSnapshotsWarning(t *testing.T) {
|
|
// Setup DB and Caddy server that accepts load
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
// Setup DB
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
|
|
|
// Create a host so GenerateConfig produces a config
|
|
host := models.ProxyHost{DomainNames: "rot.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&host)
|
|
|
|
// Create manager with a configDir that is not readable (non-existent subdir)
|
|
tmp := t.TempDir()
|
|
// Create snapshot files: make the oldest a non-empty directory to force delete error;
|
|
// generate 11 snapshots so rotateSnapshots(10) will attempt to delete 1
|
|
d1 := filepath.Join(tmp, "config-1.json")
|
|
_ = os.MkdirAll(d1, 0o700)
|
|
_ = os.WriteFile(filepath.Join(d1, "inner"), []byte("x"), 0o600) // non-empty
|
|
for i := 2; i <= 11; i++ {
|
|
_ = os.WriteFile(filepath.Join(tmp, fmt.Sprintf("config-%d.json", i)), []byte("{}"), 0o600)
|
|
}
|
|
// Set modification times to ensure config-1.json is oldest
|
|
for i := 1; i <= 11; i++ {
|
|
p := filepath.Join(tmp, fmt.Sprintf("config-%d.json", i))
|
|
if i == 1 {
|
|
p = d1
|
|
}
|
|
tmo := time.Now().Add(time.Duration(-i) * time.Minute)
|
|
_ = os.Chtimes(p, tmo, tmo)
|
|
}
|
|
|
|
client := NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL))
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
|
|
// ApplyConfig should succeed even if rotateSnapshots later returns an error
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestManager_ApplyConfig_LoadFailsAndRollbackFails(t *testing.T) {
|
|
// Mock Caddy admin API which returns error for /load so ApplyConfig fails
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Setup DB
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
|
|
|
// Create a host so GenerateConfig produces a config
|
|
host := models.ProxyHost{DomainNames: "fail.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&host)
|
|
|
|
tmp := t.TempDir()
|
|
client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL))
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
|
|
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "apply failed")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_SaveSnapshotFails(t *testing.T) {
|
|
// Setup DB and Caddy server that accepts load
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
// Setup DB
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"savefail")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
|
|
|
// Create a host so GenerateConfig produces a config
|
|
host := models.ProxyHost{DomainNames: "savefail.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&host)
|
|
|
|
// Create a file where configDir should be to cause saveSnapshot to fail
|
|
tmp := t.TempDir()
|
|
filePath := filepath.Join(tmp, "file-not-dir")
|
|
_ = os.WriteFile(filePath, []byte("data"), 0o600) // #nosec G306 -- test fixture
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
manager := NewManager(client, db, filePath, "", false, config.SecurityConfig{})
|
|
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "save snapshot")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_LoadFailsThenRollbackSucceeds(t *testing.T) {
|
|
// Create a server that fails the first /load but succeeds on the second /load
|
|
var callCount int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
callCount++
|
|
if callCount == 1 {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rollbackok")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
|
|
|
// Create a host
|
|
host := models.ProxyHost{DomainNames: "rb.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&host)
|
|
|
|
tmp := t.TempDir()
|
|
client := newTestClient(t, server.URL)
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
|
|
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "apply failed")
|
|
}
|
|
|
|
func TestManager_SaveSnapshot_MarshalError(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
|
|
// Stub jsonMarshallFunc to return error
|
|
orig := jsonMarshalFunc
|
|
jsonMarshalFunc = func(v any, prefix, indent string) ([]byte, error) {
|
|
return nil, fmt.Errorf("marshal fail")
|
|
}
|
|
defer func() { jsonMarshalFunc = orig }()
|
|
|
|
_, err := manager.saveSnapshot(&Config{})
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestManager_RotateSnapshots_DeleteError(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
// Create three files to remove one
|
|
for i := 1; i <= 3; i++ {
|
|
p := filepath.Join(tmp, fmt.Sprintf("config-%d.json", i))
|
|
_ = os.WriteFile(p, []byte("{}"), 0o600) // #nosec G306 -- test fixture
|
|
_ = os.Chtimes(p, time.Now().Add(time.Duration(i)*time.Second), time.Now().Add(time.Duration(i)*time.Second))
|
|
}
|
|
|
|
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
|
|
// Stub removeFileFunc to return error for specific path
|
|
origRemove := removeFileFunc
|
|
removeFileFunc = func(p string) error {
|
|
if filepath.Base(p) == "config-1.json" {
|
|
return fmt.Errorf("cannot delete")
|
|
}
|
|
return origRemove(p)
|
|
}
|
|
defer func() { removeFileFunc = origRemove }()
|
|
|
|
err := manager.rotateSnapshots(2)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
// Setup DB - minimal
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"genfail")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
|
|
|
// Create a host so ApplyConfig tries to generate config
|
|
host := models.ProxyHost{DomainNames: "x.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&host)
|
|
|
|
// stub generateConfigFunc to always return error
|
|
orig := generateConfigFunc
|
|
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
|
return nil, fmt.Errorf("generate fail")
|
|
}
|
|
defer func() { generateConfigFunc = orig }()
|
|
|
|
manager := NewManager(nil, db, tmp, "", false, config.SecurityConfig{})
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "generate config")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_WarnsWhenCerberusEnabledWithoutAdminWhitelist(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"cerberus")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}))
|
|
|
|
// create a host so ApplyConfig would try to generate config
|
|
h := models.ProxyHost{DomainNames: "test.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
|
|
// Insert SecurityConfig with enabled=true but no whitelist
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: ""}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
// Mock Caddy admin API
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
// Create manager and call ApplyConfig - should now warn but proceed (no error)
|
|
client := newTestClient(t, caddyServer.URL)
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
|
|
err = manager.ApplyConfig(context.Background())
|
|
// The call should succeed (or fail for other reasons, not the admin whitelist check)
|
|
// The warning is logged but doesn't block startup
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestManager_ApplyConfig_ValidateFails(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
// Setup DB - minimal
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"valfail")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
|
|
|
// Create a host so ApplyConfig tries to generate config
|
|
host := models.ProxyHost{DomainNames: "y.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&host)
|
|
|
|
// Stub validate function to return error
|
|
orig := validateConfigFunc
|
|
validateConfigFunc = func(cfg *Config) error { return fmt.Errorf("validation failed stub") }
|
|
defer func() { validateConfigFunc = orig }()
|
|
|
|
// Use a working client so generation succeeds
|
|
// Mock Caddy admin API that accepts loads
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
|
|
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "validation failed")
|
|
}
|
|
|
|
func TestManager_Rollback_ReadFileError(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
|
|
// Create snapshot entries via write
|
|
p := filepath.Join(tmp, "config-123.json")
|
|
_ = os.WriteFile(p, []byte(`{"apps":{"http":{}}}`), 0o600) // #nosec G306 -- test fixture
|
|
// Stub readFileFunc to return error
|
|
origRead := readFileFunc
|
|
readFileFunc = func(p string) ([]byte, error) { return nil, fmt.Errorf("read error") }
|
|
defer func() { readFileFunc = origRead }()
|
|
|
|
err := manager.rollback(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "read snapshot")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_RotateSnapshotsWarning_Stderr(t *testing.T) {
|
|
// Setup minimal DB and client that accepts load
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rotwarn")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
|
host := models.ProxyHost{DomainNames: "rotwarn.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&host)
|
|
|
|
// Setup Caddy server
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
// stub readDirFunc to return error to cause rotateSnapshots to fail
|
|
origReadDir := readDirFunc
|
|
readDirFunc = func(path string) ([]os.DirEntry, error) { return nil, fmt.Errorf("dir read fail") }
|
|
defer func() { readDirFunc = origReadDir }()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
manager := NewManager(client, db, t.TempDir(), "", false, config.SecurityConfig{})
|
|
err = manager.ApplyConfig(context.Background())
|
|
// Should succeed despite rotation warning (non-fatal)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
// Setup DB - minimal
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"adminwl")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}))
|
|
|
|
// Create a host so ApplyConfig would try to generate config
|
|
h := models.ProxyHost{DomainNames: "test.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
|
|
// Insert SecurityConfig with enabled=true and an admin whitelist
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
// Setup a client server that accepts loads
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Stub generateConfigFunc to capture adminWhitelist
|
|
var capturedAdmin string
|
|
orig := generateConfigFunc
|
|
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
|
capturedAdmin = adminWhitelist
|
|
// return minimal config
|
|
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
|
|
}
|
|
defer func() { generateConfigFunc = orig }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "10.0.0.1/32", capturedAdmin)
|
|
}
|
|
|
|
func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create a host so ApplyConfig would try to generate config
|
|
h := models.ProxyHost{DomainNames: "ruleset.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
|
|
// Insert ruleset
|
|
rs := models.SecurityRuleSet{Name: "owasp-crs", Content: "rules"}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
|
|
// Insert SecurityConfig with WAF enabled and rulesource set
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "owasp-crs"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
// Setup caddy server stub
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
var capturedRules []models.SecurityRuleSet
|
|
orig := generateConfigFunc
|
|
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
|
capturedRules = rulesets
|
|
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
|
|
}
|
|
defer func() { generateConfigFunc = orig }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
|
|
err = manager.ApplyConfig(context.Background())
|
|
assert.NoError(t, err)
|
|
assert.GreaterOrEqual(t, len(capturedRules), 1)
|
|
assert.Equal(t, "owasp-crs", capturedRules[0].Name)
|
|
}
|
|
|
|
func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets-coraza")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create a host
|
|
h := models.ProxyHost{DomainNames: "ruleset.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
|
|
// Insert ruleset
|
|
rs := models.SecurityRuleSet{Name: "owasp-crs", Content: "test-rule-content"}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
|
|
// Insert SecurityConfig with WAF enabled and rulesource set
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "owasp-crs"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
loadCh := make(chan []byte, 1)
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
body, _ := io.ReadAll(r.Body)
|
|
loadCh <- body
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Capture wafEnabled and rulesets passed into GenerateConfig
|
|
var capturedWafEnabled bool
|
|
var capturedRulesets []models.SecurityRuleSet
|
|
origGen := generateConfigFunc
|
|
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
|
capturedWafEnabled = wafEnabled
|
|
capturedRulesets = rulesets
|
|
return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs)
|
|
}
|
|
defer func() { generateConfigFunc = origGen }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
assert.True(t, capturedWafEnabled, "wafEnabled expected to be true when Cerberus and WAF enabled")
|
|
assert.GreaterOrEqual(t, len(capturedRulesets), 1)
|
|
|
|
var body []byte
|
|
select {
|
|
case body = <-loadCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for /load request")
|
|
}
|
|
var cfg Config
|
|
assert.NoError(t, json.Unmarshal(body, &cfg))
|
|
t.Logf("generated config: %s", string(body))
|
|
|
|
// Find the route for our host and assert waf handler exists
|
|
found := false
|
|
for _, r := range cfg.Apps.HTTP.Servers["charon_server"].Routes {
|
|
for _, m := range r.Match {
|
|
for _, h := range m.Host {
|
|
if h == "ruleset.example.com" {
|
|
for _, handle := range r.Handle {
|
|
if handlerName, ok := handle["handler"].(string); ok && handlerName == "waf" {
|
|
// Validate directives field contains Include statement (coraza-caddy schema)
|
|
if dir, ok := handle["directives"].(string); ok && strings.Contains(dir, "Include") {
|
|
// Extract the file path from the Include directive
|
|
// Format: "SecRuleEngine On\n...Include /path/to/file.conf\n"
|
|
lines := strings.Split(dir, "\n")
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "Include ") {
|
|
rf := strings.TrimPrefix(line, "Include ")
|
|
rf = strings.TrimSpace(rf)
|
|
// Ensure file exists and contains our content
|
|
b, err := os.ReadFile(rf) // #nosec G304 -- Test helper reading ruleset files from controlled test directory
|
|
if err == nil && strings.Contains(string(b), "test-rule-content") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Inline content may also exist as a fallback
|
|
if rsContent, ok := handle["ruleset_content"].(string); ok && rsContent == "test-rule-content" {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.True(t, found, "waf handler with directives should be present in generated config")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets-failwrite")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create host and ruleset
|
|
h := models.ProxyHost{DomainNames: "rulesetw.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
rs := models.SecurityRuleSet{Name: "owasp-crs", Content: "test-rule-content"}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "owasp-crs"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Stub writeFileFunc to return an error for coraza ruleset files only to exercise the warn branch
|
|
origWrite := writeFileFunc
|
|
writeFileFunc = func(path string, b []byte, perm os.FileMode) error {
|
|
if strings.Contains(path, string(filepath.Separator)+"coraza"+string(filepath.Separator)+"rulesets") {
|
|
return fmt.Errorf("cannot write")
|
|
}
|
|
return origWrite(path, b, perm)
|
|
}
|
|
defer func() { writeFileFunc = origWrite }()
|
|
|
|
// Capture rulesetPaths from GenerateConfig
|
|
var capturedPaths map[string]string
|
|
origGen := generateConfigFunc
|
|
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
|
capturedPaths = rulesetPaths
|
|
return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs)
|
|
}
|
|
defer func() { generateConfigFunc = origGen }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
// writeFile failed, capturedPaths should not contain our ruleset entry
|
|
assert.NotContains(t, capturedPaths, "owasp-crs")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_RulesetDirMkdirFailure(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
// Create a file at tmp/coraza to cause MkdirAll on tmp/coraza/rulesets to fail
|
|
corazaFile := filepath.Join(tmp, "coraza")
|
|
_ = os.WriteFile(corazaFile, []byte("not a dir"), 0o600) // #nosec G306 -- test fixture
|
|
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets-mkdirfail")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create host and ruleset
|
|
h := models.ProxyHost{DomainNames: "rulesetm.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
rs := models.SecurityRuleSet{Name: "owasp-crs", Content: "test-rule-content"}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "owasp-crs"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
// Use tmp as configDir and we already have a file at tmp/coraza which should make MkdirAll to create rulesets fail
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
// This should not error (failures to create coraza dir are warned only)
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
}
|
|
|
|
func TestManager_ApplyConfig_ReappliesOnFlagChange(t *testing.T) {
|
|
// Capture /load payloads
|
|
loadCh := make(chan []byte, 10)
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
body, _ := io.ReadAll(r.Body)
|
|
loadCh <- body
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
// Setup DB
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}))
|
|
|
|
// Create an AccessList attached to host
|
|
acl := models.AccessList{UUID: "acl-1", Name: "test-acl", Type: "whitelist", IPRules: `[{"cidr":"127.0.0.1/32"}]`, Enabled: true}
|
|
assert.NoError(t, db.Create(&acl).Error)
|
|
|
|
host := models.ProxyHost{DomainNames: "flag.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
assert.NoError(t, db.Create(&host).Error)
|
|
// Update with access list FK
|
|
assert.NoError(t, db.Model(&host).Update("access_list_id", acl.ID).Error)
|
|
|
|
// Ensure DB setting is not present so ACL disabled by default
|
|
// Manager default SecurityConfig has ACLMode disabled
|
|
tmpDir := t.TempDir()
|
|
client := newTestClient(t, caddyServer.URL)
|
|
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "disabled", WAFMode: "disabled", RateLimitMode: "disabled", CrowdSecMode: "disabled"}
|
|
manager := NewManager(client, db, tmpDir, "", false, secCfg)
|
|
|
|
// First ApplyConfig - ACL disabled, we expect no ACL-related static_response
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
var body1 []byte
|
|
select {
|
|
case body1 = <-loadCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for /load request 1")
|
|
}
|
|
var cfg1 Config
|
|
assert.NoError(t, json.Unmarshal(body1, &cfg1))
|
|
// Ensure not present — find the host route and check handles
|
|
found := false
|
|
for _, r := range cfg1.Apps.HTTP.Servers["charon_server"].Routes {
|
|
for _, m := range r.Match {
|
|
for _, h := range m.Host {
|
|
if h == "flag.example.com" {
|
|
for _, handle := range r.Handle {
|
|
if handlerName, ok := handle["handler"].(string); ok && handlerName == "subroute" {
|
|
if routes, ok := handle["routes"].([]any); ok {
|
|
for _, rt := range routes {
|
|
if rtMap, ok := rt.(map[string]any); ok {
|
|
if inner, ok := rtMap["handle"].([]any); ok {
|
|
for _, itm := range inner {
|
|
if itmMap, ok := itm.(map[string]any); ok {
|
|
if body, ok := itmMap["body"].(string); ok {
|
|
if strings.Contains(body, "Access denied") {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.False(t, found, "ACL handler must not be present when ACL disabled")
|
|
|
|
// Enable ACL via DB runtime setting
|
|
assert.NoError(t, db.Create(&models.Setting{Key: "security.acl.enabled", Value: "true"}).Error)
|
|
// Next ApplyConfig - ACL enabled
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
var body2 []byte
|
|
select {
|
|
case body2 = <-loadCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for /load request 2")
|
|
}
|
|
var cfg2 Config
|
|
assert.NoError(t, json.Unmarshal(body2, &cfg2))
|
|
found = false
|
|
for _, r := range cfg2.Apps.HTTP.Servers["charon_server"].Routes {
|
|
for _, m := range r.Match {
|
|
for _, h := range m.Host {
|
|
if h == "flag.example.com" {
|
|
for _, handle := range r.Handle {
|
|
if handlerName, ok := handle["handler"].(string); ok && handlerName == "subroute" {
|
|
if routes, ok := handle["routes"].([]any); ok {
|
|
for _, rt := range routes {
|
|
if rtMap, ok := rt.(map[string]any); ok {
|
|
if inner, ok := rtMap["handle"].([]any); ok {
|
|
for _, itm := range inner {
|
|
if itmMap, ok := itm.(map[string]any); ok {
|
|
if body, ok := itmMap["body"].(string); ok {
|
|
if strings.Contains(body, "Access denied") {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Logf("body2: %s", string(body2))
|
|
}
|
|
assert.True(t, found, "ACL handler must be present when ACL enabled via DB")
|
|
|
|
// Enable CrowdSec via DB runtime setting (switch from disabled to local)
|
|
assert.NoError(t, db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "local"}).Error)
|
|
// Next ApplyConfig - CrowdSec enabled
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
var body3 []byte
|
|
select {
|
|
case body3 = <-loadCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for /load request 3")
|
|
}
|
|
var cfg3 Config
|
|
assert.NoError(t, json.Unmarshal(body3, &cfg3))
|
|
// For crowdsec handler we inserted a simple Handler{"handler":"crowdsec"}
|
|
hasCrowdsec := false
|
|
for _, r := range cfg3.Apps.HTTP.Servers["charon_server"].Routes {
|
|
for _, m := range r.Match {
|
|
for _, h := range m.Host {
|
|
if h == "flag.example.com" {
|
|
for _, handle := range r.Handle {
|
|
if handlerName, ok := handle["handler"].(string); ok && handlerName == "crowdsec" {
|
|
hasCrowdsec = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.True(t, hasCrowdsec, "CrowdSec handler should be present when enabled via DB")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_PrependsSecRuleEngineDirectives(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"secruleengine")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create host and ruleset without SecRuleEngine directive
|
|
h := models.ProxyHost{DomainNames: "prepend.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
// Ruleset content without SecRuleEngine - should be prepended
|
|
ruleContent := `SecRule REQUEST_BODY "<script>" "id:12345,phase:2,deny,status:403,msg:'XSS blocked'"`
|
|
rs := models.SecurityRuleSet{Name: "test-xss", Content: ruleContent}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "test-xss"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Capture written file content
|
|
var writtenContent []byte
|
|
origWrite := writeFileFunc
|
|
writeFileFunc = func(path string, b []byte, perm os.FileMode) error {
|
|
if strings.Contains(path, "test-xss") {
|
|
writtenContent = b
|
|
}
|
|
return origWrite(path, b, perm)
|
|
}
|
|
defer func() { writeFileFunc = origWrite }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
|
|
// Verify SecRuleEngine On was prepended
|
|
content := string(writtenContent)
|
|
assert.True(t, strings.Contains(content, "SecRuleEngine On"), "SecRuleEngine On should be prepended")
|
|
assert.True(t, strings.Contains(content, "SecRequestBodyAccess On"), "SecRequestBodyAccess On should be prepended")
|
|
// Verify original content is still present
|
|
assert.True(t, strings.Contains(content, ruleContent), "Original rule content should be preserved")
|
|
// Verify order: directives should come before the original rule
|
|
assert.True(t, strings.Index(content, "SecRuleEngine On") < strings.Index(content, "SecRule REQUEST_BODY"), "SecRuleEngine should appear before SecRule")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_DoesNotPrependIfSecRuleEngineExists(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"secruleengine-exists")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create host and ruleset that already has SecRuleEngine
|
|
h := models.ProxyHost{DomainNames: "noprepend.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
// Ruleset content already contains SecRuleEngine
|
|
ruleContent := `SecRuleEngine DetectionOnly
|
|
SecRequestBodyAccess On
|
|
SecRule REQUEST_BODY "<script>" "id:12345,phase:2,deny,status:403,msg:'XSS blocked'"`
|
|
rs := models.SecurityRuleSet{Name: "test-existing", Content: ruleContent}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "test-existing"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Capture written file content
|
|
var writtenContent []byte
|
|
origWrite := writeFileFunc
|
|
writeFileFunc = func(path string, b []byte, perm os.FileMode) error {
|
|
if strings.Contains(path, "test-existing") {
|
|
writtenContent = b
|
|
}
|
|
return origWrite(path, b, perm)
|
|
}
|
|
defer func() { writeFileFunc = origWrite }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
|
|
// Verify content is written exactly as provided (no extra prepending)
|
|
content := string(writtenContent)
|
|
// Count occurrences of SecRuleEngine - should be exactly 1
|
|
assert.Equal(t, 1, strings.Count(strings.ToLower(content), "secruleengine"), "SecRuleEngine should appear exactly once (not prepended)")
|
|
// Verify the original content is preserved exactly
|
|
assert.Equal(t, ruleContent, content, "Content should be exactly as provided when SecRuleEngine exists")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_DebugMarshalFailure(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"debugmarshal")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create a simple host
|
|
h := models.ProxyHost{DomainNames: "marshal.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32"}
|
|
db.Create(&sec)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Stub jsonMarshalDebugFunc to return an error (exercises the else branch in debug logging)
|
|
origMarshalDebug := jsonMarshalDebugFunc
|
|
jsonMarshalDebugFunc = func(v any) ([]byte, error) {
|
|
return nil, fmt.Errorf("simulated marshal error")
|
|
}
|
|
defer func() { jsonMarshalDebugFunc = origMarshalDebug }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true})
|
|
// ApplyConfig should still succeed even if debug logging fails
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
}
|
|
|
|
func TestManager_ApplyConfig_WAFModeMonitorUsesDetectionOnly(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"wafmonitor")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create host and ruleset
|
|
h := models.ProxyHost{DomainNames: "monitor.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
// Ruleset content without SecRuleEngine
|
|
ruleContent := `SecRule REQUEST_BODY "<script>" "id:12345,phase:2,deny,status:403,msg:'XSS blocked'"`
|
|
rs := models.SecurityRuleSet{Name: "monitor-test", Content: ruleContent}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
// WAFMode = "monitor" should result in DetectionOnly
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "monitor", WAFRulesSource: "monitor-test"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Capture written file content
|
|
var writtenContent []byte
|
|
origWrite := writeFileFunc
|
|
writeFileFunc = func(path string, b []byte, perm os.FileMode) error {
|
|
if strings.Contains(path, "monitor-test") {
|
|
writtenContent = b
|
|
}
|
|
return origWrite(path, b, perm)
|
|
}
|
|
defer func() { writeFileFunc = origWrite }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "monitor"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
|
|
// Verify SecRuleEngine DetectionOnly was prepended (not On)
|
|
content := string(writtenContent)
|
|
assert.True(t, strings.Contains(content, "SecRuleEngine DetectionOnly"), "SecRuleEngine DetectionOnly should be prepended in monitor mode")
|
|
assert.False(t, strings.Contains(content, "SecRuleEngine On"), "SecRuleEngine On should NOT be present in monitor mode")
|
|
assert.True(t, strings.Contains(content, "SecRequestBodyAccess On"), "SecRequestBodyAccess On should be prepended")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_PerRulesetModeOverride(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesetmode")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create host and ruleset with per-ruleset mode set to "detection"
|
|
h := models.ProxyHost{DomainNames: "rulesetmode.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
ruleContent := `SecRule REQUEST_BODY "<script>" "id:12345,phase:2,deny,status:403,msg:'XSS blocked'"`
|
|
// Ruleset has Mode="detection" which should override global "block" mode
|
|
rs := models.SecurityRuleSet{Name: "override-test", Content: ruleContent, Mode: "detection"}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
// Global WAFMode is "block" but ruleset Mode should take precedence
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "override-test"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Capture written file content
|
|
var writtenContent []byte
|
|
origWrite := writeFileFunc
|
|
writeFileFunc = func(path string, b []byte, perm os.FileMode) error {
|
|
if strings.Contains(path, "override-test") {
|
|
writtenContent = b
|
|
}
|
|
return origWrite(path, b, perm)
|
|
}
|
|
defer func() { writeFileFunc = origWrite }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
|
|
// Verify per-ruleset mode takes precedence: should be DetectionOnly even though global is "block"
|
|
content := string(writtenContent)
|
|
assert.True(t, strings.Contains(content, "SecRuleEngine DetectionOnly"), "Per-ruleset mode 'detection' should result in DetectionOnly")
|
|
assert.False(t, strings.Contains(content, "SecRuleEngine On"), "Per-ruleset mode should override global 'block' mode")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_RulesetFileCleanup(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesetcleanup")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create initial ruleset
|
|
h := models.ProxyHost{DomainNames: "cleanup.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
rs := models.SecurityRuleSet{Name: "active-ruleset", Content: "SecRule REQUEST_BODY \"test\" \"id:1,phase:2,deny\""}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "active-ruleset"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
// Create a stale file in the coraza rulesets dir
|
|
corazaDir := filepath.Join(tmp, "coraza", "rulesets")
|
|
// #nosec G301 -- Test coraza rulesets directory needs standard Unix permissions
|
|
_ = os.MkdirAll(corazaDir, 0o755)
|
|
staleFile := filepath.Join(corazaDir, "stale-ruleset.conf")
|
|
_ = os.WriteFile(staleFile, []byte("old content"), 0o600) // #nosec G306 -- test fixture
|
|
|
|
// Create a subdirectory that should be skipped during cleanup (not deleted)
|
|
subDir := filepath.Join(corazaDir, "subdir")
|
|
// #nosec G301 -- Test subdirectory needs standard Unix permissions
|
|
_ = os.MkdirAll(subDir, 0o755)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
|
|
// Verify stale file was removed
|
|
_, err = os.Stat(staleFile)
|
|
assert.True(t, os.IsNotExist(err), "Stale ruleset file should be deleted")
|
|
|
|
// Verify subdirectory was NOT deleted (directories should be skipped)
|
|
info, err := os.Stat(subDir)
|
|
assert.NoError(t, err, "Subdirectory should not be deleted")
|
|
assert.True(t, info.IsDir(), "Subdirectory should still be a directory")
|
|
|
|
// Verify active ruleset file exists (with hash suffix in filename)
|
|
entries, err := os.ReadDir(corazaDir)
|
|
assert.NoError(t, err, "Should be able to read corazaDir")
|
|
foundActive := false
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "active-ruleset-") && strings.HasSuffix(entry.Name(), ".conf") {
|
|
foundActive = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundActive, "Active ruleset file with hash suffix should exist")
|
|
}
|
|
|
|
func TestManager_ApplyConfig_RulesetCleanupReadDirError(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"cleanupreaddirfail")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
h := models.ProxyHost{DomainNames: "readdirerr.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
rs := models.SecurityRuleSet{Name: "test-ruleset", Content: "SecRule REQUEST_BODY \"x\" \"id:1,phase:2,deny\""}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "test-ruleset"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Stub readDirFunc to return error
|
|
origReadDir := readDirFunc
|
|
readDirFunc = func(name string) ([]os.DirEntry, error) {
|
|
if strings.Contains(name, "coraza") {
|
|
return nil, fmt.Errorf("simulated readdir error")
|
|
}
|
|
return origReadDir(name)
|
|
}
|
|
defer func() { readDirFunc = origReadDir }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
// ApplyConfig should still succeed even if cleanup read fails
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
}
|
|
|
|
func TestManager_ApplyConfig_RulesetCleanupRemoveError(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"cleanupremovefail")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
h := models.ProxyHost{DomainNames: "removeerr.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
rs := models.SecurityRuleSet{Name: "keep-this", Content: "SecRule REQUEST_BODY \"x\" \"id:1,phase:2,deny\""}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "keep-this"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
// Create stale file
|
|
corazaDir := filepath.Join(tmp, "coraza", "rulesets")
|
|
// #nosec G301 -- Test coraza rulesets directory needs standard Unix permissions
|
|
_ = os.MkdirAll(corazaDir, 0o755)
|
|
staleFile := filepath.Join(corazaDir, "stale.conf")
|
|
_ = os.WriteFile(staleFile, []byte("old"), 0o600) // #nosec G306 -- test fixture
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Stub removeFileFunc to return error for stale files
|
|
origRemove := removeFileFunc
|
|
removeFileFunc = func(name string) error {
|
|
if strings.Contains(name, "stale") {
|
|
return fmt.Errorf("simulated remove error")
|
|
}
|
|
return origRemove(name)
|
|
}
|
|
defer func() { removeFileFunc = origRemove }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
// ApplyConfig should still succeed even if cleanup remove fails
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
}
|
|
|
|
func TestManager_ApplyConfig_WAFModeBlockExplicit(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"wafblock")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
h := models.ProxyHost{DomainNames: "block.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
ruleContent := `SecRule REQUEST_BODY "<script>" "id:12345,phase:2,deny,status:403,msg:'XSS blocked'"`
|
|
rs := models.SecurityRuleSet{Name: "block-test", Content: ruleContent}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "block-test"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"apps":{"http":{}}}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
var writtenContent []byte
|
|
origWrite := writeFileFunc
|
|
writeFileFunc = func(path string, b []byte, perm os.FileMode) error {
|
|
if strings.Contains(path, "block-test") {
|
|
writtenContent = b
|
|
}
|
|
return origWrite(path, b, perm)
|
|
}
|
|
defer func() { writeFileFunc = origWrite }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
|
|
// Verify SecRuleEngine On was prepended in block mode
|
|
content := string(writtenContent)
|
|
assert.True(t, strings.Contains(content, "SecRuleEngine On"), "SecRuleEngine On should be prepended in block mode")
|
|
assert.False(t, strings.Contains(content, "DetectionOnly"), "DetectionOnly should NOT be present in block mode")
|
|
}
|
|
|
|
// TestManager_ApplyConfig_RulesetNamePathTraversal tests that path traversal attempts
|
|
// in ruleset names are sanitized and do not escape the rulesets directory
|
|
func TestManager_ApplyConfig_RulesetNamePathTraversal(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets-pathtraversal")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
|
|
|
|
// Create host
|
|
h := models.ProxyHost{DomainNames: "traversal.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
|
db.Create(&h)
|
|
|
|
// Create ruleset with path traversal attempt in name
|
|
rs := models.SecurityRuleSet{Name: "../../../etc/passwd", Content: "SecRule REQUEST_BODY \"<script>\" \"id:99999,phase:2,deny\""}
|
|
assert.NoError(t, db.Create(&rs).Error)
|
|
|
|
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "../../../etc/passwd"}
|
|
assert.NoError(t, db.Create(&sec).Error)
|
|
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := newTestClient(t, caddyServer.URL)
|
|
|
|
// Track where files are written
|
|
var writtenPath string
|
|
origWrite := writeFileFunc
|
|
writeFileFunc = func(path string, b []byte, perm os.FileMode) error {
|
|
if strings.Contains(path, "coraza") {
|
|
writtenPath = path
|
|
}
|
|
return origWrite(path, b, perm)
|
|
}
|
|
defer func() { writeFileFunc = origWrite }()
|
|
|
|
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
assert.NoError(t, manager.ApplyConfig(context.Background()))
|
|
|
|
// Verify the file was written inside the expected coraza/rulesets directory
|
|
expectedDir := filepath.Join(tmp, "coraza", "rulesets")
|
|
assert.True(t, strings.HasPrefix(writtenPath, expectedDir), "Ruleset file should be inside coraza/rulesets directory")
|
|
|
|
// Verify the sanitized filename does not contain path traversal sequences
|
|
filename := filepath.Base(writtenPath)
|
|
assert.NotContains(t, filename, "..", "Path traversal sequence should be stripped")
|
|
assert.NotContains(t, filename, "/", "Forward slash should be stripped")
|
|
assert.NotContains(t, filename, "\\", "Backslash should be stripped")
|
|
|
|
// The filename should be sanitized and end with .conf
|
|
assert.True(t, strings.HasSuffix(filename, ".conf"), "Ruleset file should have .conf extension")
|
|
|
|
// Verify the directory is strictly inside the expected location
|
|
dir := filepath.Dir(writtenPath)
|
|
assert.Equal(t, expectedDir, dir, "Ruleset must be written only to the coraza/rulesets directory")
|
|
}
|