Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/platform/security/csrf.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/platform/security/csrf.go |
| Original SHA-256 | 5379cd0d854f9f18511ae89a8a1ea55b1cb9a3e6c77f5c457649fc7bb131bc89 |
| Git blob SHA | d88e3de3bb230b1fe88552e986a08889e7ff70e7 |
| Reading-copy SHA-256 | 5379cd0d854f9f18511ae89a8a1ea55b1cb9a3e6c77f5c457649fc7bb131bc89 |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/platform/security/csrf.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/platform/security/csrf.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓144 lines · 5.6 KB
package security
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"net/http"
"strings"
)
// CSRF issues and verifies signed double submit tokens. A token is a random nonce plus an HMAC over
// that nonce AND the session the token was minted for, so a value the server did not mint (for
// example a cookie planted by a compromised sibling subdomain) is rejected even though the double
// submit cookie and header match, and a token minted for one session is refused when replayed on
// another. This composes with the __Host- cookie prefix, SameSite Strict on the session, and the
// Origin allowlist below.
//
// The session binding is what makes signed double submit actually a defense rather than a signature
// over a value the attacker also gets to choose. Without it, anybody able to reach GET /auth/csrf,
// which is unauthenticated by necessity because the login form needs a token, could mint a
// structurally valid pair and plant it on a victim, and the signature proved only that the SERVER
// had minted the value at some point, not that it had minted it for THIS session.
type CSRF struct{ key []byte }
// NewCSRF builds a CSRF signer. A nil or empty key disables verification (dev only); production
// config requires the key.
func NewCSRF(key []byte) *CSRF { return &CSRF{key: key} }
// Issue mints a fresh token bound to sessionToken, to set as the __Host-mm_csrf cookie and echo in
// the X-CSRF-Token header. An empty sessionToken is the anonymous binding used before login; such a
// token stops validating the moment a session cookie exists, which is correct, and the SPA already
// re mints once on a 403 for an unsafe verb, so the login and MFA transitions cost one retry rather
// than an error the user sees.
func (c *CSRF) Issue(sessionToken string) (string, error) {
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
nb := base64.RawURLEncoding.EncodeToString(nonce)
return nb + "." + c.sign(nb, sessionToken), nil
}
// Valid reports whether token was minted by this signer FOR this session. A token in the old
// unbound format fails here rather than being accepted for compatibility: the whole point of the
// change is that an unbound token proves nothing, so honoring one would keep the hole open for the
// lifetime of every already issued cookie. It fails closed, and the SPA re mints on the 403.
func (c *CSRF) Valid(token, sessionToken string) bool {
if len(c.key) == 0 {
return true // verification disabled (dev)
}
nb, sig, found := strings.Cut(token, ".")
if !found || nb == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(sig), []byte(c.sign(nb, sessionToken))) == 1
}
// sign covers the nonce and the session binding under one MAC. The session token is folded in as its
// SHA 256 hash, the same form the sessions table stores, so the raw cookie value is never a MAC
// input and the CSRF key never sees the live session secret. The two fields are separated by a byte
// that cannot occur in base64url output, so no pair of (nonce, binding) values can be rearranged
// into another pair with the same signing input.
func (c *CSRF) sign(nonceB64, sessionToken string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte(nonceB64))
mac.Write([]byte{0x1f})
if sessionToken != "" {
mac.Write(HashSessionToken(sessionToken))
}
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Protect is the middleware enforcing CSRF on unsafe verbs: a hard Origin allowlist (reject absent or
// mismatched Origin) plus the signed double submit check (the __Host-mm_csrf cookie and the
// X-CSRF-Token header must match and be a valid token bound to the request's own session). Safe verbs
// pass through. allowedOrigins is matched exactly; an empty list disables the Origin check (dev only).
//
// sessionCookieName names the session cookie so the binding can be recomputed from the same request
// that carries the token. A request with no session cookie verifies against the anonymous binding,
// which is what the pre login endpoints need.
func (c *CSRF) Protect(cookieName, headerName, sessionCookieName string, allowedOrigins []string) func(http.Handler) http.Handler {
allow := make(map[string]bool, len(allowedOrigins))
for _, o := range allowedOrigins {
allow[strings.TrimSpace(o)] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if safeMethod(r.Method) {
next.ServeHTTP(w, r)
return
}
if len(allow) > 0 {
origin := r.Header.Get("Origin")
if origin == "" {
origin = originFromReferer(r.Header.Get("Referer"))
}
if !allow[origin] {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
var session string
if sc, serr := r.Cookie(sessionCookieName); serr == nil {
session = sc.Value
}
cookie, err := r.Cookie(cookieName)
header := r.Header.Get(headerName)
if err != nil || header == "" ||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) != 1 ||
!c.Valid(header, session) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
func safeMethod(m string) bool {
switch m {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return true
}
return false
}
func originFromReferer(referer string) string {
if referer == "" {
return ""
}
// Take scheme://host[:port] from a referer URL without importing net/url for this hot path.
i := strings.Index(referer, "://")
if i < 0 {
return ""
}
rest := referer[i+3:]
if j := strings.IndexByte(rest, '/'); j >= 0 {
rest = rest[:j]
}
return referer[:i+3] + rest
}