Implement secure CORS configuration system

This commit is contained in:
2025-10-30 22:28:03 -07:00
parent abe7b6beee
commit 9a43af27ad
12 changed files with 978 additions and 57 deletions
+4 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/igodwin/notifier/internal/logging"
@@ -68,13 +69,13 @@ func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *Boots
// Print to stdout if configured (DANGEROUS - only for interactive setup)
if cfg.PrintToStdout {
fmt.Println("\n" + "="*60)
fmt.Println("\n" + strings.Repeat("=", 60))
fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED")
fmt.Println("="*60)
fmt.Println(strings.Repeat("=", 60))
fmt.Printf("Key: %s\n", apiKey.Key)
fmt.Println("\nSave this key in a secure location. You will not be able to see it again.")
fmt.Println("Use this key to create additional API keys via the key management API.")
fmt.Println("="*60 + "\n")
fmt.Println(strings.Repeat("=", 60) + "\n")
}
logger.Infof("Bootstrap admin key created successfully")
+22 -3
View File
@@ -127,16 +127,35 @@ func (h *HybridKeyStore) UpdateLastUsed(ctx context.Context, keyStr string) erro
}
// CheckRateLimit checks if a key has exceeded its rate limit
func (h *HybridKeyStore) CheckRateLimit(keyStr string) error {
func (h *HybridKeyStore) CheckRateLimit(keyStr string) (bool, error) {
h.cache.mu.RLock()
defer h.cache.mu.RUnlock()
limiter, exists := h.cache.rateLimits[keyStr]
if !exists {
return fmt.Errorf("rate limiter not found")
return false, fmt.Errorf("rate limiter not found")
}
return limiter.Check()
// Check if we're under the rate limit
if limiter.maxRequests == 0 {
return true, nil // Unlimited
}
limiter.mu.Lock()
defer limiter.mu.Unlock()
now := time.Now()
if now.After(limiter.resetTime) {
limiter.resetTime = now.Add(limiter.window)
limiter.count = 0
}
if limiter.count >= limiter.maxRequests {
return false, nil
}
limiter.count++
return true, nil
}
// GetAuditLog retrieves audit log for a key
+54
View File
@@ -20,6 +20,7 @@ type Config struct {
Metrics MetricsConfig `mapstructure:"metrics"`
HealthCheck HealthCheckConfig `mapstructure:"health_check"`
Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Retention NotificationRetentionConfig `mapstructure:"retention"`
ConfigFile string `mapstructure:"-"` // Path to config file used (not from config)
}
@@ -69,6 +70,26 @@ type AuthConfig struct {
DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited)
}
// CORSConfig contains CORS (Cross-Origin Resource Sharing) configuration
type CORSConfig struct {
// AllowedOrigins is a whitelist of allowed origins (e.g., ["https://example.com", "https://app.example.com"])
// Wildcards (*) are NOT supported for security reasons - you must specify exact origins
AllowedOrigins []string `mapstructure:"allowed_origins"`
// AllowedMethods is a list of allowed HTTP methods (e.g., ["GET", "POST", "OPTIONS", "DELETE"])
AllowedMethods []string `mapstructure:"allowed_methods"`
// AllowedHeaders is a list of allowed HTTP headers (e.g., ["Content-Type", "Authorization"])
AllowedHeaders []string `mapstructure:"allowed_headers"`
// AllowCredentials indicates whether credentials (cookies, authorization headers) are allowed
// Note: When true, AllowedOrigins must NOT contain wildcards (enforced by validation)
AllowCredentials bool `mapstructure:"allow_credentials"`
// MaxAge is the duration in seconds that browsers can cache preflight responses
MaxAge int `mapstructure:"max_age"`
}
// NotificationRetentionConfig contains notification retention and cleanup configuration
type NotificationRetentionConfig struct {
Enabled bool `mapstructure:"enabled"` // Enable automatic cleanup
@@ -181,6 +202,13 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("auth.enabled", false) // Authentication disabled by default
v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute default
// CORS defaults - secure by default (no origins allowed)
v.SetDefault("cors.allowed_origins", []string{}) // Empty by default - must be explicitly configured
v.SetDefault("cors.allowed_methods", []string{"GET", "POST", "OPTIONS", "DELETE"}) // Standard REST methods
v.SetDefault("cors.allowed_headers", []string{"Content-Type", "Authorization"}) // Common headers
v.SetDefault("cors.allow_credentials", false) // Credentials disabled by default
v.SetDefault("cors.max_age", 3600) // 1 hour cache for preflight
// Retention defaults
v.SetDefault("retention.enabled", true) // Enable retention cleanup by default
v.SetDefault("retention.ttl", "168h") // 7 days default
@@ -224,6 +252,32 @@ func (c *Config) Validate() error {
return fmt.Errorf("at least one notifier must be configured")
}
// Validate CORS configuration
if err := c.validateCORS(); err != nil {
return err
}
return nil
}
// validateCORS validates the CORS configuration
func (c *Config) validateCORS() error {
// Check for wildcard in allowed origins (security vulnerability)
for _, origin := range c.CORS.AllowedOrigins {
if origin == "*" {
return fmt.Errorf("wildcard (*) is not allowed in CORS allowed_origins for security reasons - specify exact origins instead")
}
// Check for invalid origin format
if origin != "" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
return fmt.Errorf("invalid origin format: %s - origins must start with http:// or https://", origin)
}
}
// Validate that credentials are not used with empty origin list (pointless configuration)
if c.CORS.AllowCredentials && len(c.CORS.AllowedOrigins) == 0 {
return fmt.Errorf("allow_credentials is enabled but no origins are allowed - this configuration is ineffective")
}
return nil
}
+329
View File
@@ -0,0 +1,329 @@
package config
import (
"strings"
"testing"
"github.com/igodwin/notifier/internal/domain"
)
// TestValidateCORS_WildcardRejection tests that wildcard origins are rejected
func TestValidateCORS_WildcardRejection(t *testing.T) {
config := &Config{
Server: ServerConfig{
GRPCPort: 50051,
RESTPort: 8080,
Mode: "both",
},
Queue: domain.QueueConfig{
Type: "local",
},
Notifiers: NotifiersConfig{
Stdout: true,
},
CORS: CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Content-Type"},
},
}
err := config.Validate()
if err == nil {
t.Error("Expected validation to fail with wildcard origin")
}
if !strings.Contains(err.Error(), "wildcard") {
t.Errorf("Expected error to mention wildcard, got: %v", err)
}
}
// TestValidateCORS_InvalidOriginFormat tests origin format validation
func TestValidateCORS_InvalidOriginFormat(t *testing.T) {
tests := []struct {
name string
origin string
valid bool
}{
{
name: "valid https",
origin: "https://example.com",
valid: true,
},
{
name: "valid http",
origin: "http://localhost:3000",
valid: true,
},
{
name: "missing protocol",
origin: "example.com",
valid: false,
},
{
name: "invalid protocol",
origin: "ftp://example.com",
valid: false,
},
{
name: "just domain without protocol",
origin: "www.example.com",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &Config{
Server: ServerConfig{
GRPCPort: 50051,
RESTPort: 8080,
Mode: "both",
},
Queue: domain.QueueConfig{
Type: "local",
},
Notifiers: NotifiersConfig{
Stdout: true,
},
CORS: CORSConfig{
AllowedOrigins: []string{tt.origin},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"Content-Type"},
},
}
err := config.Validate()
if tt.valid && err != nil && strings.Contains(err.Error(), "invalid origin format") {
t.Errorf("Expected origin %v to be valid, got error: %v", tt.origin, err)
}
if !tt.valid && (err == nil || !strings.Contains(err.Error(), "invalid origin format")) {
t.Errorf("Expected origin %v to be invalid, got error: %v", tt.origin, err)
}
})
}
}
// TestValidateCORS_CredentialsWithoutOrigins tests that credentials require origins
func TestValidateCORS_CredentialsWithoutOrigins(t *testing.T) {
config := &Config{
Server: ServerConfig{
GRPCPort: 50051,
RESTPort: 8080,
Mode: "both",
},
Queue: domain.QueueConfig{
Type: "local",
},
Notifiers: NotifiersConfig{
Stdout: true,
},
CORS: CORSConfig{
AllowedOrigins: []string{}, // Empty origins
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"Content-Type"},
AllowCredentials: true, // But credentials enabled
},
}
err := config.Validate()
if err == nil {
t.Error("Expected validation to fail when credentials enabled but no origins allowed")
}
if !strings.Contains(err.Error(), "allow_credentials") {
t.Errorf("Expected error to mention allow_credentials, got: %v", err)
}
}
// TestValidateCORS_ValidConfigurations tests valid CORS configurations
func TestValidateCORS_ValidConfigurations(t *testing.T) {
tests := []struct {
name string
cors CORSConfig
}{
{
name: "no origins (default secure config)",
cors: CORSConfig{
AllowedOrigins: []string{},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Content-Type"},
},
},
{
name: "single origin",
cors: CORSConfig{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Content-Type"},
},
},
{
name: "multiple origins",
cors: CORSConfig{
AllowedOrigins: []string{
"https://example.com",
"https://app.example.com",
"http://localhost:3000",
},
AllowedMethods: []string{"GET", "POST", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
},
},
{
name: "with credentials",
cors: CORSConfig{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
},
},
{
name: "localhost development config",
cors: CORSConfig{
AllowedOrigins: []string{
"http://localhost:3000",
"http://localhost:8080",
"http://localhost:5173",
},
AllowedMethods: []string{"GET", "POST", "OPTIONS", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &Config{
Server: ServerConfig{
GRPCPort: 50051,
RESTPort: 8080,
Mode: "both",
},
Queue: domain.QueueConfig{
Type: "local",
},
Notifiers: NotifiersConfig{
Stdout: true,
},
CORS: tt.cors,
}
err := config.Validate()
if err != nil && strings.Contains(err.Error(), "CORS") {
t.Errorf("Expected valid CORS config, got error: %v", err)
}
})
}
}
// TestValidateCORS_MultipleOrigins tests validation with multiple origins including invalid ones
func TestValidateCORS_MultipleOrigins(t *testing.T) {
config := &Config{
Server: ServerConfig{
GRPCPort: 50051,
RESTPort: 8080,
Mode: "both",
},
Queue: domain.QueueConfig{
Type: "local",
},
Notifiers: NotifiersConfig{
Stdout: true,
},
CORS: CORSConfig{
AllowedOrigins: []string{
"https://example.com",
"*", // Wildcard in the middle
"https://app.example.com",
},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"Content-Type"},
},
}
err := config.Validate()
if err == nil {
t.Error("Expected validation to fail with wildcard in origins list")
}
if !strings.Contains(err.Error(), "wildcard") {
t.Errorf("Expected error to mention wildcard, got: %v", err)
}
}
// TestValidateCORS_EdgeCases tests edge cases in CORS validation
func TestValidateCORS_EdgeCases(t *testing.T) {
tests := []struct {
name string
cors CORSConfig
shouldErr bool
errText string
}{
{
name: "empty string in origins",
cors: CORSConfig{
AllowedOrigins: []string{"https://example.com", ""},
AllowedMethods: []string{"GET"},
},
shouldErr: false, // Empty strings are ignored
},
{
name: "origin with port",
cors: CORSConfig{
AllowedOrigins: []string{"https://example.com:8443"},
AllowedMethods: []string{"GET"},
},
shouldErr: false,
},
{
name: "origin with path (invalid)",
cors: CORSConfig{
AllowedOrigins: []string{"https://example.com/path"},
AllowedMethods: []string{"GET"},
},
shouldErr: false, // Path is technically valid in origin
},
{
name: "credentials without origins",
cors: CORSConfig{
AllowedOrigins: []string{},
AllowedMethods: []string{"GET"},
AllowCredentials: true,
},
shouldErr: true,
errText: "allow_credentials",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &Config{
Server: ServerConfig{
GRPCPort: 50051,
RESTPort: 8080,
Mode: "both",
},
Queue: domain.QueueConfig{
Type: "local",
},
Notifiers: NotifiersConfig{
Stdout: true,
},
CORS: tt.cors,
}
err := config.Validate()
if tt.shouldErr && err == nil {
t.Errorf("Expected validation to fail for %s", tt.name)
}
if tt.shouldErr && err != nil && tt.errText != "" && !strings.Contains(err.Error(), tt.errText) {
t.Errorf("Expected error to contain '%s', got: %v", tt.errText, err)
}
if !tt.shouldErr && err != nil && strings.Contains(err.Error(), "CORS") {
t.Errorf("Expected validation to pass for %s, got error: %v", tt.name, err)
}
})
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ func createTestService(t *testing.T) *NotificationService {
t.Fatalf("Failed to create logger: %v", err)
}
svc := NewNotificationService(factory, q, 2, nil, logger)
svc := NewNotificationService(factory, q, 2, nil, nil, logger)
return svc
}