package middleware import ( "cmp" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/binary" "encoding/hex" "errors" "fmt" "net/http" "strings" "time" "git.avsdev.uk/Go/job-search/internal/config" "git.avsdev.uk/Go/job-search/internal/httpx" ) // Errors returned by CSRF.Validate for a token that is rejected. var ( // ErrCSRFMissing is returned when no token is presented. ErrCSRFMissing = errors.New("csrf token missing") // ErrCSRFNoCookie is returned when the request has no CSRF cookie. ErrCSRFNoCookie = errors.New("csrf cookie missing") // ErrCSRFMismatch is returned when the token differs from the cookie. ErrCSRFMismatch = errors.New("csrf token does not match cookie") // ErrCSRFInvalid is returned for a malformed token or a bad signature. ErrCSRFInvalid = errors.New("csrf token invalid") // ErrCSRFExpired is returned for a token older than the token TTL. ErrCSRFExpired = errors.New("csrf token expired") ) // CSRF implements two independent layers: // // 1. http.CrossOriginProtection (Go >=1.25): rejects unsafe cross-origin // browser requests using Sec-Fetch-Site, falling back to Origin vs Host. // 2. Signed double-submit token: an HttpOnly cookie plus the same value in a // request header. The value is HMAC-signed and timestamped, so an // attacker who can plant cookies (e.g. from a sibling subdomain) still // cannot mint a valid token. This layer also covers requests that carry // neither Sec-Fetch-Site nor Origin. // // A token can be obtained with GET /api/csrf and sent in the header for API // consumers. type CSRF struct { cop *http.CrossOriginProtection token bool key []byte cookieName string headerName string ttl time.Duration exempt *http.ServeMux } // The defaults NewCSRF uses for an empty cookie or header name, or a token // TTL of 0 or less, as the configuration's defaults. const ( // defaultCSRFCookieName is the cookie the token is set in. defaultCSRFCookieName = "js_csrf" // defaultCSRFHeaderName is the request header that carries the token. defaultCSRFHeaderName = "X-CSRF-Token" // defaultCSRFTokenTTL is how long a token is valid. defaultCSRFTokenTTL = 12 * time.Hour ) // Sizes of a token's parts. const ( // csrfNonceLen is the size of a token's random nonce. csrfNonceLen = 16 // csrfPayloadLen is the size of a token's signed payload: the nonce and // the time it was issued (unix seconds). csrfPayloadLen = csrfNonceLen + 8 ) // NewCSRF builds the CSRF layers from conf. Without a configured key, tokens // are signed with a random key for this process, and a warning is logged if // tokens are enabled. It logs to slog.Default() unless given WithLogger. func NewCSRF(conf config.JSCServerCSRF, opts ...Option) (*CSRF, error) { csrf := &CSRF{ token: conf.Tokens.Enabled, cookieName: cmp.Or(conf.Tokens.CookieName, defaultCSRFCookieName), headerName: http.CanonicalHeaderKey( cmp.Or(conf.Tokens.HeaderName, defaultCSRFHeaderName), ), ttl: conf.Tokens.TokenTTL, exempt: http.NewServeMux(), } if csrf.ttl <= 0 { csrf.ttl = defaultCSRFTokenTTL } if conf.Tokens.Key != "" { key, err := hex.DecodeString(conf.Tokens.Key) if err != nil { return nil, fmt.Errorf("csrf key: %w", err) } csrf.key = key } else { csrf.key = make([]byte, 32) rand.Read(csrf.key) // crypto/rand.Read never returns an error if conf.Tokens.Enabled { applyOptions(opts).logger("csrf").Warn( "csrf key not set; using a random per-process key, so tokens " + "won't survive a restart or be valid across instances", ) } } noop := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { }) for _, pattern := range conf.ExemptPaths { csrf.exempt.Handle(pattern, noop) } if conf.CrossOriginProtection.Enabled { csrf.cop = http.NewCrossOriginProtection() for _, origin := range conf.CrossOriginProtection.TrustedOrigins { if err := csrf.cop.AddTrustedOrigin(origin); err != nil { return nil, fmt.Errorf("csrf trusted origin %q: %w", origin, err) } } for _, pattern := range conf.ExemptPaths { csrf.cop.AddInsecureBypassPattern(pattern) } csrf.cop.SetDenyHandler(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { httpx.WriteProblem( w, r, http.StatusForbidden, "cross-origin request rejected", ) }, )) } return csrf, nil } // HeaderName is the request header that must carry the token. func (c *CSRF) HeaderName() string { return c.headerName } // TokenEnabled reports whether the token layer is active. func (c *CSRF) TokenEnabled() bool { return c.token } // Protect applies both layers to unsafe methods, except on exempt paths. func (c *CSRF) Protect(next http.Handler) http.Handler { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c.token && !isSafeMethod(r.Method) && !c.isExempt(r) { if err := c.Validate(r, r.Header.Get(c.headerName)); err != nil { httpx.WriteProblem(w, r, http.StatusForbidden, err.Error()) return } } next.ServeHTTP(w, r) }) if c.cop != nil { return c.cop.Handler(handler) } return handler } // Validate checks a presented token against the cookie and its signature. func (c *CSRF) Validate(r *http.Request, presented string) error { if presented == "" { return ErrCSRFMissing } cookie, err := r.Cookie(c.cookieNameFor(IsSecure(r))) if err != nil || cookie.Value == "" { return ErrCSRFNoCookie } if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(presented)) != 1 { return ErrCSRFMismatch } issued, err := c.verify(presented) if err != nil { return err } if time.Since(issued) > c.ttl { return ErrCSRFExpired } return nil } // IssueHandler serves GET /api/csrf: it (re)issues the cookie and returns // the token for the client to echo back in the header. A still-fresh token // is reused so parallel tabs do not invalidate each other. func (c *CSRF) IssueHandler(w http.ResponseWriter, r *http.Request) { secure := IsSecure(r) name := c.cookieNameFor(secure) token := "" issued := time.Time{} if cookie, err := r.Cookie(name); err == nil { cookieIssued, err := c.verify(cookie.Value) if err == nil && time.Since(cookieIssued) < c.ttl/2 { token, issued = cookie.Value, cookieIssued } } if token == "" { issued = time.Now() token = c.mint(issued) // Secure is false only on plain HTTP (local development), where // browsers refuse Secure cookies; HttpOnly and SameSite are always set. http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: see above Name: name, Value: token, Path: "/", MaxAge: int(c.ttl.Seconds()), HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, }) } httpx.WriteJSON(w, http.StatusOK, map[string]any{ "token": token, "header": c.headerName, "expiresAt": issued.Add(c.ttl).UTC(), }) } // mint returns a new token issued at now: a random nonce and the issue time, // and their signature (see sign), each base64url encoded and joined by ".". func (c *CSRF) mint(now time.Time) string { payload := make([]byte, csrfPayloadLen) rand.Read(payload[:csrfNonceLen]) // crypto/rand.Read never returns an error binary.BigEndian.PutUint64( payload[csrfNonceLen:], uint64(now.Unix()), //nolint:gosec // G115: the current time is after 1970 ) encoding := base64.RawURLEncoding return encoding.EncodeToString(payload) + "." + encoding.EncodeToString(c.sign(payload)) } // verify checks token's format and signature and returns when it was issued, // or ErrCSRFInvalid. func (c *CSRF) verify(token string) (time.Time, error) { encodedPayload, encodedSignature, ok := strings.Cut(token, ".") if !ok { return time.Time{}, ErrCSRFInvalid } encoding := base64.RawURLEncoding payload, payloadErr := encoding.DecodeString(encodedPayload) signature, signatureErr := encoding.DecodeString(encodedSignature) if payloadErr != nil || signatureErr != nil || len(payload) != csrfPayloadLen { return time.Time{}, ErrCSRFInvalid } if !hmac.Equal(signature, c.sign(payload)) { return time.Time{}, ErrCSRFInvalid } issuedUnix := binary.BigEndian.Uint64(payload[csrfNonceLen:]) // The payload is signed, so this is a time mint wrote: after 1970. return time.Unix(int64(issuedUnix), 0), nil //nolint:gosec // G115: see above } // sign returns the HMAC-SHA256 of payload under the CSRF key. func (c *CSRF) sign(payload []byte) []byte { mac := hmac.New(sha256.New, c.key) mac.Write([]byte("csrf-v1|")) mac.Write(payload) return mac.Sum(nil) } // cookieNameFor drops the __Host-/__Secure- prefix on plain HTTP (local // development), because browsers refuse such cookies without Secure. func (c *CSRF) cookieNameFor(secure bool) string { if secure { return c.cookieName } name := strings.TrimPrefix(c.cookieName, "__Host-") return strings.TrimPrefix(name, "__Secure-") } // isExempt reports whether r's path matches one of the exempt paths. func (c *CSRF) isExempt(r *http.Request) bool { _, pattern := c.exempt.Handler(r) return pattern != "" } // isSafeMethod reports whether method is safe (it changes nothing), so // needs no CSRF protection. func isSafeMethod(method string) bool { return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions }