Implement secure CORS configuration system
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user