Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Vulnerability Research

gin-contrib/cors Wildcard & Origin Bypass: Complete Fix Guide

Fix gin-contrib/cors wildcard misconfigurations: AllowAllOrigins with credentials, AllowOriginFunc substring bugs, and safe Go CORS configs with exact allowlists.

Offensive360 Security Research Team — min read
gin-contrib/cors cors golang go cors cors wildcard gin cors fix cors wildcard cors misconfiguration CORS Access-Control-Allow-Origin web security API security CWE-942 cors off-by-one cors origin bypass

The gin-contrib/cors middleware is the de-facto CORS solution for Go applications built on the Gin framework. But its default configuration and several common patterns introduce CORS misconfigurations ranging from wildcard policies on credentialed APIs to subtle origin-allowlist bypass bugs. This guide covers every gin-contrib/cors vulnerability pattern, exactly how each one is exploited, and the correct configuration for each scenario.


Quick Fix Reference

If you just need the answer:

// SECURE gin-contrib/cors configuration for authenticated APIs
import "github.com/gin-contrib/cors"

r := gin.Default()
r.Use(cors.New(cors.Config{
    AllowOrigins: []string{
        "https://app.yourcompany.com",
        "https://yourcompany.com",
    },
    AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
    AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
    AllowCredentials: true,
    MaxAge:           86400,
}))

Never use AllowAllOrigins: true or AllowOrigins: []string{"*"} with AllowCredentials: true. Scroll down for a full explanation of why — and what to do if you need dynamic origin matching.


Understanding gin-contrib/cors Configuration Fields

Before fixing anything, it helps to understand what each configuration field actually does:

FieldTypeWhat It Controls
AllowAllOriginsboolIf true, sets Access-Control-Allow-Origin: *
AllowOrigins[]stringExact origin strings to allow; "*" in this slice = AllowAllOrigins behavior
AllowOriginFuncfunc(string) boolCustom function called with the request’s Origin; return true to allow
AllowCredentialsboolSets Access-Control-Allow-Credentials: true
AllowMethods[]stringMethods listed in the preflight response
AllowHeaders[]stringRequest headers the browser may send
MaxAgeintSeconds the preflight response may be cached

The critical constraint from the CORS spec: Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true cannot be combined. Browsers block credentialed cross-origin requests when the origin is *. Any configuration that tries to do both is broken by design — and the attempted “fix” often introduces a more dangerous vulnerability.


Vulnerability Pattern 1: AllowAllOrigins with AllowCredentials

This is the most common gin-contrib/cors misconfiguration.

Vulnerable Code

// VULNERABLE — browsers block this, but developers then "fix" it incorrectly
r.Use(cors.New(cors.Config{
    AllowAllOrigins:  true,
    AllowCredentials: true, // ← Browsers reject "Origin: *" + credentials
    AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
}))

When this configuration is deployed:

  1. Browsers receive Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true
  2. The CORS spec requires browsers to reject this combination
  3. The frontend gets a CORS error
  4. A developer “fixes” it by switching to origin reflection

The Dangerous “Fix” That Follows

// DANGEROUS FIX — reflects any origin unconditionally
r.Use(cors.New(cors.Config{
    AllowOriginFunc: func(origin string) bool {
        return true // ← Reflects any origin back — functionally "AllowAllOrigins" with credentials
    },
    AllowCredentials: true,
}))

When AllowOriginFunc returns true, gin-contrib/cors reflects the request’s Origin value back in the Access-Control-Allow-Origin response header. With AllowCredentials: true, this allows any website to make credentialed cross-origin requests to your API and read the responses — a critical CORS misconfiguration.

The Correct Fix

Use a fixed allowlist. If you genuinely need to allow all origins for a public unauthenticated API, use AllowAllOrigins: true without AllowCredentials: true:

// For public, unauthenticated APIs — wildcard is acceptable
r.Use(cors.New(cors.Config{
    AllowAllOrigins: true,
    // No AllowCredentials — wildcard + credentials is invalid
    AllowMethods: []string{"GET", "OPTIONS"},
    AllowHeaders: []string{"Content-Type"},
    MaxAge:       3600,
}))

For authenticated APIs, use an explicit allowlist:

// For authenticated APIs — explicit allowlist required
r.Use(cors.New(cors.Config{
    AllowOrigins: []string{
        "https://app.yourcompany.com",
        "https://yourcompany.com",
    },
    AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
    AllowHeaders:     []string{"Origin", "Content-Type", "Authorization", "X-API-Key"},
    AllowCredentials: true,
    MaxAge:           86400,
}))

Vulnerability Pattern 2: AllowOriginFunc with Substring Check

This is the gin-contrib/cors “off-by-one” pattern: the developer implements a custom AllowOriginFunc with a string check that is almost correct but contains a boundary error.

Vulnerable Code

// VULNERABLE — strings.Contains allows "evil-yourcompany.com"
r.Use(cors.New(cors.Config{
    AllowOriginFunc: func(origin string) bool {
        return strings.Contains(origin, "yourcompany.com")
        // ✓ https://yourcompany.com  (passes)
        // ✓ https://app.yourcompany.com  (passes)
        // ✗ https://evil-yourcompany.com  (ALSO passes — bypass!)
        // ✗ https://yourcompany.com.attacker.io  (ALSO passes — bypass!)
    },
    AllowCredentials: true,
}))

The strings.Contains check is off by one domain boundary: it checks whether yourcompany.com appears anywhere in the origin string, but doesn’t verify it appears at the right position (as the full hostname or a proper subdomain).

An attacker who registers evil-yourcompany.com or yourcompany.com.attacker.io passes this check.

Another Common Substring Variant

// ALSO VULNERABLE — HasPrefix doesn't check the right boundary
r.Use(cors.New(cors.Config{
    AllowOriginFunc: func(origin string) bool {
        return strings.HasPrefix(origin, "https://yourcompany.com")
        // ✓ https://yourcompany.com  (passes)
        // ✓ https://yourcompany.com/... (passes, but origins don't have paths)
        // ✗ https://yourcompany.com.attacker.io  (PASSES — bypass!)
    },
    AllowCredentials: true,
}))

HasPrefix is also wrong because yourcompany.com.attacker.io starts with yourcompany.com (as a subdomain).

The Correct AllowOriginFunc

Fix the boundary check by parsing the URL and using strings.HasSuffix with a leading dot, which enforces the domain boundary:

import (
    "net/url"
    "strings"
    "github.com/gin-contrib/cors"
)

func makeAllowOriginFunc(allowedDomains []string) func(string) bool {
    // Build a set for O(1) exact lookup
    allowedSet := make(map[string]struct{}, len(allowedDomains))
    for _, d := range allowedDomains {
        allowedSet[d] = struct{}{}
    }

    return func(origin string) bool {
        if origin == "" {
            return false
        }

        // Fast path: exact match
        if _, ok := allowedSet[origin]; ok {
            return true
        }

        // Subdomain match with proper dot-boundary check
        parsed, err := url.Parse(origin)
        if err != nil || parsed.Scheme != "https" {
            return false
        }

        host := parsed.Hostname()
        for domain := range allowedSet {
            // Parse the allowed domain to extract just the hostname
            allowedParsed, err := url.Parse(domain)
            if err != nil {
                continue
            }
            allowedHost := allowedParsed.Hostname()

            // HasSuffix with a leading dot ensures:
            //   app.yourcompany.com → passes (ends with ".yourcompany.com")
            //   evil-yourcompany.com → REJECTED (doesn't end with ".yourcompany.com")
            if strings.HasSuffix(host, "."+allowedHost) {
                return true
            }
        }

        return false
    }
}

// Usage
r.Use(cors.New(cors.Config{
    AllowOriginFunc: makeAllowOriginFunc([]string{
        "https://yourcompany.com",
        "https://app.yourcompany.com",
    }),
    AllowCredentials: true,
    AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
    AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
    MaxAge:           86400,
}))

Test the fix:

// Unit tests for the allowlist function
func TestAllowOriginFunc(t *testing.T) {
    fn := makeAllowOriginFunc([]string{"https://yourcompany.com"})

    cases := []struct {
        origin string
        want   bool
    }{
        {"https://yourcompany.com", true},           // Exact match
        {"https://app.yourcompany.com", true},        // Subdomain
        {"https://evil-yourcompany.com", false},      // Suffix bypass attempt
        {"https://yourcompany.com.attacker.io", false}, // Subdomain bypass attempt
        {"http://yourcompany.com", false},            // HTTP not HTTPS
        {"https://notyourcompany.com", false},        // Different domain
        {"", false},                                   // Empty origin
        {"null", false},                              // Null origin (sandboxed iframe)
    }

    for _, tc := range cases {
        got := fn(tc.origin)
        if got != tc.want {
            t.Errorf("AllowOriginFunc(%q) = %v, want %v", tc.origin, got, tc.want)
        }
    }
}

Vulnerability Pattern 3: AllowOrigins with "*" and AllowCredentials

Placing "*" in the AllowOrigins slice is equivalent to setting AllowAllOrigins: true. Combined with AllowCredentials: true, this is the invalid wildcard+credentials combination:

// VULNERABLE — "*" in AllowOrigins slice behaves like AllowAllOrigins
r.Use(cors.New(cors.Config{
    AllowOrigins:     []string{"*"},          // ← Same as AllowAllOrigins: true
    AllowCredentials: true,                   // ← Browsers reject this combination
}))

The gin-contrib/cors library treats a "*" entry in AllowOrigins as a wildcard, not as a literal origin string. The fix is to replace "*" with explicit origin strings.


Vulnerability Pattern 4: Regex-Based AllowOriginFunc Without Anchors

Some teams implement regex matching in AllowOriginFunc for flexibility. Unanchored regexes have the same boundary-check problem as substring matching:

import "regexp"

// VULNERABLE — unanchored regex matches anywhere in the string
var allowedPattern = regexp.MustCompile(`yourcompany\.com`)

r.Use(cors.New(cors.Config{
    AllowOriginFunc: func(origin string) bool {
        return allowedPattern.MatchString(origin)
        // regexp.MatchString finds the pattern anywhere — not full-string match
        // https://yourcompany.com.attacker.io → PASSES
        // https://notyourcompany.com → PASSES
    },
    AllowCredentials: true,
}))

The Fix: Anchored Regex

Use ^ (start) and $ (end) anchors to require a full-string match:

// SECURE — anchored regex with full-string match
var allowedPattern = regexp.MustCompile(
    `^https://([a-zA-Z0-9][a-zA-Z0-9\-]*\.)?yourcompany\.com$`,
)

r.Use(cors.New(cors.Config{
    AllowOriginFunc: func(origin string) bool {
        if origin == "" {
            return false
        }
        return allowedPattern.MatchString(origin)
        // https://yourcompany.com → ✓
        // https://app.yourcompany.com → ✓
        // https://evil-yourcompany.com → ✗ (no dot before yourcompany)
        // https://yourcompany.com.attacker.io → ✗ (anchored at end)
    },
    AllowCredentials: true,
}))

Note: Go’s regexp.MatchString (and regexp.Regexp.MatchString) finds the pattern anywhere in the string unless anchors are used. This is equivalent to Python’s re.search(). Always use ^ and $ anchors for CORS origin validation.


Vulnerability Pattern 5: Missing Vary: Origin Header

When you use AllowOriginFunc or a dynamic allowlist, gin-contrib/cors automatically adds the Vary: Origin header. However, if you implement custom CORS middleware alongside gin-contrib/cors, or if you override headers manually, you may strip this header.

Why Vary: Origin Matters

When your CORS response includes a specific allowed origin (Access-Control-Allow-Origin: https://app.yourcompany.com), and a CDN or reverse proxy caches that response, the cache might serve the same response to a different origin — with the wrong Access-Control-Allow-Origin value.

Vary: Origin instructs CDNs and proxies to cache CORS responses separately per Origin header value, preventing this cross-origin cache poisoning.

// gin-contrib/cors adds Vary: Origin automatically when using AllowOriginFunc
// or AllowOrigins with a specific list (not wildcard)

// If you add custom middleware that sets CORS headers manually, always include:
c.Header("Vary", "Origin")

Complete Secure gin-contrib/cors Configuration

For a Typical Authenticated REST API

package main

import (
    "os"
    "strings"

    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
)

func corsMiddleware() gin.HandlerFunc {
    // Read allowed origins from environment for deployment flexibility
    rawOrigins := os.Getenv("CORS_ALLOWED_ORIGINS")
    // e.g., CORS_ALLOWED_ORIGINS=https://app.yourcompany.com,https://yourcompany.com

    var allowedOrigins []string
    if rawOrigins != "" {
        for _, o := range strings.Split(rawOrigins, ",") {
            trimmed := strings.TrimSpace(o)
            if trimmed != "" {
                allowedOrigins = append(allowedOrigins, trimmed)
            }
        }
    }

    // Fallback for local development
    if len(allowedOrigins) == 0 {
        allowedOrigins = []string{"http://localhost:3000", "http://localhost:5173"}
    }

    return cors.New(cors.Config{
        AllowOrigins:     allowedOrigins,
        AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
        AllowHeaders:     []string{"Origin", "Content-Type", "Authorization", "X-API-Key", "X-Request-ID"},
        ExposeHeaders:    []string{"Content-Length", "X-Request-ID"},
        AllowCredentials: true,
        MaxAge:           86400, // Cache preflight responses for 24 hours
    })
}

func main() {
    r := gin.Default()
    r.Use(corsMiddleware())

    r.GET("/api/health", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "ok"})
    })

    r.Run(":8080")
}

For a Public API (No Authentication)

func publicAPICORS() gin.HandlerFunc {
    return cors.New(cors.Config{
        AllowAllOrigins: true, // Wildcard — safe for public unauthenticated data
        AllowMethods:    []string{"GET", "OPTIONS"},
        AllowHeaders:    []string{"Content-Type", "Accept"},
        // No AllowCredentials — never combine with AllowAllOrigins
        MaxAge:          3600,
    })
}

For a Multi-Tenant SaaS (Dynamic Subdomain Allowlist)

import (
    "net/url"
    "strings"
    "github.com/gin-contrib/cors"
)

// Allow any subdomain of yourplatform.com plus explicit tenant origins
func multiTenantCORSMiddleware() gin.HandlerFunc {
    // Hardcoded base domain — no substring bugs
    const baseDomain = "yourplatform.com"

    // Explicit additional origins (e.g., custom domains mapped by tenants)
    additionalOrigins := map[string]struct{}{
        "https://client1.com":       {},
        "https://app.client2.io":    {},
    }

    return cors.New(cors.Config{
        AllowOriginFunc: func(origin string) bool {
            if origin == "" {
                return false
            }

            parsed, err := url.Parse(origin)
            if err != nil || parsed.Scheme != "https" {
                return false
            }

            host := parsed.Hostname()

            // Allow exact base domain
            if host == baseDomain {
                return true
            }

            // Allow subdomains with dot-boundary enforcement
            if strings.HasSuffix(host, "."+baseDomain) {
                return true
            }

            // Allow additional explicitly-listed origins
            if _, ok := additionalOrigins[origin]; ok {
                return true
            }

            return false
        },
        AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
        AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
        AllowCredentials: true,
        MaxAge:           3600,
    })
}

Testing gin-contrib/cors Configurations

Use these curl commands to verify your CORS middleware behaves correctly:

# Test 1: Legitimate origin — should receive CORS headers
curl -si \
  -H "Origin: https://app.yourcompany.com" \
  -H "Access-Control-Request-Method: GET" \
  -X OPTIONS \
  http://localhost:8080/api/user \
  | grep -i "access-control"
# Expected:
#   Access-Control-Allow-Origin: https://app.yourcompany.com
#   Access-Control-Allow-Credentials: true
#   Vary: Origin

# Test 2: Suffix bypass attempt — should be REJECTED
curl -si \
  -H "Origin: https://evil-yourcompany.com" \
  http://localhost:8080/api/user \
  | grep -i "access-control"
# Expected: no Access-Control-Allow-Origin header

# Test 3: Subdomain of attacker's domain — should be REJECTED
curl -si \
  -H "Origin: https://yourcompany.com.attacker.io" \
  http://localhost:8080/api/user \
  | grep -i "access-control"
# Expected: no Access-Control-Allow-Origin header

# Test 4: Null origin (sandboxed iframe) — should be REJECTED
curl -si \
  -H "Origin: null" \
  http://localhost:8080/api/user \
  | grep -i "access-control"
# Expected: no Access-Control-Allow-Origin header

# Test 5: HTTP instead of HTTPS — should be REJECTED (in production)
curl -si \
  -H "Origin: http://yourcompany.com" \
  http://localhost:8080/api/user \
  | grep -i "access-control"
# Expected: no Access-Control-Allow-Origin header (HTTPS only policy)

All five tests should produce the expected results. If test 2 or 3 returns Access-Control-Allow-Origin headers, your AllowOriginFunc has a boundary-check bug.


gin-contrib/cors Version Notes

The middleware’s behavior has evolved across versions. Key changes:

  • v1.7.0+: AllowOriginFunc is the recommended approach for dynamic origin validation; AllowOrigins list is processed with exact matching only (no glob support)
  • v1.6.0+: AllowPrivateNetwork field added for Private Network Access (PNA) headers
  • All versions: AllowAllOrigins: true with AllowCredentials: true produces a misconfigured response that browsers reject; the middleware does not error or warn

Always pin your dependency: github.com/gin-contrib/cors v1.7.x and check the changelog when updating.


How SAST Tools Detect gin-contrib/cors Misconfigurations

Static analysis tools that understand Go’s type system can detect these patterns:

  • AllowAllOrigins: true + AllowCredentials: true: a trivially detectable struct literal pattern
  • AllowOriginFunc returning true unconditionally: data-flow analysis showing the function always returns true
  • strings.Contains(origin, ...) in AllowOriginFunc: interprocedural analysis flagging substring-based origin checks in CORS validation context

Offensive360 SAST detects CORS misconfiguration patterns in Go, including gin-contrib/cors struct literals and custom AllowOriginFunc implementations, mapping findings to the CORSAllowOriginWildcard and CORSReflectedOrigin rules in the Knowledge Base.


Summary

PatternRiskFix
AllowAllOrigins: true + AllowCredentials: trueCritical — browsers block, devs then introduce reflected-originUse explicit AllowOrigins list
AllowOriginFunc always returns trueCritical — any origin allowed with credentialsReturn false for non-allowlisted origins
strings.Contains(origin, "domain.com")High — domain suffix bypassUse strings.HasSuffix(host, ".domain.com") with URL parsing
strings.HasPrefix(origin, "https://domain.com")High — domain.com.evil.io bypassUse exact match + HasSuffix with dot
Unanchored regex domain\.comHigh — matches anywhere in origin stringUse ^https://...domain\.com$ anchors
AllowOrigins: []string{"*"} + AllowCredentialsCritical — same as AllowAllOriginsReplace "*" with explicit origins

The safest gin-contrib/cors configuration is always the simplest: a static AllowOrigins slice containing the exact origin strings you want to allow.


Offensive360 SAST detects CORS misconfigurations in Go, including gin-contrib/cors wildcard policies and flawed AllowOriginFunc implementations. Scan your Go API code or browse the CORS vulnerability reference for full remediation details.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.