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.
304 lines
9.1 KiB
Go
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
|
|
}
|