Shared Mutable State

ID

go.shared_mutable_state

Severity

low

Remediation Complexity

medium

Remediation Risk

medium

Remediation Effort

medium

Resource

Synchronization

Language

Go

Tags

ASVS50:v15.4.1, CWE:362, CWE:567, NIST.SP.800-53, PCI-DSS:6.5.6

Description

Detects shared mutable state in Go servers: package-level variables, and the fields of the struct whose methods are the request handlers, written outside initialization without a lock. net/http serves every request on its own goroutine, and so do chi, gorilla/mux, gin, echo and fiber. Goroutines run in parallel, and the server value is built once, so every request writes the same variables at the same time. A map written concurrently can crash the process (fatal error: concurrent map writes), a counter loses increments, and an append loses elements.

Writes are reported in files that serve requests (they declare a handler or import a router) and in any goroutine a go statement starts. The wiring is read across files and packages. A struct built once (in main, init, a constructor or a function that registers routes) and registered, or handed to the constructor of a struct with handlers, is shared wherever it is declared. A method only start-up code calls is initialization. When the value written comes from a function parameter, one caller’s data can reach another, and that finding is raised to high. One issue is reported per piece of state, at its worst write.

Rationale

A package-level map written by a handler:

var hits = map[string]int{}

func Hit(w http.ResponseWriter, r *http.Request) {
	hits[r.URL.Path]++          // every request writes the same map
}

The handler receiver’s fields:

type Server struct {
	sessions map[string]string
}

func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
	s.sessions[r.FormValue("user")] = r.FormValue("token")
}

A write under a read lock, which every reader holds at the same time:

func (s *Server) Read(w http.ResponseWriter, r *http.Request) {
	s.rw.RLock()
	defer s.rw.RUnlock()
	s.reads++
}

Remediation

  1. Keep request data in the request: pass it through arguments and return values, or through context.Context.

  2. Lock every access with a sync.Mutex, or with the write Lock of a sync.RWMutex:

    var (
    	mu   sync.Mutex
    	hits = map[string]int{}
    )
    
    func Hit(w http.ResponseWriter, r *http.Request) {
    	mu.Lock()
    	defer mu.Unlock()
    	hits[r.URL.Path]++
    }
  3. Use a concurrent type: sync/atomic for counters, sync.Map for caches, sync.Once for one-time initialization.

  4. Give the state one owner: a goroutine that receives requests on a channel and is the only code that touches the state.

Not Reported

  • A write after the function took a lock (mu.Lock(), released by defer or after the write).

  • A write inside sync.Once.Do.

  • State only one goroutine touches, the one draining a channel.

  • sync/atomic values and sync.Map, which change through method calls.

  • A lazy initialization whose value is not a parameter: if cache == nil { cache = … }, or after if s.conn != nil { return }.

  • A load-once flag, only ever set to true, and the plain writes under if !loaded.

  • Writes in init(), main(), a constructor (New, NewX or newX returning a value), a CLI start-up callback (cobra’s Run, urfave/cli’s Action and Before, written in place or named), and a function or method only those call, in any file.

  • A value a request builds for itself, and a struct no handler is a method of.

  • Code in a file that neither serves requests nor starts a goroutine.

  • Test code.

© 2026 Xygeni. All rights reserved