Files
Charon/backend/internal/caddy/config_security_headers_test.go
T
GitHub Actions 8cf762164f feat: implement HTTP Security Headers management (Issue #20)
Add comprehensive security header management system with reusable
profiles, interactive builders, and security scoring.

Features:
- SecurityHeaderProfile model with 11+ header types
- CRUD API with 10 endpoints (/api/v1/security/headers/*)
- Caddy integration for automatic header injection
- 3 built-in presets (Basic, Strict, Paranoid)
- Security score calculator (0-100) with suggestions
- Interactive CSP builder with validation
- Permissions-Policy builder
- Real-time security score preview
- Per-host profile assignment

Headers Supported:
- HSTS with preload support
- Content-Security-Policy with report-only mode
- X-Frame-Options, X-Content-Type-Options
- Referrer-Policy, Permissions-Policy
- Cross-Origin-Opener/Resource/Embedder-Policy
- X-XSS-Protection, Cache-Control security

Implementation:
- Backend: models, handlers, services (85% coverage)
- Frontend: React components, hooks (87.46% coverage)
- Tests: 1,163 total tests passing
- Docs: Comprehensive feature documentation

Closes #20
2025-12-19 18:55:48 +00:00

364 lines
11 KiB
Go

package caddy
import (
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/assert"
)
func TestBuildSecurityHeadersHandler_AllEnabled(t *testing.T) {
profile := &models.SecurityHeaderProfile{
HSTSEnabled: true,
HSTSMaxAge: 31536000,
HSTSIncludeSubdomains: true,
HSTSPreload: true,
CSPEnabled: true,
CSPDirectives: `{"default-src":["'self'"],"script-src":["'self'"]}`,
CSPReportOnly: false,
XFrameOptions: "DENY",
XContentTypeOptions: true,
ReferrerPolicy: "no-referrer",
PermissionsPolicy: `[{"feature":"camera","allowlist":[]}]`,
CrossOriginOpenerPolicy: "same-origin",
CrossOriginResourcePolicy: "same-origin",
CrossOriginEmbedderPolicy: "require-corp",
XSSProtection: true,
CacheControlNoStore: true,
}
host := &models.ProxyHost{
SecurityHeaderProfile: profile,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
assert.NotNil(t, handler)
assert.Equal(t, "headers", handler["handler"])
response := handler["response"].(map[string]interface{})
headers := response["set"].(map[string][]string)
assert.Contains(t, headers["Strict-Transport-Security"][0], "max-age=31536000")
assert.Contains(t, headers["Strict-Transport-Security"][0], "includeSubDomains")
assert.Contains(t, headers["Strict-Transport-Security"][0], "preload")
assert.Contains(t, headers, "Content-Security-Policy")
assert.Equal(t, "DENY", headers["X-Frame-Options"][0])
assert.Equal(t, "nosniff", headers["X-Content-Type-Options"][0])
assert.Equal(t, "no-referrer", headers["Referrer-Policy"][0])
assert.Contains(t, headers, "Permissions-Policy")
assert.Equal(t, "same-origin", headers["Cross-Origin-Opener-Policy"][0])
assert.Equal(t, "same-origin", headers["Cross-Origin-Resource-Policy"][0])
assert.Equal(t, "require-corp", headers["Cross-Origin-Embedder-Policy"][0])
assert.Equal(t, "1; mode=block", headers["X-XSS-Protection"][0])
assert.Equal(t, "no-store", headers["Cache-Control"][0])
}
func TestBuildSecurityHeadersHandler_HSTSOnly(t *testing.T) {
profile := &models.SecurityHeaderProfile{
HSTSEnabled: true,
HSTSMaxAge: 31536000,
HSTSIncludeSubdomains: true,
HSTSPreload: false,
CSPEnabled: false,
XFrameOptions: "SAMEORIGIN",
XContentTypeOptions: true,
}
host := &models.ProxyHost{
SecurityHeaderProfile: profile,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
assert.NotNil(t, handler)
response := handler["response"].(map[string]interface{})
headers := response["set"].(map[string][]string)
assert.Contains(t, headers["Strict-Transport-Security"][0], "max-age=31536000")
assert.Contains(t, headers["Strict-Transport-Security"][0], "includeSubDomains")
assert.NotContains(t, headers["Strict-Transport-Security"][0], "preload")
assert.NotContains(t, headers, "Content-Security-Policy")
assert.Equal(t, "SAMEORIGIN", headers["X-Frame-Options"][0])
assert.Equal(t, "nosniff", headers["X-Content-Type-Options"][0])
}
func TestBuildSecurityHeadersHandler_CSPOnly(t *testing.T) {
profile := &models.SecurityHeaderProfile{
HSTSEnabled: false,
CSPEnabled: true,
CSPDirectives: `{
"default-src": ["'self'"],
"script-src": ["'self'", "https://cdn.example.com"],
"style-src": ["'self'", "'unsafe-inline'"]
}`,
}
host := &models.ProxyHost{
SecurityHeaderProfile: profile,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
assert.NotNil(t, handler)
response := handler["response"].(map[string]interface{})
headers := response["set"].(map[string][]string)
assert.NotContains(t, headers, "Strict-Transport-Security")
assert.Contains(t, headers, "Content-Security-Policy")
csp := headers["Content-Security-Policy"][0]
assert.Contains(t, csp, "default-src 'self'")
assert.Contains(t, csp, "script-src 'self' https://cdn.example.com")
assert.Contains(t, csp, "style-src 'self' 'unsafe-inline'")
}
func TestBuildSecurityHeadersHandler_CSPReportOnly(t *testing.T) {
profile := &models.SecurityHeaderProfile{
CSPEnabled: true,
CSPDirectives: `{"default-src":["'self'"]}`,
CSPReportOnly: true,
}
host := &models.ProxyHost{
SecurityHeaderProfile: profile,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
assert.NotNil(t, handler)
response := handler["response"].(map[string]interface{})
headers := response["set"].(map[string][]string)
assert.NotContains(t, headers, "Content-Security-Policy")
assert.Contains(t, headers, "Content-Security-Policy-Report-Only")
}
func TestBuildSecurityHeadersHandler_NoProfile(t *testing.T) {
host := &models.ProxyHost{
SecurityHeaderProfile: nil,
SecurityHeadersEnabled: true,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
assert.NotNil(t, handler)
// Should use defaults
response := handler["response"].(map[string]interface{})
headers := response["set"].(map[string][]string)
assert.Contains(t, headers, "Strict-Transport-Security")
assert.Equal(t, "SAMEORIGIN", headers["X-Frame-Options"][0])
assert.Equal(t, "nosniff", headers["X-Content-Type-Options"][0])
}
func TestBuildSecurityHeadersHandler_Disabled(t *testing.T) {
host := &models.ProxyHost{
SecurityHeaderProfile: nil,
SecurityHeadersEnabled: false,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
assert.Nil(t, handler)
}
func TestBuildSecurityHeadersHandler_NilHost(t *testing.T) {
handler, err := buildSecurityHeadersHandler(nil)
assert.NoError(t, err)
assert.Nil(t, handler)
}
func TestBuildCSPString(t *testing.T) {
tests := []struct {
name string
input string
expected string
wantErr bool
}{
{
name: "simple CSP",
input: `{"default-src":["'self'"]}`,
expected: "default-src 'self'",
wantErr: false,
},
{
name: "multiple directives",
input: `{"default-src":["'self'"],"script-src":["'self'","https:"],"style-src":["'self'","'unsafe-inline'"]}`,
expected: "default-src 'self'; script-src 'self' https:; style-src 'self' 'unsafe-inline'",
wantErr: false,
},
{
name: "empty string",
input: "",
expected: "",
wantErr: false,
},
{
name: "invalid JSON",
input: "not json",
expected: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := buildCSPString(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
// CSP order can vary, so check parts exist
if tt.expected != "" {
parts := []string{}
if tt.expected == "default-src 'self'" {
parts = []string{"default-src 'self'"}
} else {
// For multiple directives, just check all parts are present
parts = []string{"default-src 'self'", "script-src", "style-src"}
}
for _, part := range parts {
assert.Contains(t, result, part)
}
}
}
})
}
}
func TestBuildPermissionsPolicyString(t *testing.T) {
tests := []struct {
name string
input string
expected string
wantErr bool
}{
{
name: "single feature no allowlist",
input: `[{"feature":"camera","allowlist":[]}]`,
expected: "camera=()",
wantErr: false,
},
{
name: "single feature with self",
input: `[{"feature":"microphone","allowlist":["self"]}]`,
expected: "microphone=(self)",
wantErr: false,
},
{
name: "multiple features",
input: `[{"feature":"camera","allowlist":[]},{"feature":"microphone","allowlist":["self"]},{"feature":"geolocation","allowlist":["*"]}]`,
expected: "camera=(), microphone=(self), geolocation=(*)",
wantErr: false,
},
{
name: "empty string",
input: "",
expected: "",
wantErr: false,
},
{
name: "invalid JSON",
input: "not json",
expected: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := buildPermissionsPolicyString(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
if tt.expected != "" {
assert.Equal(t, tt.expected, result)
}
}
})
}
}
func TestGetDefaultSecurityHeaderProfile(t *testing.T) {
profile := getDefaultSecurityHeaderProfile()
assert.NotNil(t, profile)
assert.True(t, profile.HSTSEnabled)
assert.Equal(t, 31536000, profile.HSTSMaxAge)
assert.False(t, profile.HSTSIncludeSubdomains)
assert.False(t, profile.HSTSPreload)
assert.False(t, profile.CSPEnabled) // Off by default
assert.Equal(t, "SAMEORIGIN", profile.XFrameOptions)
assert.True(t, profile.XContentTypeOptions)
assert.Equal(t, "strict-origin-when-cross-origin", profile.ReferrerPolicy)
assert.True(t, profile.XSSProtection)
assert.Equal(t, "same-origin", profile.CrossOriginOpenerPolicy)
assert.Equal(t, "same-origin", profile.CrossOriginResourcePolicy)
}
func TestBuildSecurityHeadersHandler_PermissionsPolicy(t *testing.T) {
profile := &models.SecurityHeaderProfile{
PermissionsPolicy: `[{"feature":"camera","allowlist":[]},{"feature":"microphone","allowlist":["self"]},{"feature":"geolocation","allowlist":["*"]}]`,
}
host := &models.ProxyHost{
SecurityHeaderProfile: profile,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
assert.NotNil(t, handler)
response := handler["response"].(map[string]interface{})
headers := response["set"].(map[string][]string)
assert.Contains(t, headers, "Permissions-Policy")
pp := headers["Permissions-Policy"][0]
assert.Contains(t, pp, "camera=()")
assert.Contains(t, pp, "microphone=(self)")
assert.Contains(t, pp, "geolocation=(*)")
}
func TestBuildSecurityHeadersHandler_InvalidCSPJSON(t *testing.T) {
profile := &models.SecurityHeaderProfile{
CSPEnabled: true,
CSPDirectives: "invalid json",
}
host := &models.ProxyHost{
SecurityHeaderProfile: profile,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
assert.NotNil(t, handler)
// Should skip CSP if invalid JSON
response := handler["response"].(map[string]interface{})
headers := response["set"].(map[string][]string)
assert.NotContains(t, headers, "Content-Security-Policy")
}
func TestBuildSecurityHeadersHandler_InvalidPermissionsJSON(t *testing.T) {
profile := &models.SecurityHeaderProfile{
PermissionsPolicy: "invalid json",
}
host := &models.ProxyHost{
SecurityHeaderProfile: profile,
}
handler, err := buildSecurityHeadersHandler(host)
assert.NoError(t, err)
// Should skip invalid permissions policy but continue with other headers
// If profile had no other headers, handler would be nil
// Since we're only testing permissions policy, handler will be nil
assert.Nil(t, handler)
}