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
+372
View File
@@ -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)
}
})
}
}
-1
View File
@@ -2,7 +2,6 @@ package rest
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
+92 -19
View File
@@ -2,6 +2,8 @@ package rest
import ( import (
"net/http" "net/http"
"strconv"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/igodwin/notifier/internal/auth" "github.com/igodwin/notifier/internal/auth"
@@ -9,13 +11,45 @@ import (
"github.com/igodwin/notifier/internal/logging" "github.com/igodwin/notifier/internal/logging"
) )
// NewRouter creates a new HTTP router with all routes configured // CORSConfig contains CORS middleware configuration
func NewRouter(service domain.NotificationService, logger *logging.Logger) *mux.Router { type CORSConfig struct {
return NewRouterWithAuth(service, logger, nil) // 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 // DefaultCORSConfig returns a secure default CORS configuration
func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore) *mux.Router { // 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) handler := NewHandler(service, logger)
router := mux.NewRouter() router := mux.NewRouter()
@@ -45,9 +79,11 @@ func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logge
// Health check route (no auth required) // Health check route (no auth required)
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet) 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(loggingMiddleware)
router.Use(corsMiddleware) if corsConfig != nil {
router.Use(newCORSMiddleware(corsConfig))
}
return router return router
} }
@@ -60,18 +96,55 @@ func loggingMiddleware(next http.Handler) http.Handler {
}) })
} }
// corsMiddleware adds CORS headers // newCORSMiddleware creates a CORS middleware with origin whitelist validation
func corsMiddleware(next http.Handler) http.Handler { func newCORSMiddleware(config *CORSConfig) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return func(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") origin := r.Header.Get("Origin")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions { // Check if the origin is in the allowed list
w.WriteHeader(http.StatusOK) allowed := false
return 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
View File
@@ -12,7 +12,6 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/gorilla/mux"
grpcapi "github.com/igodwin/notifier/api/grpc" grpcapi "github.com/igodwin/notifier/api/grpc"
pb "github.com/igodwin/notifier/api/grpc/pb" pb "github.com/igodwin/notifier/api/grpc/pb"
"github.com/igodwin/notifier/api/rest" "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 { 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 // Convert config CORS to rest.CORSConfig
if authStore != nil { corsConfig := &rest.CORSConfig{
router = rest.NewRouterWithAuth(svc, logger, authStore) AllowedOrigins: cfg.CORS.AllowedOrigins,
} else { AllowedMethods: cfg.CORS.AllowedMethods,
router = rest.NewRouter(svc, logger) 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) addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
server := &http.Server{ server := &http.Server{
Addr: addr, Addr: addr,
+36
View File
@@ -109,6 +109,42 @@ health_check:
path: "/health" path: "/health"
interval: 30 # seconds 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 # Notification retention and automatic cleanup configuration
retention: retention:
enabled: true # Enable automatic cleanup of old/expired notifications enabled: true # Enable automatic cleanup of old/expired notifications
+9 -5
View File
@@ -7,7 +7,9 @@ toolchain go1.24.6
require ( require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/spf13/viper v1.19.0 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/grpc v1.76.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.10
) )
@@ -61,17 +63,19 @@ require (
github.com/spf13/afero v1.11.0 // indirect github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // 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/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/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // 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/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.37.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/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.43.0 // indirect golang.org/x/crypto v0.43.0 // indirect
+42 -19
View File
@@ -1,5 +1,7 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 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 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 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/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 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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/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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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= 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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 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/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 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 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 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 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= 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/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 h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 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= 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.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.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.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/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.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.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.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.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.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.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= 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/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 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 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 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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-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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 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= 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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 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 h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= 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= 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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"strings"
"time" "time"
"github.com/igodwin/notifier/internal/logging" "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) // Print to stdout if configured (DANGEROUS - only for interactive setup)
if cfg.PrintToStdout { if cfg.PrintToStdout {
fmt.Println("\n" + "="*60) fmt.Println("\n" + strings.Repeat("=", 60))
fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED") fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED")
fmt.Println("="*60) fmt.Println(strings.Repeat("=", 60))
fmt.Printf("Key: %s\n", apiKey.Key) 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("\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("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") 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 // 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() h.cache.mu.RLock()
defer h.cache.mu.RUnlock() defer h.cache.mu.RUnlock()
limiter, exists := h.cache.rateLimits[keyStr] limiter, exists := h.cache.rateLimits[keyStr]
if !exists { 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 // GetAuditLog retrieves audit log for a key
+54
View File
@@ -20,6 +20,7 @@ type Config struct {
Metrics MetricsConfig `mapstructure:"metrics"` Metrics MetricsConfig `mapstructure:"metrics"`
HealthCheck HealthCheckConfig `mapstructure:"health_check"` HealthCheck HealthCheckConfig `mapstructure:"health_check"`
Auth AuthConfig `mapstructure:"auth"` Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Retention NotificationRetentionConfig `mapstructure:"retention"` Retention NotificationRetentionConfig `mapstructure:"retention"`
ConfigFile string `mapstructure:"-"` // Path to config file used (not from config) 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) 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 // NotificationRetentionConfig contains notification retention and cleanup configuration
type NotificationRetentionConfig struct { type NotificationRetentionConfig struct {
Enabled bool `mapstructure:"enabled"` // Enable automatic cleanup 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.enabled", false) // Authentication disabled by default
v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute 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 // Retention defaults
v.SetDefault("retention.enabled", true) // Enable retention cleanup by default v.SetDefault("retention.enabled", true) // Enable retention cleanup by default
v.SetDefault("retention.ttl", "168h") // 7 days 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") 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 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) 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 return svc
} }