- Added role-based middleware to various security handler tests to ensure only admin users can access certain endpoints. - Created a new test file for authorization checks on security mutators, verifying that non-admin users receive forbidden responses. - Updated existing tests to include role setting for admin users, ensuring consistent access control during testing. - Introduced sensitive data masking in settings handler responses, ensuring sensitive values are not exposed in API responses. - Enhanced user handler responses to mask API keys and invite tokens, providing additional security for user-related endpoints. - Refactored routes to group security admin endpoints under a dedicated route with role-based access control. - Added tests for import handler routes to verify authorization guards, ensuring only admin users can access import functionalities.
193 lines
7.7 KiB
Go
193 lines
7.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/caddy"
|
|
"github.com/Wikid82/charon/backend/internal/config"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
)
|
|
|
|
func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) {
|
|
t.Helper()
|
|
// Use a file-backed sqlite DB to avoid shared memory connection issues in tests
|
|
dsn := filepath.Join(t.TempDir(), "test.db")
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
api := r.Group("/api/v1")
|
|
cfg := config.SecurityConfig{}
|
|
h := NewSecurityHandler(cfg, db, nil)
|
|
api.POST("/security/decisions", h.CreateDecision)
|
|
api.GET("/security/decisions", h.ListDecisions)
|
|
api.POST("/security/rulesets", h.UpsertRuleSet)
|
|
api.GET("/security/rulesets", h.ListRuleSets)
|
|
api.DELETE("/security/rulesets/:id", h.DeleteRuleSet)
|
|
return r, db
|
|
}
|
|
|
|
func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) {
|
|
r, db := setupSecurityTestRouterWithExtras(t)
|
|
|
|
payload := `{"ip":"1.2.3.4","action":"block","host":"example.com","rule_id":"manual-1","details":"test"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/security/decisions", strings.NewReader(payload))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Create decision expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var decisionResp map[string]any
|
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp))
|
|
require.NotNil(t, decisionResp["decision"])
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/decisions?limit=10", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Upsert ruleset expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var listResp map[string][]map[string]any
|
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listResp))
|
|
require.GreaterOrEqual(t, len(listResp["decisions"]), 1)
|
|
|
|
// Now test ruleset upsert
|
|
rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}`
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp = httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("Upsert ruleset expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var rsResp map[string]any
|
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &rsResp))
|
|
require.NotNil(t, rsResp["ruleset"])
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/rulesets", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("List rulesets expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var listRsResp map[string][]map[string]any
|
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listRsResp))
|
|
require.GreaterOrEqual(t, len(listRsResp["rulesets"]), 1)
|
|
|
|
// Delete the ruleset we just created
|
|
// Note: ID has json:"-" tag so we use UUID to look up the record from DB
|
|
rulesetUUID, ok := listRsResp["rulesets"][0]["uuid"].(string)
|
|
require.True(t, ok, "uuid should be present in response")
|
|
var ruleset models.SecurityRuleSet
|
|
require.NoError(t, db.Where("uuid = ?", rulesetUUID).First(&ruleset).Error)
|
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.FormatUint(uint64(ruleset.ID), 10), http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
var delResp map[string]any
|
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &delResp))
|
|
require.Equal(t, true, delResp["deleted"].(bool))
|
|
}
|
|
|
|
func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) {
|
|
t.Helper()
|
|
// Setup DB
|
|
dsn := filepath.Join(t.TempDir(), "security_rules_decisions_test.db") + "?_busy_timeout=5000&_journal_mode=WAL"
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
sqlDB.SetMaxOpenConns(1)
|
|
sqlDB.SetMaxIdleConns(1)
|
|
t.Cleanup(func() {
|
|
if sqlDB != nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
|
|
|
|
// Ensure DB has expected tables (migrations executed above)
|
|
|
|
// Ensure proxy_hosts table exists in case AutoMigrate didn't create it
|
|
db.Exec("CREATE TABLE IF NOT EXISTS proxy_hosts (id INTEGER PRIMARY KEY AUTOINCREMENT, domain_names TEXT, forward_host TEXT, forward_port INTEGER, enabled BOOLEAN)")
|
|
// Create minimal settings and caddy_configs tables to satisfy Manager.ApplyConfig queries
|
|
db.Exec("CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT, value TEXT, type TEXT, category TEXT, updated_at datetime)")
|
|
db.Exec("CREATE TABLE IF NOT EXISTS caddy_configs (id INTEGER PRIMARY KEY AUTOINCREMENT, config_hash TEXT, applied_at datetime, success BOOLEAN, error_msg TEXT)")
|
|
// debug: tables exist
|
|
|
|
// Caddy admin server to capture /load calls
|
|
loadCh := make(chan struct{}, 2)
|
|
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
|
loadCh <- struct{}{}
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer caddyServer.Close()
|
|
|
|
client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL))
|
|
tmp := t.TempDir()
|
|
m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
api := r.Group("/api/v1")
|
|
cfg := config.SecurityConfig{}
|
|
h := NewSecurityHandler(cfg, db, m)
|
|
api.POST("/security/rulesets", h.UpsertRuleSet)
|
|
api.DELETE("/security/rulesets/:id", h.DeleteRuleSet)
|
|
|
|
// Upsert ruleset should trigger manager.ApplyConfig -> POST /load
|
|
rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
select {
|
|
case <-loadCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for manager ApplyConfig /load post on upsert")
|
|
}
|
|
|
|
// Now delete the ruleset and ensure /load is triggered again
|
|
// Read ID from DB
|
|
var rs models.SecurityRuleSet
|
|
assert.NoError(t, db.First(&rs).Error)
|
|
// Use FormatUint to avoid integer overflow when converting uint to int
|
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.FormatUint(uint64(rs.ID), 10), http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
select {
|
|
case <-loadCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for manager ApplyConfig /load post on delete")
|
|
}
|
|
}
|