Day 81 — CORS & Preflight
Learning Objectives
- Implement strict origin allowlist CORS (never wildcard for authenticated APIs)
- Handle OPTIONS preflight correctly with
Vary: Origin - Test CORS configuration with multiple allowed and disallowed origins
Key Concepts
Same-Origin Policy:
Browser blocks JS from reading cross-origin responses by default
Without CORS: attacker.com JS cannot read your API response even with credentials
CORS Mistakes (OWASP A05):
Access-Control-Allow-Origin: * with credentials → CRITICAL
Any site can make authenticated API calls using victim's cookies
Reflect Origin without allowlist validation:
if origin != "" { w.Header().Set("ACAO", origin) } → VULNERABLE
Vary: Origin missing → CDN caches wrong origin, serves to all
Correct CORS for authenticated APIs:
Explicit allowlist (no wildcards)
Vary: Origin (required for CDN correctness)
Access-Control-Allow-Credentials: true only for specific trusted origins
Exercise: Strict CORS Middleware
package cors
import (
"fmt"
"net/http"
"strings"
)
type Config struct {
AllowedOrigins []string // explicit list, no wildcards
AllowedMethods []string
AllowedHeaders []string
ExposedHeaders []string
AllowCredentials bool
MaxAge int
}
type CORS struct {
cfg Config
allowedOrigins map[string]bool
}
func New(cfg Config) *CORS {
c := &CORS{
cfg: cfg,
allowedOrigins: make(map[string]bool),
}
for _, o := range cfg.AllowedOrigins {
c.allowedOrigins[o] = true
}
return c
}
func (c *CORS) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Vary: Origin must ALWAYS be set when CORS headers might be present
// Without it, CDN caches response for one origin and serves to all others
w.Header().Add("Vary", "Origin")
if origin == "" {
// Not a CORS request — serve normally
next.ServeHTTP(w, r)
return
}
// Validate origin against allowlist — never reflect without validation
if !c.allowedOrigins[origin] {
// Don't set ACAO header — browser will block the response
fmt.Printf("[CORS] blocked origin: %q\n", origin)
next.ServeHTTP(w, r)
return
}
// Set CORS headers for allowed origin
w.Header().Set("Access-Control-Allow-Origin", origin) // never wildcard
if c.cfg.AllowCredentials {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
if len(c.cfg.ExposedHeaders) > 0 {
w.Header().Set("Access-Control-Expose-Headers", strings.Join(c.cfg.ExposedHeaders, ", "))
}
// Handle preflight OPTIONS
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", strings.Join(c.cfg.AllowedMethods, ", "))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(c.cfg.AllowedHeaders, ", "))
if c.cfg.MaxAge > 0 {
w.Header().Set("Access-Control-Max-Age", fmt.Sprintf("%d", c.cfg.MaxAge))
}
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
cors := New(Config{
AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
ExposedHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 3600,
})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"ok":true}`)
})
fmt.Println("Testing CORS:")
fmt.Println(" Allowed: curl -H 'Origin: https://app.example.com' http://localhost:8080/")
fmt.Println(" Blocked: curl -H 'Origin: https://evil.com' http://localhost:8080/")
http.ListenAndServe(":8080", cors.Middleware(handler))
}
Test:
# Should include ACAO header (allowed):
curl -s -I -H "Origin: https://app.example.com" http://localhost:8080/
# Access-Control-Allow-Origin: https://app.example.com
# Should NOT include ACAO header (blocked):
curl -s -I -H "Origin: https://evil.com" http://localhost:8080/
# (no Access-Control-Allow-Origin header)
Real-World Context
The CORS wildcard vulnerability (Access-Control-Allow-Origin: * with credentials) allows any website to make authenticated API calls using the victim's session. In 2018, this class of misconfiguration affected dozens of major platforms. The fix is 3 lines: use an allowlist, never reflect without validation, always set Vary: Origin.