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.
This commit is contained in:
1 parent
f3a91ad915
commit
a279405cae
41 files changed
+3876
-78
No files matched your search
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
)
|
||||
|
||||
// Compress returns gzip/zstd response compression using klauspost/compress,
|
||||
// which is considerably faster than compress/gzip. Responses that already
|
||||
// carry a Content-Encoding (e.g. precompressed frontend assets) are passed
|
||||
// through untouched.
|
||||
//
|
||||
// Request-body decompression is left disabled on purpose (decompression bombs).
|
||||
//
|
||||
// Do not apply this to the WebSocket route.
|
||||
func Compress() (func(http.Handler) http.Handler, error) {
|
||||
wrap, err := gzhttp.NewWrapper(
|
||||
gzhttp.MinSize(1000),
|
||||
gzhttp.CompressionLevel(5),
|
||||
gzhttp.EnableZstd(true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return wrap(next)
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/config"
|
||||
)
|
||||
|
||||
func TestCrossOriginRejected(t *testing.T) {
|
||||
var cfg config.JSCServerCSRF
|
||||
cfg.CrossOriginProtection.Enabled = true
|
||||
c, err := NewCSRF(cfg, WithLogger(slog.New(slog.DiscardHandler)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := c.Protect(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
}))
|
||||
wants := map[string]int{
|
||||
"cross-site": http.StatusForbidden,
|
||||
"same-origin": http.StatusOK,
|
||||
}
|
||||
for site, want := range wants {
|
||||
req := httptest.NewRequest("POST", "/api/items", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", site)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != want {
|
||||
t.Errorf("%s: got %d, want %d", site, rec.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/config"
|
||||
)
|
||||
|
||||
// DefaultHeaders sets a number of security related response headers. Using
|
||||
// configuration settings any extra headers are added and any headers to be
|
||||
// removed are stripped.
|
||||
func DefaultHeaders(
|
||||
conf config.JSCServerHeaders,
|
||||
) func(http.Handler) http.Handler {
|
||||
type header struct {
|
||||
name, value string
|
||||
}
|
||||
var static []header
|
||||
|
||||
addHeader := func(name, value string) {
|
||||
if value != "" {
|
||||
static = append(static, header{
|
||||
name: http.CanonicalHeaderKey(name),
|
||||
value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
addHeader("Content-Security-Policy", conf.ContentSecurityPolicy)
|
||||
addHeader("X-Frame-Options", conf.FrameOptions)
|
||||
addHeader("Referrer-Policy", conf.ReferrerPolicy)
|
||||
addHeader("Permissions-Policy", conf.PermissionsPolicy)
|
||||
addHeader("Cross-Origin-Opener-Policy", conf.CrossOriginOpenerPolicy)
|
||||
addHeader("Cross-Origin-Resource-Policy", conf.CrossOriginResourcePolicy)
|
||||
addHeader("X-Content-Type-Options", "nosniff")
|
||||
for _, extra := range conf.ExtraResponseHeaders {
|
||||
addHeader(extra.Header, extra.Value)
|
||||
}
|
||||
|
||||
hsts := ""
|
||||
if seconds := int64(conf.HSTS.MaxAge.Seconds()); seconds > 0 {
|
||||
hsts = fmt.Sprintf("max-age=%d", seconds)
|
||||
if conf.HSTS.IncludeSubdomains {
|
||||
hsts += "; includeSubDomains"
|
||||
}
|
||||
if conf.HSTS.Preload {
|
||||
hsts += "; preload"
|
||||
}
|
||||
}
|
||||
|
||||
remove := make([]string, 0, len(conf.RemoveResponseHeaders))
|
||||
for _, name := range conf.RemoveResponseHeaders {
|
||||
remove = append(remove, http.CanonicalHeaderKey(name))
|
||||
}
|
||||
strip := func(headers http.Header) {
|
||||
for _, name := range remove {
|
||||
headers.Del(name)
|
||||
}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
headers := w.Header()
|
||||
|
||||
for _, staticHeader := range static {
|
||||
headers.Set(staticHeader.name, staticHeader.value)
|
||||
}
|
||||
|
||||
// HSTS is only meaningful (and only honoured) over HTTPS.
|
||||
if hsts != "" && IsSecure(r) {
|
||||
headers.Set("Strict-Transport-Security", hsts)
|
||||
}
|
||||
|
||||
if len(remove) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(&responseRecorder{
|
||||
ResponseWriter: w,
|
||||
beforeHeader: strip,
|
||||
}, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/httpx"
|
||||
)
|
||||
|
||||
// responseRecorder captures status and size while staying transparent to
|
||||
// http.ResponseController (Unwrap) and to code that type-asserts for
|
||||
// http.Hijacker / http.Flusher (needed by the WebSocket upgrade).
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int64
|
||||
hijacked bool
|
||||
// beforeHeader, if set, runs once just before headers are written.
|
||||
beforeHeader func(http.Header)
|
||||
}
|
||||
|
||||
// WriteHeader records the status, running beforeHeader the first time, and
|
||||
// writes it.
|
||||
func (w *responseRecorder) WriteHeader(code int) {
|
||||
if w.status == 0 {
|
||||
w.status = code
|
||||
if w.beforeHeader != nil {
|
||||
w.beforeHeader(w.Header())
|
||||
}
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Write writes data, writing a 200 status first if none has been, and counts
|
||||
// the bytes written.
|
||||
func (w *responseRecorder) Write(data []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
written, err := w.ResponseWriter.Write(data)
|
||||
w.bytes += int64(written)
|
||||
|
||||
return written, err
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped ResponseWriter, for http.ResponseController.
|
||||
func (w *responseRecorder) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
// Flush writes a 200 status first if none has been, then flushes the
|
||||
// wrapped ResponseWriter if it can.
|
||||
func (w *responseRecorder) Flush() {
|
||||
if w.status == 0 {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
http.NewResponseController(w.ResponseWriter).Flush() //nolint:errcheck,gosec // none returned
|
||||
}
|
||||
|
||||
// Hijack takes over the connection, recording it as hijacked with status 101
|
||||
// (switching protocols) if no status was written.
|
||||
func (w *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
conn, readWriter, err := http.NewResponseController(w.ResponseWriter).Hijack()
|
||||
if err == nil {
|
||||
w.hijacked = true
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusSwitchingProtocols
|
||||
}
|
||||
}
|
||||
return conn, readWriter, err
|
||||
}
|
||||
|
||||
// RequestID assigns each request an ID, exposed as X-Request-ID. An incoming
|
||||
// X-Request-ID is only accepted from trusted proxies and only if well formed.
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := ""
|
||||
if Client(r).ViaProxy {
|
||||
if incoming := r.Header.Get("X-Request-ID"); validRequestID(incoming) {
|
||||
id = incoming
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
id = rand.Text()
|
||||
}
|
||||
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
next.ServeHTTP(w, r.WithContext(httpx.WithRequestID(r.Context(), id)))
|
||||
})
|
||||
}
|
||||
|
||||
// validRequestID reports whether id is an acceptable request ID: 1 to 64
|
||||
// characters, each a letter, digit, '-', '_' or '.'.
|
||||
func validRequestID(id string) bool {
|
||||
if id == "" || len(id) > 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(id); i++ {
|
||||
if !isRequestIDChar(id[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isRequestIDChar reports whether char may appear in a request ID.
|
||||
func isRequestIDChar(char byte) bool {
|
||||
switch {
|
||||
case char >= 'a' && char <= 'z',
|
||||
char >= 'A' && char <= 'Z',
|
||||
char >= '0' && char <= '9':
|
||||
return true
|
||||
}
|
||||
return char == '-' || char == '_' || char == '.'
|
||||
}
|
||||
|
||||
// Logger logs one line per request. The query string is deliberately not
|
||||
// logged, as it may carry secrets. It logs to slog.Default() unless given
|
||||
// WithLogger.
|
||||
func Logger(opts ...Option) func(http.Handler) http.Handler {
|
||||
settings := applyOptions(opts)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
recorder := &responseRecorder{
|
||||
ResponseWriter: w,
|
||||
}
|
||||
|
||||
defer func() {
|
||||
status := recorder.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
|
||||
level := slog.LevelInfo
|
||||
switch {
|
||||
case status >= 500:
|
||||
level = slog.LevelError
|
||||
case status >= 400:
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
|
||||
settings.logger("http").LogAttrs(
|
||||
r.Context(),
|
||||
level,
|
||||
"request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", status),
|
||||
slog.Int64("bytes", recorder.bytes),
|
||||
slog.Duration("duration", time.Since(start)),
|
||||
slog.String("ip", ClientIP(r)),
|
||||
slog.String("proto", r.Proto),
|
||||
slog.String("request_id", httpx.RequestID(r.Context())),
|
||||
slog.Bool("hijacked", recorder.hijacked),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(recorder, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Recoverer turns panics into 500 responses and logs them. It logs to
|
||||
// slog.Default() unless given WithLogger.
|
||||
func Recoverer(opts ...Option) func(http.Handler) http.Handler {
|
||||
settings := applyOptions(opts)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err, isErr := recovered.(error)
|
||||
if isErr && errors.Is(err, http.ErrAbortHandler) {
|
||||
panic(recovered)
|
||||
}
|
||||
|
||||
settings.logger("http").Error(
|
||||
"panic",
|
||||
"error", recovered,
|
||||
"path", r.URL.Path,
|
||||
"request_id", httpx.RequestID(r.Context()),
|
||||
)
|
||||
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Option configures a middleware that logs, such as NewCSRF, Logger or
|
||||
// Recoverer.
|
||||
type Option func(*options)
|
||||
|
||||
// options holds the settings Options change.
|
||||
type options struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// WithLogger makes a middleware log to logger instead of slog.Default().
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(opts *options) {
|
||||
opts.log = logger
|
||||
}
|
||||
}
|
||||
|
||||
// applyOptions returns the settings opts make.
|
||||
func applyOptions(opts []Option) options {
|
||||
var settings options
|
||||
for _, opt := range opts {
|
||||
opt(&settings)
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
// logger returns the logger to use, tagged with component: the given one, or
|
||||
// slog.Default(), looked up now.
|
||||
func (opts options) logger(component string) *slog.Logger {
|
||||
log := opts.log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return log.With("component", component)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/config"
|
||||
"git.avsdev.uk/Go/job-search/internal/httpx"
|
||||
)
|
||||
|
||||
// defaultMaxBodyBytes is the largest request body when max_body_bytes is 0:
|
||||
// 1MB, as the configuration's default.
|
||||
const defaultMaxBodyBytes = 1_000_000
|
||||
|
||||
// RequestChecks validates the Host header against headers.allowed_hosts (if
|
||||
// any are set) and caps the request body at max_body_bytes: 0 means
|
||||
// defaultMaxBodyBytes, and -1 (util.ByteSizeNoLimit) no limit.
|
||||
func RequestChecks(
|
||||
conf config.JSCServer,
|
||||
) func(http.Handler) http.Handler {
|
||||
allowedHosts := newHostAllowList(conf.Headers.AllowedHosts)
|
||||
|
||||
maxBodyBytes := int64(conf.MaxBodyBytes)
|
||||
if maxBodyBytes == 0 {
|
||||
maxBodyBytes = defaultMaxBodyBytes
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !allowedHosts.allows(r.Host) {
|
||||
httpx.WriteProblem(
|
||||
w, r, http.StatusMisdirectedRequest, "host not allowed",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if maxBodyBytes > 0 && r.Body != nil && r.Body != http.NoBody {
|
||||
if r.ContentLength > maxBodyBytes {
|
||||
httpx.WriteProblem(
|
||||
w, r, http.StatusRequestEntityTooLarge, "request body too large",
|
||||
)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// hostAllowList is the set of hosts a request's Host header may name: exact
|
||||
// host names, and suffixes from "*.example.com" patterns. An empty list
|
||||
// allows every host.
|
||||
type hostAllowList struct {
|
||||
exact map[string]bool
|
||||
// suffixes are the domains of "*.domain" patterns, e.g. ".example.com".
|
||||
suffixes []string
|
||||
}
|
||||
|
||||
// newHostAllowList builds a hostAllowList from host names and "*.domain"
|
||||
// patterns, ignoring case and blank entries.
|
||||
func newHostAllowList(hosts []string) hostAllowList {
|
||||
list := hostAllowList{
|
||||
exact: map[string]bool{},
|
||||
}
|
||||
for _, host := range hosts {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if strings.HasPrefix(host, "*.") {
|
||||
list.suffixes = append(list.suffixes, host[1:])
|
||||
} else if host != "" {
|
||||
list.exact[host] = true
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// allows reports whether the Host header host (with or without a port, and
|
||||
// a trailing dot) is allowed. A suffix matches subdomains only, not the bare
|
||||
// domain.
|
||||
func (l hostAllowList) allows(host string) bool {
|
||||
if len(l.exact) == 0 && len(l.suffixes) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if hostname, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = hostname
|
||||
}
|
||||
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
if l.exact[host] {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, suffix := range l.suffixes {
|
||||
if strings.HasSuffix(host, suffix) && len(host) > len(suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RequireJSON rejects requests with a body that is not JSON (415 Unsupported
|
||||
// Media Type).
|
||||
func RequireJSON(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hasBody := r.ContentLength > 0 || len(r.TransferEncoding) > 0
|
||||
|
||||
if hasBody {
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
isJSON := mediaType == "application/json" ||
|
||||
strings.HasSuffix(mediaType, "+json")
|
||||
if err != nil || !isJSON {
|
||||
httpx.WriteProblem(
|
||||
w,
|
||||
r,
|
||||
http.StatusUnsupportedMediaType,
|
||||
"Content-Type must be application/json",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// NoStore marks responses as non-cacheable.
|
||||
func NoStore(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user