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
-
Keep request data in the request: pass it through arguments and return values, or through
context.Context. -
Lock every access with a
sync.Mutex, or with the writeLockof async.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]++ } -
Use a concurrent type:
sync/atomicfor counters,sync.Mapfor caches,sync.Oncefor one-time initialization. -
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 bydeferor after the write). -
A write inside
sync.Once.Do. -
State only one goroutine touches, the one draining a channel.
-
sync/atomicvalues andsync.Map, which change through method calls. -
A lazy initialization whose value is not a parameter:
if cache == nil { cache = … }, or afterif s.conn != nil { return }. -
A load-once flag, only ever set to
true, and the plain writes underif !loaded. -
Writes in
init(),main(), a constructor (New,NewXornewXreturning a value), a CLI start-up callback (cobra’sRun, urfave/cli’sActionandBefore, 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.