Implement secure CORS configuration system
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCORSMiddleware_AllowedOrigin tests that allowed origins are accepted
|
||||
func TestCORSMiddleware_AllowedOrigin(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"https://example.com", "https://app.example.com"},
|
||||
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 3600,
|
||||
}
|
||||
|
||||
middleware := newCORSMiddleware(config)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
expectOrigin string
|
||||
expectMethods string
|
||||
expectHeaders string
|
||||
expectMaxAge string
|
||||
expectCreds string
|
||||
}{
|
||||
{
|
||||
name: "allowed origin - example.com",
|
||||
origin: "https://example.com",
|
||||
expectOrigin: "https://example.com",
|
||||
expectMethods: "GET, POST, OPTIONS",
|
||||
expectHeaders: "Content-Type, Authorization",
|
||||
expectMaxAge: "3600",
|
||||
expectCreds: "",
|
||||
},
|
||||
{
|
||||
name: "allowed origin - app.example.com",
|
||||
origin: "https://app.example.com",
|
||||
expectOrigin: "https://app.example.com",
|
||||
expectMethods: "GET, POST, OPTIONS",
|
||||
expectHeaders: "Content-Type, Authorization",
|
||||
expectMaxAge: "3600",
|
||||
expectCreds: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// Check CORS headers
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tt.expectOrigin {
|
||||
t.Errorf("Access-Control-Allow-Origin = %v, want %v", got, tt.expectOrigin)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != tt.expectMethods {
|
||||
t.Errorf("Access-Control-Allow-Methods = %v, want %v", got, tt.expectMethods)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != tt.expectHeaders {
|
||||
t.Errorf("Access-Control-Allow-Headers = %v, want %v", got, tt.expectHeaders)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Max-Age"); got != tt.expectMaxAge {
|
||||
t.Errorf("Access-Control-Max-Age = %v, want %v", got, tt.expectMaxAge)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != tt.expectCreds {
|
||||
t.Errorf("Access-Control-Allow-Credentials = %v, want %v", got, tt.expectCreds)
|
||||
}
|
||||
|
||||
// Verify response
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %v, want %v", rec.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSMiddleware_BlockedOrigin tests that non-whitelisted origins are rejected
|
||||
func TestCORSMiddleware_BlockedOrigin(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
}
|
||||
|
||||
middleware := newCORSMiddleware(config)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
}{
|
||||
{
|
||||
name: "different domain",
|
||||
origin: "https://malicious.com",
|
||||
},
|
||||
{
|
||||
name: "subdomain not in whitelist",
|
||||
origin: "https://subdomain.example.com",
|
||||
},
|
||||
{
|
||||
name: "http instead of https",
|
||||
origin: "http://example.com",
|
||||
},
|
||||
{
|
||||
name: "no origin header",
|
||||
origin: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
if tt.origin != "" {
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// CORS headers should NOT be set
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Access-Control-Allow-Origin should not be set, got %v", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "" {
|
||||
t.Errorf("Access-Control-Allow-Methods should not be set, got %v", got)
|
||||
}
|
||||
|
||||
// The request should still succeed (CORS is browser-enforced)
|
||||
// But without CORS headers, browsers will block the response
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %v, want %v", rec.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSMiddleware_PreflightRequest tests OPTIONS preflight requests
|
||||
func TestCORSMiddleware_PreflightRequest(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST", "DELETE"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
||||
MaxAge: 7200,
|
||||
}
|
||||
|
||||
middleware := newCORSMiddleware(config)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Handler should not be called for OPTIONS request")
|
||||
}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
expectStatus int
|
||||
expectHeaders bool
|
||||
}{
|
||||
{
|
||||
name: "preflight from allowed origin",
|
||||
origin: "https://example.com",
|
||||
expectStatus: http.StatusOK,
|
||||
expectHeaders: true,
|
||||
},
|
||||
{
|
||||
name: "preflight from blocked origin",
|
||||
origin: "https://malicious.com",
|
||||
expectStatus: http.StatusOK,
|
||||
expectHeaders: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodOptions, "/test", nil)
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// Preflight should always return 200 OK
|
||||
if rec.Code != tt.expectStatus {
|
||||
t.Errorf("status = %v, want %v", rec.Code, tt.expectStatus)
|
||||
}
|
||||
|
||||
// Check if CORS headers are set based on origin
|
||||
hasOriginHeader := rec.Header().Get("Access-Control-Allow-Origin") != ""
|
||||
if hasOriginHeader != tt.expectHeaders {
|
||||
t.Errorf("CORS headers present = %v, want %v", hasOriginHeader, tt.expectHeaders)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSMiddleware_Credentials tests credential handling
|
||||
func TestCORSMiddleware_Credentials(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 3600,
|
||||
}
|
||||
|
||||
middleware := newCORSMiddleware(config)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// Verify credentials header is set
|
||||
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("Access-Control-Allow-Credentials = %v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSMiddleware_NoWildcard tests that wildcard is never returned
|
||||
func TestCORSMiddleware_NoWildcard(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"https://example.com", "https://app.example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
}
|
||||
|
||||
middleware := newCORSMiddleware(config)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Test multiple origins to ensure wildcard is never used
|
||||
origins := []string{"https://example.com", "https://app.example.com", "https://malicious.com"}
|
||||
|
||||
for _, origin := range origins {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// Verify wildcard is NEVER returned
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got == "*" {
|
||||
t.Errorf("Access-Control-Allow-Origin should never be wildcard, origin was %v", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSMiddleware_EmptyConfig tests behavior with empty allowed origins
|
||||
func TestCORSMiddleware_EmptyConfig(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{}, // No origins allowed
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
}
|
||||
|
||||
middleware := newCORSMiddleware(config)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// No CORS headers should be set
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Access-Control-Allow-Origin should not be set with empty config, got %v", got)
|
||||
}
|
||||
|
||||
// Request should still succeed
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %v, want %v", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultCORSConfig tests the default configuration
|
||||
func TestDefaultCORSConfig(t *testing.T) {
|
||||
config := DefaultCORSConfig()
|
||||
|
||||
if len(config.AllowedOrigins) != 0 {
|
||||
t.Errorf("Default config should have no allowed origins, got %v", config.AllowedOrigins)
|
||||
}
|
||||
|
||||
if config.AllowCredentials {
|
||||
t.Error("Default config should not allow credentials")
|
||||
}
|
||||
|
||||
expectedMethods := []string{"GET", "POST", "OPTIONS", "DELETE"}
|
||||
if len(config.AllowedMethods) != len(expectedMethods) {
|
||||
t.Errorf("Default methods count = %v, want %v", len(config.AllowedMethods), len(expectedMethods))
|
||||
}
|
||||
|
||||
expectedHeaders := []string{"Content-Type", "Authorization"}
|
||||
if len(config.AllowedHeaders) != len(expectedHeaders) {
|
||||
t.Errorf("Default headers count = %v, want %v", len(config.AllowedHeaders), len(expectedHeaders))
|
||||
}
|
||||
|
||||
if config.MaxAge != 3600 {
|
||||
t.Errorf("Default MaxAge = %v, want 3600", config.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSMiddleware_MaxAge tests custom max age values
|
||||
func TestCORSMiddleware_MaxAge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
maxAge int
|
||||
expectMaxAge string
|
||||
}{
|
||||
{
|
||||
name: "zero max age",
|
||||
maxAge: 0,
|
||||
expectMaxAge: "",
|
||||
},
|
||||
{
|
||||
name: "one hour",
|
||||
maxAge: 3600,
|
||||
expectMaxAge: "3600",
|
||||
},
|
||||
{
|
||||
name: "one day",
|
||||
maxAge: 86400,
|
||||
expectMaxAge: "86400",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: tt.maxAge,
|
||||
}
|
||||
|
||||
middleware := newCORSMiddleware(config)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if got := rec.Header().Get("Access-Control-Max-Age"); got != tt.expectMaxAge {
|
||||
t.Errorf("Access-Control-Max-Age = %v, want %v", got, tt.expectMaxAge)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
+92
-19
@@ -2,6 +2,8 @@ package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/igodwin/notifier/internal/auth"
|
||||
@@ -9,13 +11,45 @@ import (
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
)
|
||||
|
||||
// NewRouter creates a new HTTP router with all routes configured
|
||||
func NewRouter(service domain.NotificationService, logger *logging.Logger) *mux.Router {
|
||||
return NewRouterWithAuth(service, logger, nil)
|
||||
// CORSConfig contains CORS middleware 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
|
||||
AllowedOrigins []string
|
||||
|
||||
// AllowedMethods is a list of allowed HTTP methods (e.g., ["GET", "POST", "OPTIONS", "DELETE"])
|
||||
AllowedMethods []string
|
||||
|
||||
// AllowedHeaders is a list of allowed HTTP headers (e.g., ["Content-Type", "Authorization"])
|
||||
AllowedHeaders []string
|
||||
|
||||
// AllowCredentials indicates whether credentials (cookies, authorization headers) are allowed
|
||||
// Note: When true, AllowedOrigins must NOT contain wildcards
|
||||
AllowCredentials bool
|
||||
|
||||
// MaxAge is the duration in seconds that browsers can cache preflight responses
|
||||
MaxAge int
|
||||
}
|
||||
|
||||
// NewRouterWithAuth creates a new HTTP router with optional authentication
|
||||
func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore) *mux.Router {
|
||||
// DefaultCORSConfig returns a secure default CORS configuration
|
||||
// By default, no origins are allowed - you must explicitly configure allowed origins
|
||||
func DefaultCORSConfig() *CORSConfig {
|
||||
return &CORSConfig{
|
||||
AllowedOrigins: []string{}, // Empty by default - must be explicitly configured
|
||||
AllowedMethods: []string{"GET", "POST", "OPTIONS", "DELETE"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 3600, // 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
// NewRouter creates a new HTTP router with all routes configured
|
||||
func NewRouter(service domain.NotificationService, logger *logging.Logger) *mux.Router {
|
||||
return NewRouterWithAuth(service, logger, nil, DefaultCORSConfig())
|
||||
}
|
||||
|
||||
// NewRouterWithAuth creates a new HTTP router with optional authentication and CORS configuration
|
||||
func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore, corsConfig *CORSConfig) *mux.Router {
|
||||
handler := NewHandler(service, logger)
|
||||
router := mux.NewRouter()
|
||||
|
||||
@@ -45,9 +79,11 @@ func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logge
|
||||
// Health check route (no auth required)
|
||||
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
|
||||
|
||||
// Middleware
|
||||
// Middleware - CORS must be applied before auth to handle preflight requests
|
||||
router.Use(loggingMiddleware)
|
||||
router.Use(corsMiddleware)
|
||||
if corsConfig != nil {
|
||||
router.Use(newCORSMiddleware(corsConfig))
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -60,18 +96,55 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
// newCORSMiddleware creates a CORS middleware with origin whitelist validation
|
||||
func newCORSMiddleware(config *CORSConfig) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
// Check if the origin is in the allowed list
|
||||
allowed := false
|
||||
for _, allowedOrigin := range config.AllowedOrigins {
|
||||
if origin == allowedOrigin {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
// Only set CORS headers if the origin is allowed
|
||||
if allowed {
|
||||
// Set the exact origin (never use wildcard)
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
|
||||
// Set allowed methods
|
||||
if len(config.AllowedMethods) > 0 {
|
||||
w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
|
||||
}
|
||||
|
||||
// Set allowed headers
|
||||
if len(config.AllowedHeaders) > 0 {
|
||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
|
||||
}
|
||||
|
||||
// Set credentials header if enabled
|
||||
if config.AllowCredentials {
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
// Set max age for preflight caching
|
||||
if config.MaxAge > 0 {
|
||||
w.Header().Set("Access-Control-Max-Age", strconv.FormatInt(int64(config.MaxAge), 10))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle preflight OPTIONS requests
|
||||
if r.Method == http.MethodOptions {
|
||||
// Return 200 OK for preflight requests
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+17
-6
@@ -12,7 +12,6 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
grpcapi "github.com/igodwin/notifier/api/grpc"
|
||||
pb "github.com/igodwin/notifier/api/grpc/pb"
|
||||
"github.com/igodwin/notifier/api/rest"
|
||||
@@ -286,13 +285,25 @@ func startGRPCServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config
|
||||
}
|
||||
|
||||
func startRESTServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore) *http.Server {
|
||||
var router *mux.Router
|
||||
if authStore != nil {
|
||||
router = rest.NewRouterWithAuth(svc, logger, authStore)
|
||||
} else {
|
||||
router = rest.NewRouter(svc, logger)
|
||||
// Convert config CORS to rest.CORSConfig
|
||||
corsConfig := &rest.CORSConfig{
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
AllowedMethods: cfg.CORS.AllowedMethods,
|
||||
AllowedHeaders: cfg.CORS.AllowedHeaders,
|
||||
AllowCredentials: cfg.CORS.AllowCredentials,
|
||||
MaxAge: cfg.CORS.MaxAge,
|
||||
}
|
||||
|
||||
// Log CORS configuration
|
||||
if len(cfg.CORS.AllowedOrigins) > 0 {
|
||||
logger.Infof("CORS enabled for origins: %v", cfg.CORS.AllowedOrigins)
|
||||
} else {
|
||||
logger.Warn("CORS has no allowed origins configured - all cross-origin requests will be blocked")
|
||||
}
|
||||
|
||||
// Create router with auth and CORS config
|
||||
router := rest.NewRouterWithAuth(svc, logger, authStore, corsConfig)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
|
||||
+36
@@ -109,6 +109,42 @@ health_check:
|
||||
path: "/health"
|
||||
interval: 30 # seconds
|
||||
|
||||
# CORS (Cross-Origin Resource Sharing) configuration
|
||||
# Controls which web origins can access the REST API
|
||||
cors:
|
||||
# Whitelist of allowed origins - wildcards (*) are NOT supported for security
|
||||
# Development example: Allow localhost on common ports
|
||||
allowed_origins:
|
||||
- "http://localhost:3000" # Common React/Next.js dev port
|
||||
- "http://localhost:8080" # Common Vue/Angular dev port
|
||||
- "http://localhost:5173" # Vite dev server
|
||||
|
||||
# Production example (commented out):
|
||||
# allowed_origins:
|
||||
# - "https://app.example.com" # Production web app
|
||||
# - "https://dashboard.example.com" # Admin dashboard
|
||||
# - "https://api-docs.example.com" # API documentation site
|
||||
|
||||
# Allowed HTTP methods (defaults shown below)
|
||||
allowed_methods:
|
||||
- "GET"
|
||||
- "POST"
|
||||
- "OPTIONS"
|
||||
- "DELETE"
|
||||
|
||||
# Allowed request headers (defaults shown below)
|
||||
allowed_headers:
|
||||
- "Content-Type"
|
||||
- "Authorization"
|
||||
|
||||
# Allow credentials (cookies, authorization headers)
|
||||
# Set to true if your frontend needs to send auth tokens
|
||||
allow_credentials: false
|
||||
|
||||
# Cache duration for preflight OPTIONS requests (in seconds)
|
||||
# Browsers will cache the CORS preflight response for this duration
|
||||
max_age: 3600 # 1 hour
|
||||
|
||||
# Notification retention and automatic cleanup configuration
|
||||
retention:
|
||||
enabled: true # Enable automatic cleanup of old/expired notifications
|
||||
|
||||
@@ -7,7 +7,9 @@ toolchain go1.24.6
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/testcontainers/testcontainers-go v0.39.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
)
|
||||
@@ -61,17 +63,19 @@ require (
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/testcontainers/testcontainers-go v0.39.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -16,6 +18,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -54,6 +58,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
@@ -64,10 +70,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
@@ -78,6 +84,8 @@ github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
@@ -101,8 +109,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
@@ -124,16 +132,16 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts=
|
||||
@@ -150,16 +158,22 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
@@ -193,10 +207,14 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
@@ -207,6 +225,9 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
@@ -214,10 +235,12 @@ google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94U
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
|
||||
@@ -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