package security import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "net/http" "net/http/httptest" "strings" "testing" ) var csrfKey = []byte("csrf test key, thirty two bytes!") // A token minted for one session must not verify against another. This is the property the type // claims (the config calls the key "HMAC key binding a CSRF token to its session") and the property // that makes signed double submit worth more than an unsigned one: without it, anyone who can call // the public mint endpoint holds a token that verifies inside every victim's session. func TestCSRFTokenDoesNotCrossSessions(t *testing.T) { c := NewCSRF(csrfKey) victim, attacker := "victim-session-token", "attacker-session-token" tok, err := c.Issue(attacker) if err != nil { t.Fatalf("issue: %v", err) } if !c.Valid(tok, attacker) { t.Fatal("token issued for a session must verify in that session") } if c.Valid(tok, victim) { t.Fatal("token issued for one session verified in another, the token is not session bound") } if c.Valid(tok, "") { t.Fatal("session bound token verified as anonymous") } } // The anonymous binding is a binding of its own, not a wildcard: the pre login endpoints need it, and // it must stop working once a session exists rather than becoming a universal token. func TestCSRFAnonymousTokenIsNotUniversal(t *testing.T) { c := NewCSRF(csrfKey) tok, err := c.Issue("") if err != nil { t.Fatalf("issue: %v", err) } if !c.Valid(tok, "") { t.Fatal("anonymous token must verify with no session") } if c.Valid(tok, "some-session-token") { t.Fatal("anonymous token verified inside a session") } } // A token in the old unbound format (HMAC over the nonce alone) must be refused, not honored for // compatibility and not panic the verifier. func TestCSRFLegacyUnboundTokenIsRefused(t *testing.T) { c := NewCSRF(csrfKey) nonce := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef")) mac := hmac.New(sha256.New, csrfKey) mac.Write([]byte(nonce)) legacy := nonce + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) if c.Valid(legacy, "") || c.Valid(legacy, "any-session") { t.Fatal("a token minted before session binding must fail closed") } for _, malformed := range []string{"", ".", "nodot", ".sigonly", "nonce."} { if c.Valid(malformed, "") { t.Fatalf("malformed token %q accepted", malformed) } } } // The middleware must recompute the binding from the session cookie on the request, so a stolen or // separately minted token cannot be replayed against another user's session cookie. func TestCSRFProtectBindsToRequestSession(t *testing.T) { c := NewCSRF(csrfKey) const csrfCookie, sessCookie, header = "mm_csrf", "mm_session", "X-CSRF-Token" mw := c.Protect(csrfCookie, header, sessCookie, []string{"https://app.example.gov"}) handled := false h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handled = true })) post := func(token, session string) int { handled = false r := httptest.NewRequest(http.MethodPost, "/users/invite", nil) r.Header.Set("Origin", "https://app.example.gov") r.Header.Set(header, token) r.AddCookie(&http.Cookie{Name: csrfCookie, Value: token}) if session != "" { r.AddCookie(&http.Cookie{Name: sessCookie, Value: session}) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w.Code } mine, err := c.Issue("my-session") if err != nil { t.Fatalf("issue: %v", err) } if code := post(mine, "my-session"); code != http.StatusOK || !handled { t.Fatalf("own session request rejected: status %d handled %v", code, handled) } if code := post(mine, "victim-session"); code != http.StatusForbidden || handled { t.Fatalf("token replayed against another session was allowed: status %d handled %v", code, handled) } anon, err := c.Issue("") if err != nil { t.Fatalf("issue: %v", err) } if code := post(anon, ""); code != http.StatusOK { t.Fatalf("anonymous request rejected: status %d", code) } if code := post(anon, "victim-session"); code != http.StatusForbidden { t.Fatalf("anonymous token accepted inside a session: status %d", code) } } // The signing input must not be reinterpretable: a nonce and a binding that concatenate to the same // bytes under a different split must not produce the same signature. func TestCSRFSigningInputIsUnambiguous(t *testing.T) { c := NewCSRF(csrfKey) a := c.sign("abc", "def") b := c.sign("abcdef", "") if strings.EqualFold(a, b) { t.Fatal("signing input is ambiguous across the nonce and session boundary") } }