cors icon indicating copy to clipboard operation
cors copied to clipboard

With some CORS configurations, some handlers can introduce synchronisation bugs and cause data races

Open jub0bs opened this issue 1 month ago • 9 comments

Problem

Presumably for performance, the library (v1.11.1 and some older versions) reuses some non-exported slice variables and struct field from one middleware call to the next:

  • package-level var headerOriginAll,
  • package-level var headerTrue,
  • package-level var headerVaryOrigin, and
  • struct field Cors.preflightVary.

Unfortunately, with some CORS middleware, some handlers that they wrap can get a hold of aliases of those slices and mutate their backing array. Because middleware are typically invoked concurrently and such mutation doesn't involve any synchronisation, data races can occur. Therefore, those slices are a bit of a footgun.

Proof of concept

  1. Clone the repo and save the following code snippet to some test file (perhaps named mutability_test.go):

    package cors
    
    import (
      "net/http"
      "net/http/httptest"
      "slices"
      "sync"
      "testing"
    )
    
    var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      for _, k := range keys {
        vv := w.Header()[k]
        if len(vv) > 0 {
          vv[0] = "oops!"
        }
      }
    })
    
    var keys = []string{
      "Access-Control-Allow-Credentials",
      "Access-Control-Allow-Origin",
      "Vary",
    }
    
    func TestMutabilityFromHandlerWithPrelightRequest(t *testing.T) {
      // Arrange
      c := New(Options{
        AllowedOrigins:     []string{"*"},
        AllowCredentials:   true,
        AllowedMethods:     []string{http.MethodPut},
        OptionsPassthrough: true,
      })
      var ( // save the initial values of mutable variables for later assertions
        headerOriginAllWant = slices.Clone(headerOriginAll)
        headerTrueWant      = slices.Clone(headerTrue)
        cPreflightVaryWant  = slices.Clone(c.preflightVary)
      )
      req := httptest.NewRequest(http.MethodOptions, "https://example.org", nil)
      req.Header.Add("Origin", "https://example.com")
      req.Header.Add("Access-Control-Request-Method", http.MethodPut)
      rec := httptest.NewRecorder()
    
      // Act
      c.Handler(handler).ServeHTTP(rec, req)
    
      // Assert
      const tmpl = "%s: got %q; want %q"
      if !slices.Equal(c.preflightVary, cPreflightVaryWant) {
        t.Errorf(tmpl, "c.preflightVary", c.preflightVary, cPreflightVaryWant)
      }
      if !slices.Equal(headerOriginAll, headerOriginAllWant) {
        t.Errorf(tmpl, "headerOriginAll", headerOriginAll, headerOriginAllWant)
      }
      if !slices.Equal(headerTrue, headerTrueWant) {
        t.Errorf(tmpl, "headerTrue", headerTrue, headerTrueWant)
      }
    
      // Clean up : restore slices to their initial value
      headerOriginAll = headerOriginAllWant
      headerTrue = headerTrueWant
      c.preflightVary = cPreflightVaryWant
    }
    
    func TestMutabilityFromHandlerWithActualRequest(t *testing.T) {
      // Arrange
      var c = New(Options{
        AllowedOrigins:   []string{"*"},
        AllowCredentials: true,
      })
      var ( // save the initial values of mutable variables for later assertions
        headerOriginAllWant  = slices.Clone(headerOriginAll)
        headerTrueWant       = slices.Clone(headerTrue)
        headerVaryOriginWant = slices.Clone(headerVaryOrigin)
      )
      req := httptest.NewRequest(http.MethodGet, "https://example.org", nil)
      req.Header.Add("Origin", "https://example.com")
      rec := httptest.NewRecorder()
    
      // Act
      c.Handler(handler).ServeHTTP(rec, req)
    
      // Assert
      const tmpl = "%s: got %q; want %q"
      if !slices.Equal(headerVaryOrigin, headerVaryOriginWant) {
        t.Errorf(tmpl, "headerVaryOrigin", headerVaryOrigin, headerVaryOriginWant)
      }
      if !slices.Equal(headerOriginAll, headerOriginAllWant) {
        t.Errorf(tmpl, "headerOriginAll", headerOriginAll, headerOriginAllWant)
      }
      if !slices.Equal(headerTrue, headerTrueWant) {
        t.Errorf(tmpl, "headerTrue", headerTrue, headerTrueWant)
      }
    
      // Clean up: restore slices to their initial value
      headerOriginAll = headerOriginAllWant
      headerTrue = headerTrueWant
      headerVaryOrigin = headerVaryOriginWant
    }
    
    // Note: run this test with -race
    func TestSynchronizationBugWithPrelightRequest(t *testing.T) {
      testSynchronizationBug(t, true)
    }
    
    // Note: run this test with -race
    func TestSynchronizationBugWithActualRequest(t *testing.T) {
      testSynchronizationBug(t, false)
    }
    
    func testSynchronizationBug(t *testing.T, preflight bool) {
      t.Helper()
      c := New(Options{
        AllowedOrigins:     []string{"*"},
        AllowCredentials:   true,
        AllowedMethods:     []string{http.MethodPut},
        OptionsPassthrough: true,
      })
      var req *http.Request
      if preflight {
        req = httptest.NewRequest(http.MethodOptions, "https://example.org", nil)
        req.Header.Add("Access-Control-Request-Method", http.MethodPut)
      } else {
        req = httptest.NewRequest(http.MethodGet, "https://example.org", nil)
      }
      req.Header.Add("Origin", "https://example.com")
    
      // simulate concurrent requests
      const n = 128
      var wg sync.WaitGroup
      wg.Add(n)
      for range n {
        go func() {
          defer wg.Done()
          rec := httptest.NewRecorder()
          c.Handler(handler).ServeHTTP(rec, req)
        }()
      }
      wg.Wait()
    }
    
  2. Run the following command and observe that the two tests fail, proving that the slices in question can indeed be mutated by a handler, at least with some CORS configs:

    $ go test -run '^TestMutabilityFromHandlerW'
    --- FAIL: TestMutabilityFromHandlerWithPrelightRequest (0.00s)
        mutability_test.go:50: c.preflightVary: got ["oops!"]; want ["Origin, Access-Control-Request-Method, Access-Control-Request-Headers"]
        mutability_test.go:53: headerOriginAll: got ["oops!"]; want ["*"]
        mutability_test.go:56: headerTrue: got ["oops!"]; want ["true"]
    --- FAIL: TestMutabilityFromHandlerWithActualRequest (0.00s)
        mutability_test.go:86: headerVaryOrigin: got ["oops!"]; want ["Origin"]
        mutability_test.go:89: headerOriginAll: got ["oops!"]; want ["*"]
        mutability_test.go:92: headerTrue: got ["oops!"]; want ["true"]
    
  3. Run the following command and observe that the two tests fail, proving that data races can occur as a result of such unsynchronised mutation:

    go test -race -run '^TestSynchronizationBug'
    ==================
    WARNING: DATA RACE
    -snip-
    ==================
    --- FAIL: TestSynchronizationBugWithPrelightRequest (0.00s)
        testing.go:1617: race detected during execution of test
    ==================
    WARNING: DATA RACE
    -snip-
    ==================
    --- FAIL: TestSynchronizationBugWithActualRequest (0.00s)
        testing.go:1617: race detected during execution of test
    FAIL
    exit status 1
    FAIL    github.com/rs/cors      0.538s
    

Remediation

Do not share slices between from one call to the next. Here is a possible patch that I'd be happy to contribute:

diff --git a/cors.go b/cors.go
index 724f242..46a4171 100644
--- a/cors.go
+++ b/cors.go
@@ -30,10 +30,6 @@ import (
        "github.com/rs/cors/internal"
 )
 
-var headerVaryOrigin = []string{"Origin"}
-var headerOriginAll = []string{"*"}
-var headerTrue = []string{"true"}
-
 // Options is a configuration container to setup the CORS middleware.
 type Options struct {
        // AllowedOrigins is a list of origins a cross-domain request can be executed from.
@@ -133,7 +129,7 @@ type Cors struct {
        allowCredentials     bool
        allowPrivateNetwork  bool
        optionPassthrough    bool
-       preflightVary        []string
+       preflightVary        string
 }
 
 // New creates a new Cors handler with the provided options.
@@ -229,9 +225,9 @@ func New(options Options) *Cors {
 
        // Pre-compute prefight Vary header to save allocations
        if c.allowPrivateNetwork {
-               c.preflightVary = []string{"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network"}
+               c.preflightVary = "Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network"
        } else {
-               c.preflightVary = []string{"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"}
+               c.preflightVary = "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
        }
 
        // Precompute max-age
@@ -337,11 +333,7 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
        // Always set Vary headers
        // see https://github.com/rs/cors/issues/10,
        //     https://github.com/rs/cors/commit/dbdca4d95feaa7511a46e6f1efb3b3aa505bc43f#commitcomment-12352001
-       if vary, found := headers["Vary"]; found {
-               headers["Vary"] = append(vary, c.preflightVary[0])
-       } else {
-               headers["Vary"] = c.preflightVary
-       }
+       headers.Add("Vary", c.preflightVary)
        allowed, additionalVaryHeaders := c.isOriginAllowed(r, origin)
        if len(additionalVaryHeaders) > 0 {
                headers.Add("Vary", strings.Join(convert(additionalVaryHeaders, http.CanonicalHeaderKey), ", "))
@@ -372,7 +364,7 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
                return
        }
        if c.allowedOriginsAll {
-               headers["Access-Control-Allow-Origin"] = headerOriginAll
+               headers.Set("Access-Control-Allow-Origin", "*")
        } else {
                headers["Access-Control-Allow-Origin"] = r.Header["Origin"]
        }
@@ -385,10 +377,10 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
                headers["Access-Control-Allow-Headers"] = reqHeaders
        }
        if c.allowCredentials {
-               headers["Access-Control-Allow-Credentials"] = headerTrue
+               headers.Set("Access-Control-Allow-Credentials", "true")
        }
        if c.allowPrivateNetwork && r.Header.Get("Access-Control-Request-Private-Network") == "true" {
-               headers["Access-Control-Allow-Private-Network"] = headerTrue
+               headers.Set("Access-Control-Allow-Private-Network", "true")
        }
        if len(c.maxAge) > 0 {
                headers["Access-Control-Max-Age"] = c.maxAge
@@ -404,11 +396,7 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
        allowed, additionalVaryHeaders := c.isOriginAllowed(r, origin)
 
        // Always set Vary, see https://github.com/rs/cors/issues/10
-       if vary := headers["Vary"]; vary == nil {
-               headers["Vary"] = headerVaryOrigin
-       } else {
-               headers["Vary"] = append(vary, headerVaryOrigin[0])
-       }
+       headers.Add("Vary", "Origin")
        if len(additionalVaryHeaders) > 0 {
                headers.Add("Vary", strings.Join(convert(additionalVaryHeaders, http.CanonicalHeaderKey), ", "))
        }
@@ -430,7 +418,7 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
                return
        }
        if c.allowedOriginsAll {
-               headers["Access-Control-Allow-Origin"] = headerOriginAll
+               headers.Set("Access-Control-Allow-Origin", "*")
        } else {
                headers["Access-Control-Allow-Origin"] = r.Header["Origin"]
        }
@@ -438,7 +426,7 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
                headers["Access-Control-Expose-Headers"] = c.exposedHeaders
        }
        if c.allowCredentials {
-               headers["Access-Control-Allow-Credentials"] = headerTrue
+               headers.Set("Access-Control-Allow-Credentials", "true")
        }
        c.logf("  Actual response added headers: %v", headers)
 }

jub0bs avatar Nov 21 '25 19:11 jub0bs

Another observation: if option OptionsPassthrough didn't exist (and my opinion is that it shouldn't), the library could rely on precomputed slices for writing headers in responses to preflight requests: there indeed would be no way for wrapped handlers to get a hold of those slices (and mutate them).

But that ship has sailed; perhaps that option should be dropped in a hypothetical v2, and deprecated until then.

jub0bs avatar Nov 30 '25 10:11 jub0bs

Do not share slices between from one call to the next. Here is a possible patch that I'd be happy to contribute:

OK, but changes you made in this patch just hide the fact that internally headers.Set then allocate a slice. So it is the same as doing always slices.Clone before setting the header ourselves?

mitar avatar Nov 30 '25 11:11 mitar

@mitar I believe that relying on slices.Clone would have the same semantics as my patch's. And, without having run any benchmarks, I believe the performance would be similar (if not identical) to my patch's.

Wouldn't you agree that setting the header is more idiomatic code than the alternative you suggest, though? What are you concerned about? Code churn?

jub0bs avatar Nov 30 '25 11:11 jub0bs

No, I just wanted to make sure if I am missing something. I think using standard methods like .Set is reasonable.

mitar avatar Nov 30 '25 12:11 mitar

@jub0bs is this based on the assumption it can happen or is it based on real cases where some middleware are actually doing that? I can’t see a valid use case where a middleware would blindly change the first element of a CORS header value this way instead of calling Set. It already smells by itself like a bug.

rs avatar Nov 30 '25 14:11 rs

@rs

is this based on the assumption it can happen or is it based on real cases where some middleware are actually doing that?

I have not found instances of this in the wild, but the fact that it could easily happen is in itself regrettable.

I can’t see a valid use case where a middleware would blindly change the first element of a CORS header value this way instead of calling Set. It already smells by itself like a bug.

Authors of handlers wrapped in a CORS middleware may assume that their http.ResponseWriter.Header() is fair game for any kind of modifications. This middleware library (or any other) shouldn't forbid them to carry out such modifications.

jub0bs avatar Nov 30 '25 14:11 jub0bs

The API to change headers is Set and Add. When you access the header slices directly, you need to know what you’re doing. Since we never had a single report of an issue with the current design, I’m willing to keep it this way until we get a real world issue.

rs avatar Nov 30 '25 14:11 rs

@rs I still think that's a wart worth fixing but, as the owner of this repo, you have the last word. Feel free to close this issue.

jub0bs avatar Nov 30 '25 14:11 jub0bs

FWIW, Russ Cox's stance (which, granted, may well differ from yours) about exposing pointers to memory intended as read-only in user code is: don't.

jub0bs avatar Nov 30 '25 15:11 jub0bs