Files
job-search/internal/middleware/csrf.go
T
bwilliams a279405cae Added the HTTP server and serve command, with proxy, CSRF, TLS and security header config; wsserve is now ws serve, alongside a new ws dial
The serve command runs internal/server: the API under /api (health, and
the CSRF token endpoint) and the WebSocket hub under /ws, over HTTP or TLS,
with an optional HTTP to HTTPS redirect, the PROXY protocol from trusted
sources and a graceful shutdown that closes WebSockets first. Its middleware
covers the client IP behind trusted proxies, request IDs, request logging,
panic recovery, global and API rate limits, Host and body size checks,
security response headers, compression, CSRF protection (cross-origin
checks and signed double-submit tokens) and JSON-only API bodies. Middleware
that logs takes a WithLogger option.

New server config sections: proxy, csrf, tls, headers and frontend (not
used yet), plus max_header_bytes, max_body_bytes, global and API rate limits
and HTTP read, write and idle timeouts. Every setting documents what 0 or
empty means, and internal/config/README.md is reorganised by section.
server.timeouts.read_header must be greater than 0, and a header or idle
timeout of 0 means no limit rather than the read timeout.

WebSocket upgrades are protected by the Origin check: the server's own
origin, or one of csrf.cross_origin_protection.trusted_origins, which the
hub now takes with WithTrustedOrigins.

wsserve becomes "ws serve". "ws dial" opens a WebSocket on /ws to test it:
--ping sends a ping and waits for the pong, --message sends messages and
prints the replies, --origin tests the Origin check, and with neither
--ping nor --message each line of stdin is sent until Ctrl+C.
2026-10-15 17:47:40 +01:00

304 lines
9.1 KiB
Go

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
}