Skip to content

Commit abf649a

Browse files
Copilotintel352
andauthored
feat(cors): configurable allowed headers, credentials, maxAge, and wildcard subdomain origins (#299)
* Initial plan * feat: support configurable CORS headers, credentials, maxAge, and wildcard subdomain origins Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * fix: address CORS middleware review comments - Vary header, empty origin guard, port-aware wildcard, float64 maxAge, schema update Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> Co-authored-by: Jonathan Langevin <codingsloth@pm.me>
1 parent 006b368 commit abf649a

5 files changed

Lines changed: 462 additions & 29 deletions

File tree

e2e_middleware_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,144 @@ func TestE2E_Middleware_CORS(t *testing.T) {
416416
t.Log("E2E Middleware CORS: Allowed, disallowed, headers, and preflight scenarios verified")
417417
}
418418

419+
// TestE2E_Middleware_CORS_FullConfig verifies that the CORS middleware factory correctly
420+
// applies allowedHeaders, allowCredentials, maxAge, and wildcard subdomain origin matching.
421+
func TestE2E_Middleware_CORS_FullConfig(t *testing.T) {
422+
port := getFreePort(t)
423+
addr := fmt.Sprintf(":%d", port)
424+
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
425+
426+
cfg := &config.WorkflowConfig{
427+
Modules: []config.ModuleConfig{
428+
{Name: "fc-server", Type: "http.server", Config: map[string]any{"address": addr}},
429+
{Name: "fc-router", Type: "http.router", DependsOn: []string{"fc-server"}},
430+
{Name: "fc-handler", Type: "http.handler", DependsOn: []string{"fc-router"}, Config: map[string]any{"contentType": "application/json"}},
431+
{Name: "fc-cors", Type: "http.middleware.cors", DependsOn: []string{"fc-router"}, Config: map[string]any{
432+
"allowedOrigins": []any{"*.example.com", "https://trusted.io"},
433+
"allowedMethods": []any{"GET", "POST", "OPTIONS"},
434+
"allowedHeaders": []any{"Authorization", "Content-Type", "X-CSRF-Token", "X-Request-Id"},
435+
"allowCredentials": true,
436+
"maxAge": 3600,
437+
}},
438+
},
439+
Workflows: map[string]any{
440+
"http": map[string]any{
441+
"server": "fc-server",
442+
"router": "fc-router",
443+
"routes": []any{
444+
map[string]any{
445+
"method": "GET",
446+
"path": "/api/fc-test",
447+
"handler": "fc-handler",
448+
"middlewares": []any{"fc-cors"},
449+
},
450+
},
451+
},
452+
},
453+
Triggers: map[string]any{},
454+
}
455+
456+
logger := &mockLogger{}
457+
app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger)
458+
engine := NewStdEngine(app, logger)
459+
loadAllPlugins(t, engine)
460+
engine.RegisterWorkflowHandler(handlers.NewHTTPWorkflowHandler())
461+
462+
if err := engine.BuildFromConfig(cfg); err != nil {
463+
t.Fatalf("BuildFromConfig failed: %v", err)
464+
}
465+
466+
ctx := t.Context()
467+
if err := engine.Start(ctx); err != nil {
468+
t.Fatalf("Engine start failed: %v", err)
469+
}
470+
defer engine.Stop(context.Background())
471+
472+
waitForServer(t, baseURL, 5*time.Second)
473+
client := &http.Client{Timeout: 5 * time.Second}
474+
475+
// Subtest 1: Configurable allowedHeaders are reflected
476+
t.Run("configurable_headers", func(t *testing.T) {
477+
req, _ := http.NewRequest("GET", baseURL+"/api/fc-test", nil)
478+
req.Header.Set("Origin", "http://app.example.com")
479+
resp, err := client.Do(req)
480+
if err != nil {
481+
t.Fatalf("Request failed: %v", err)
482+
}
483+
defer resp.Body.Close()
484+
485+
acah := resp.Header.Get("Access-Control-Allow-Headers")
486+
want := "Authorization, Content-Type, X-CSRF-Token, X-Request-Id"
487+
if acah != want {
488+
t.Errorf("Expected Access-Control-Allow-Headers %q, got %q", want, acah)
489+
}
490+
})
491+
492+
// Subtest 2: allowCredentials sets the Credentials header
493+
t.Run("allow_credentials", func(t *testing.T) {
494+
req, _ := http.NewRequest("GET", baseURL+"/api/fc-test", nil)
495+
req.Header.Set("Origin", "https://trusted.io")
496+
resp, err := client.Do(req)
497+
if err != nil {
498+
t.Fatalf("Request failed: %v", err)
499+
}
500+
defer resp.Body.Close()
501+
502+
if resp.Header.Get("Access-Control-Allow-Credentials") != "true" {
503+
t.Errorf("Expected Access-Control-Allow-Credentials: true, got %q", resp.Header.Get("Access-Control-Allow-Credentials"))
504+
}
505+
})
506+
507+
// Subtest 3: maxAge is set on responses
508+
t.Run("max_age", func(t *testing.T) {
509+
req, _ := http.NewRequest("GET", baseURL+"/api/fc-test", nil)
510+
req.Header.Set("Origin", "https://trusted.io")
511+
resp, err := client.Do(req)
512+
if err != nil {
513+
t.Fatalf("Request failed: %v", err)
514+
}
515+
defer resp.Body.Close()
516+
517+
if resp.Header.Get("Access-Control-Max-Age") != "3600" {
518+
t.Errorf("Expected Access-Control-Max-Age: 3600, got %q", resp.Header.Get("Access-Control-Max-Age"))
519+
}
520+
})
521+
522+
// Subtest 4: Wildcard subdomain matching
523+
t.Run("wildcard_subdomain", func(t *testing.T) {
524+
req, _ := http.NewRequest("GET", baseURL+"/api/fc-test", nil)
525+
req.Header.Set("Origin", "http://admin.example.com")
526+
resp, err := client.Do(req)
527+
if err != nil {
528+
t.Fatalf("Request failed: %v", err)
529+
}
530+
defer resp.Body.Close()
531+
532+
acao := resp.Header.Get("Access-Control-Allow-Origin")
533+
if acao != "http://admin.example.com" {
534+
t.Errorf("Expected Access-Control-Allow-Origin 'http://admin.example.com', got %q", acao)
535+
}
536+
})
537+
538+
// Subtest 5: Wildcard subdomain does not match unrelated domains
539+
t.Run("wildcard_subdomain_no_match", func(t *testing.T) {
540+
req, _ := http.NewRequest("GET", baseURL+"/api/fc-test", nil)
541+
req.Header.Set("Origin", "http://evil.com")
542+
resp, err := client.Do(req)
543+
if err != nil {
544+
t.Fatalf("Request failed: %v", err)
545+
}
546+
defer resp.Body.Close()
547+
548+
acao := resp.Header.Get("Access-Control-Allow-Origin")
549+
if acao != "" {
550+
t.Errorf("Expected no Access-Control-Allow-Origin for disallowed domain, got %q", acao)
551+
}
552+
})
553+
554+
t.Log("E2E Middleware CORS FullConfig: all new features verified")
555+
}
556+
419557
// TestE2E_Middleware_RequestID verifies the RequestID middleware adds an
420558
// X-Request-ID header to every response, and preserves a client-supplied one.
421559
func TestE2E_Middleware_RequestID(t *testing.T) {

module/http_middleware.go

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"math"
77
"net"
88
"net/http"
9+
"net/url"
910
"strconv"
1011
"strings"
1112
"sync"
@@ -318,19 +319,59 @@ func (m *LoggingMiddleware) RequiresServices() []modular.ServiceDependency {
318319
return nil
319320
}
320321

322+
// CORSMiddlewareConfig holds configuration for the CORS middleware.
323+
type CORSMiddlewareConfig struct {
324+
// AllowedOrigins is the list of origins allowed to make cross-origin requests.
325+
// Use "*" to allow all origins. Supports wildcard subdomain patterns like "*.example.com".
326+
AllowedOrigins []string
327+
// AllowedMethods is the list of HTTP methods allowed in CORS requests.
328+
AllowedMethods []string
329+
// AllowedHeaders is the list of HTTP headers allowed in CORS requests.
330+
// Defaults to ["Content-Type", "Authorization"] when empty.
331+
AllowedHeaders []string
332+
// AllowCredentials indicates whether the request can include user credentials.
333+
// When true, the actual request Origin is reflected (never "*").
334+
AllowCredentials bool
335+
// MaxAge specifies how long (in seconds) the preflight response may be cached.
336+
// Zero means no caching directive is sent.
337+
MaxAge int
338+
}
339+
321340
// CORSMiddleware provides CORS support
322341
type CORSMiddleware struct {
323-
name string
324-
allowedOrigins []string
325-
allowedMethods []string
342+
name string
343+
allowedOrigins []string
344+
allowedMethods []string
345+
allowedHeaders []string
346+
allowCredentials bool
347+
maxAge int
326348
}
327349

328-
// NewCORSMiddleware creates a new CORS middleware
350+
// defaultCORSHeaders is the default set of allowed headers for backward compatibility.
351+
var defaultCORSHeaders = []string{"Content-Type", "Authorization"}
352+
353+
// NewCORSMiddleware creates a new CORS middleware with default allowed headers.
329354
func NewCORSMiddleware(name string, allowedOrigins, allowedMethods []string) *CORSMiddleware {
355+
return NewCORSMiddlewareWithConfig(name, CORSMiddlewareConfig{
356+
AllowedOrigins: allowedOrigins,
357+
AllowedMethods: allowedMethods,
358+
})
359+
}
360+
361+
// NewCORSMiddlewareWithConfig creates a new CORS middleware with full configuration.
362+
// If AllowedHeaders is empty, it defaults to ["Content-Type", "Authorization"].
363+
func NewCORSMiddlewareWithConfig(name string, cfg CORSMiddlewareConfig) *CORSMiddleware {
364+
headers := cfg.AllowedHeaders
365+
if len(headers) == 0 {
366+
headers = defaultCORSHeaders
367+
}
330368
return &CORSMiddleware{
331-
name: name,
332-
allowedOrigins: allowedOrigins,
333-
allowedMethods: allowedMethods,
369+
name: name,
370+
allowedOrigins: cfg.AllowedOrigins,
371+
allowedMethods: cfg.AllowedMethods,
372+
allowedHeaders: headers,
373+
allowCredentials: cfg.AllowCredentials,
374+
maxAge: cfg.MaxAge,
334375
}
335376
}
336377

@@ -344,24 +385,49 @@ func (m *CORSMiddleware) Init(app modular.Application) error {
344385
return nil
345386
}
346387

388+
// corsOriginAllowed checks if the given origin is in the allowed list.
389+
// It supports exact matching, "*" wildcard, and subdomain wildcards like "*.example.com".
390+
// Wildcard patterns are matched against the parsed hostname only, so ports are handled correctly:
391+
// "*.example.com" will match "http://sub.example.com:3000".
392+
func corsOriginAllowed(origin string, allowedOrigins []string) bool {
393+
if origin == "" {
394+
return false
395+
}
396+
for _, allowed := range allowedOrigins {
397+
if allowed == "*" || allowed == origin {
398+
return true
399+
}
400+
// Wildcard subdomain matching: "*.example.com" matches "sub.example.com" (any port).
401+
// Parse the request origin to extract just the hostname for comparison.
402+
if strings.HasPrefix(allowed, "*.") {
403+
suffix := allowed[1:] // ".example.com"
404+
u, err := url.Parse(origin)
405+
if err == nil && strings.HasSuffix(u.Hostname(), suffix) {
406+
return true
407+
}
408+
}
409+
}
410+
return false
411+
}
412+
347413
// Process implements middleware processing
348414
func (m *CORSMiddleware) Process(next http.Handler) http.Handler {
349415
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
350416
origin := r.Header.Get("Origin")
351417

352-
// Check if origin is allowed
353-
allowed := false
354-
for _, allowedOrigin := range m.allowedOrigins {
355-
if allowedOrigin == "*" || allowedOrigin == origin {
356-
allowed = true
357-
break
358-
}
359-
}
360-
361-
if allowed {
418+
// Only apply CORS headers when the request includes an Origin header.
419+
// Requests without Origin are not cross-origin requests and need no CORS response.
420+
if origin != "" && corsOriginAllowed(origin, m.allowedOrigins) {
421+
w.Header().Add("Vary", "Origin")
362422
w.Header().Set("Access-Control-Allow-Origin", origin)
363423
w.Header().Set("Access-Control-Allow-Methods", strings.Join(m.allowedMethods, ", "))
364-
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
424+
w.Header().Set("Access-Control-Allow-Headers", strings.Join(m.allowedHeaders, ", "))
425+
if m.allowCredentials {
426+
w.Header().Set("Access-Control-Allow-Credentials", "true")
427+
}
428+
if m.maxAge > 0 {
429+
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(m.maxAge))
430+
}
365431
}
366432

367433
// Handle preflight requests

0 commit comments

Comments
 (0)