All checks were successful
check / check (push) Successful in 3m47s
GORM's default logger printed the fully interpolated SQL to standard
output on every statement that returned an error, including a plain
record-not-found. On /webhook/{uuid} and on the login form the
interpolated parameter is client-chosen and unbounded, so an
unauthenticated client sized the operator's log, one line per request,
at no level the operator could turn down.
Every gorm.Open in the service now installs internal/gormlog, a
gormlogger.Interface over the service's *slog.Logger. Its lines take
the level the operator set and the handler internal/logger selected; a
record-not-found is not logged as an error, since it is the expected
outcome on both of those paths and each handler already records its
own miss at DEBUG without the SQL; slow statements are kept at WARN
above the same 200ms threshold GORM used; and every value it emits is
spent through internal/logfield, the same encoded-byte budget the
access log spends. MaxAccessLogLineBytes bounds a GORM line too, and
internal/gormlog asserts each line against the constant directly.
Trace orders its cases exactly as GORM's own Trace orders them --
error-that-is-not-a-miss, then slow, then routine -- so a statement
that both missed and ran slow is still reported as slow. Ordering the
drop first would have made this adapter strictly less observant than
the IgnoreRecordNotFoundError option it was chosen over, on the two
lookups the issue is about, and a miss is the statement most likely to
be slow.
The third gorm.Open, in the archive writer, was not named in the issue
and had the same default. All three sites are pinned independently:
internal/handlers covers the main and per-webhook databases,
internal/delivery covers the archive writer, whose type is unexported.
Reverting any one of the three to a bare &gorm.Config{} fails the
suite.
The flood test's per-line and volume assertions were vacuous, because
the replaced default logger wrote only to a buffer while everything
else went to the captured stdout. It now tees to stdout as GORM's real
default does, so a reverted call site lands in the same capture and
those assertions measure the whole writer set.
internal/logfield gains the zero-headroom budget assertion and the
rune-splitting case: a value built from one rune must keep exactly
MaxBytes/EncodedBytes(r) of them, which a raw-byte budget fails and a
LessOrEqual on the budget cannot catch.
README: the ceiling now covers GORM, and the writers it does not cover
are re-derived by measuring rather than by reading. fx's console
logger and the Go runtime write to standard error. net/http's nil
ErrorLog is not a separate writer at all -- slog.SetDefault redirects
the log package's default logger into internal/logger's handler, so
those lines arrive on standard output at INFO. A handler panic reaches
that same path because chi's Recoverer crashes before writing, filed
as #187, and is the widest
line the service can write: measured at roughly 2,770 bytes against
the stated 2,560, a width that moves with the goroutine number and the
source paths in the stack, so only the fact that it exceeds the
ceiling is stated as invariant.
609 lines
20 KiB
Go
609 lines
20 KiB
Go
// Package middleware provides HTTP middleware for logging, auth,
|
|
// CORS, and metrics.
|
|
package middleware
|
|
|
|
import (
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
basicauth "github.com/99designs/basicauth-go"
|
|
"github.com/go-chi/chi"
|
|
"github.com/go-chi/chi/middleware"
|
|
"github.com/go-chi/cors"
|
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
|
"github.com/slok/go-http-metrics/middleware/std"
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/logfield"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
const (
|
|
// corsMaxAge is the maximum time (in seconds) that a
|
|
// preflight response can be cached.
|
|
corsMaxAge = 300
|
|
|
|
// unmatchedRoute is logged in the access log's url field when a
|
|
// redirected or rejected request matched no route pattern at
|
|
// all. Every byte of such a path is client-chosen, so none of it
|
|
// is logged.
|
|
unmatchedRoute = "(unmatched)"
|
|
|
|
// redactedQuery stands in for the query string on the access log
|
|
// branches that keep the concrete URL. The query is client-chosen
|
|
// on every route, including the ones that answer an
|
|
// unauthenticated 200, so logging it verbatim would let a client
|
|
// pick the size of the line it writes.
|
|
redactedQuery = "?(redacted)"
|
|
|
|
// maxLogRequestIDBytes bounds the request id, which is also
|
|
// client-supplied: chi's RequestID middleware passes an inbound
|
|
// X-Request-Id header through verbatim. Its generated form is an
|
|
// order of magnitude shorter than this.
|
|
maxLogRequestIDBytes = 128
|
|
|
|
// maxLogMethodBytes bounds the method. Go accepts any RFC 7230
|
|
// token there, bounded only by the header size limit, so it is
|
|
// client-chosen text like the rest. The longest registered method
|
|
// is half this.
|
|
maxLogMethodBytes = 32
|
|
|
|
// MaxAccessLogLineBytes is the ceiling on one JSON access log line,
|
|
// and the number an operator multiplies by the request rate to size
|
|
// log storage. It is not an observation of a sample: it is the sum
|
|
// of the budgets above, each of which logfield.Truncate enforces in
|
|
// ENCODED bytes, plus the part of the line no client can influence.
|
|
//
|
|
// url, useragent, referer 3*(512+11) = 1569
|
|
// request_id 128+11 = 139
|
|
// method 32+11 = 43
|
|
// fixed portion = 336
|
|
// ----
|
|
// 2087
|
|
//
|
|
// The 512 is logfield.MaxBytes; the 11 is the truncation marker,
|
|
// charged on top of each budget rather than inside it.
|
|
//
|
|
// The fixed portion is the JSON punctuation, the field names, the
|
|
// level and the message, both timestamps at their longest, an IPv6
|
|
// remoteIP with a zone, a three-digit status and a full-width int64
|
|
// latency. Stated at 2560 so the figure carries headroom rather
|
|
// than sitting on the arithmetic.
|
|
//
|
|
// The tty text handler in internal/logger is covered by the same
|
|
// figure. logfield.EncodedBytes charges every rune at least what
|
|
// the wider of the two handlers emits for it — including the ten
|
|
// bytes strconv.Quote spends on a non-printable rune at or above
|
|
// U+10000, which is four more than the JSON handler ever spends —
|
|
// so each budget bounds the encoded field under either handler.
|
|
// The text handler's fixed portion is 286, the smaller of the two,
|
|
// which puts its worst case at 2037.
|
|
//
|
|
// It is also the ceiling on every OTHER line this service writes
|
|
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
|
// supplies. Those lines — the MaxBodySize rejection, the CSRF
|
|
// rejection, the rate-limit rejection, the unauthenticated-request
|
|
// and unknown-entrypoint DEBUG lines, the failed-login DEBUG
|
|
// lines, and the two login-throttle WARN lines ("login failure
|
|
// limit exceeded" in loginguard.go and "password verification
|
|
// capacity exhausted" in internal/handlers/auth.go) — spend the
|
|
// same per-field budgets, and each carries strictly fewer
|
|
// client-supplied fields than the access log does,
|
|
// so none of them can reach a width the access log cannot. That is
|
|
// asserted directly, per line and under both handlers, rather than
|
|
// left to the reasoning: see logbound_test.go in this package and
|
|
// in internal/handlers.
|
|
//
|
|
// The two login-throttle lines are capped defensively: chi pins
|
|
// their route to the constant path "/pages/login", so no request
|
|
// through the mux can widen either one. Their assertions call
|
|
// RecordLoginFailure and the login handler directly with the path
|
|
// a caller on a parameterised route would supply, which is the
|
|
// only way those caps can be pinned at all.
|
|
//
|
|
// One writer in that set is not a handler's own slog call. The
|
|
// GORM adapter in internal/gormlog logs the statement with the
|
|
// client-chosen parameter already interpolated into it, which on
|
|
// the receiver and login lookups is exactly the value the budgets
|
|
// above exist for. Its widest line spends two logfield.MaxBytes
|
|
// budgets — the statement and the driver error — against a fixed
|
|
// portion smaller than this one's, and
|
|
// internal/gormlog/gormlog_test.go asserts every line it emits
|
|
// against this constant directly rather than leaving it as
|
|
// arithmetic.
|
|
//
|
|
// What it does NOT cover, so that the figure above is not read as
|
|
// more than it is:
|
|
//
|
|
// - Lines carrying an AUTHENTICATED operator's own input, which
|
|
// are not truncated at all: the webhook name on "webhook
|
|
// created" and the target host on "target URL blocked by SSRF
|
|
// protection" (both internal/handlers/source_management.go),
|
|
// and target_name in internal/delivery/engine.go and
|
|
// target_http.go. Each is bounded only by the 1 MB form body
|
|
// cap, so a 100 KB name writes one line of roughly 600 KB.
|
|
// Deliberate: truncating the operator's own configuration
|
|
// echoed back costs debuggability against no adversary.
|
|
// - The "log" delivery target, which exists to write the whole
|
|
// inbound event to the log. Deliberate; see
|
|
// internal/delivery/target_log.go.
|
|
// - The widest line the service can write, which is neither an
|
|
// access log line nor client-chosen. A handler panic arrives
|
|
// through net/http's nil ErrorLog as one record carrying a
|
|
// whole goroutine stack, above this figure — measured at
|
|
// roughly 2,770 bytes. The exact width is not an invariant: it
|
|
// moves with the goroutine number and with the source paths
|
|
// baked into the stack. That it exceeds this ceiling does not
|
|
// move. The value is the runtime's, not a client's; see
|
|
// README.md and
|
|
// https://git.eeqj.de/sneak/webhooker/issues/187.
|
|
MaxAccessLogLineBytes = 2560
|
|
)
|
|
|
|
//nolint:revive // MiddlewareParams is a standard fx naming convention.
|
|
type MiddlewareParams struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
Session *session.Session
|
|
}
|
|
|
|
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
|
// metrics.
|
|
type Middleware struct {
|
|
log *slog.Logger
|
|
params *MiddlewareParams
|
|
session *session.Session
|
|
|
|
// loginGuard counts failed credential verifications and bounds
|
|
// concurrent password hashing. It is built on first use so that
|
|
// every construction path gets one; see guard().
|
|
loginGuardOnce sync.Once
|
|
loginGuard *loginGuard
|
|
}
|
|
|
|
// New creates a Middleware from the provided fx parameters.
|
|
//
|
|
//nolint:revive // lc parameter is required by fx even if unused.
|
|
func New(
|
|
lc fx.Lifecycle,
|
|
params MiddlewareParams,
|
|
) (*Middleware, error) {
|
|
s := new(Middleware)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
s.session = params.Session
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// the following is from
|
|
// https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/
|
|
|
|
func ipFromHostPort(hp string) string {
|
|
h, _, err := net.SplitHostPort(hp)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
if len(h) > 0 && h[0] == '[' {
|
|
return h[1 : len(h)-1]
|
|
}
|
|
|
|
return h
|
|
}
|
|
|
|
type loggingResponseWriter struct {
|
|
http.ResponseWriter
|
|
|
|
statusCode int
|
|
}
|
|
|
|
// newLoggingResponseWriter wraps w and records status codes.
|
|
func newLoggingResponseWriter(
|
|
w http.ResponseWriter,
|
|
) *loggingResponseWriter {
|
|
return &loggingResponseWriter{w, http.StatusOK}
|
|
}
|
|
|
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|
lrw.statusCode = code
|
|
lrw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// concreteLogURL renders the request's own URL for the access log
|
|
// branches that keep it, with the query string replaced by a fixed
|
|
// marker.
|
|
//
|
|
// The path on those branches is bounded by the service's routes or by
|
|
// the operator's data — a 2xx on the receiver means the UUID named a
|
|
// stored entrypoint, a 2xx under /s means the file is in the embedded
|
|
// tree. The query is not bounded by anything: /.well-known/healthcheck
|
|
// and /s/* take no authentication and sit behind no rate limiter, and
|
|
// /pages/login behind only the login limiter, so any of them will
|
|
// answer 200 to a URL carrying an arbitrary number of arbitrary bytes
|
|
// after the '?'. Keeping the path and dropping the query is what makes
|
|
// this branch as bounded as the pattern branches below.
|
|
//
|
|
// Nothing debuggable is lost. One route in the service reads a query
|
|
// parameter at all — `page`, on the authenticated pagination links in
|
|
// internal/handlers/source_management.go — and the alternatives that
|
|
// would preserve more (a key count, a key allowlist) all require
|
|
// parsing an attacker-sized query on every request, which is work an
|
|
// unauthenticated client would then be choosing for us.
|
|
func concreteLogURL(r *http.Request) string {
|
|
path := r.URL.EscapedPath()
|
|
|
|
if r.URL.RawQuery == "" && !r.URL.ForceQuery {
|
|
return path
|
|
}
|
|
|
|
return path + redactedQuery
|
|
}
|
|
|
|
// accessLogURL returns the value for the access log's url field.
|
|
//
|
|
// 2xx and 5xx responses get the concrete path (see concreteLogURL). A
|
|
// success resolved against a static route or against the operator's
|
|
// own data — on the receiver, a 2xx means the UUID named a stored
|
|
// entrypoint — and a server error is our own bug, where the exact URL
|
|
// is the primary evidence and which no client can provoke at will.
|
|
//
|
|
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
|
// the outcomes an unauthenticated client drives for free: 404 or 429
|
|
// on any invented /webhook/ path, 303 to the login page on any
|
|
// invented /user/ path. Logging the concrete URL there lets a flood
|
|
// write attacker-chosen text, of attacker-chosen length, into the
|
|
// operator's log at one line per request. The pattern comes from the
|
|
// router's own table, so it is bounded by the service's routes while
|
|
// still naming which class of request was rejected.
|
|
//
|
|
// The pattern is only populated once routing has run, so this must be
|
|
// called after the handler returns, not before.
|
|
func accessLogURL(r *http.Request, status int) string {
|
|
if status < http.StatusMultipleChoices ||
|
|
status >= http.StatusInternalServerError {
|
|
return concreteLogURL(r)
|
|
}
|
|
|
|
if rc := chi.RouteContext(r.Context()); rc != nil {
|
|
if pattern := rc.RoutePattern(); pattern != "" {
|
|
return pattern
|
|
}
|
|
}
|
|
|
|
return unmatchedRoute
|
|
}
|
|
|
|
// Logging returns middleware that logs each HTTP request with
|
|
// timing and metadata.
|
|
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
start := time.Now()
|
|
lrw := newLoggingResponseWriter(w)
|
|
ctx := r.Context()
|
|
|
|
defer func() {
|
|
latency := time.Since(start)
|
|
requestID := ""
|
|
|
|
if reqID := ctx.Value(
|
|
middleware.RequestIDKey,
|
|
); reqID != nil {
|
|
if id, ok := reqID.(string); ok {
|
|
requestID = id
|
|
}
|
|
}
|
|
|
|
// Every field below that a client can influence is
|
|
// truncated to a fixed budget, so the size of this
|
|
// line does not track the size of the request.
|
|
s.log.Info("http request",
|
|
"request_start", start,
|
|
"method", logfield.Truncate(
|
|
r.Method, maxLogMethodBytes,
|
|
),
|
|
"url", logfield.Truncate(
|
|
accessLogURL(r, lrw.statusCode),
|
|
logfield.MaxBytes,
|
|
),
|
|
"useragent", logfield.Truncate(
|
|
r.UserAgent(), logfield.MaxBytes,
|
|
),
|
|
"request_id", logfield.Truncate(
|
|
requestID, maxLogRequestIDBytes,
|
|
),
|
|
"referer", logfield.Truncate(
|
|
r.Referer(), logfield.MaxBytes,
|
|
),
|
|
"proto", r.Proto,
|
|
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
|
"status", lrw.statusCode,
|
|
"latency_ms", latency.Milliseconds(),
|
|
)
|
|
}()
|
|
|
|
next.ServeHTTP(lrw, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// CORS returns middleware that sets CORS headers (permissive in
|
|
// dev, no-op in prod).
|
|
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
|
if s.params.Config.IsDev() {
|
|
// In development, allow any origin for local testing.
|
|
return cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{
|
|
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
|
},
|
|
AllowedHeaders: []string{
|
|
"Accept", "Authorization",
|
|
"Content-Type", "X-CSRF-Token",
|
|
},
|
|
ExposedHeaders: []string{"Link"},
|
|
AllowCredentials: false,
|
|
MaxAge: corsMaxAge,
|
|
})
|
|
}
|
|
|
|
// In production, the web UI is server-rendered so
|
|
// cross-origin requests are not expected. Return a no-op
|
|
// middleware.
|
|
return func(next http.Handler) http.Handler {
|
|
return next
|
|
}
|
|
}
|
|
|
|
// RequireAuth returns middleware that checks for a valid session.
|
|
// Unauthenticated users are redirected to the login page.
|
|
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
sess, err := s.session.Get(r)
|
|
if err != nil {
|
|
s.log.Debug(
|
|
"auth middleware: failed to get session",
|
|
"error", err,
|
|
)
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// IsAuthenticated also enforces both session expiry
|
|
// deadlines, so an idle-expired or absolutely-expired
|
|
// session lands here and is sent back to the login
|
|
// page.
|
|
if !s.session.IsAuthenticated(sess) {
|
|
// This is the unauthenticated branch, so both
|
|
// fields are entirely client-chosen and neither
|
|
// is bounded by anything the router did. DEBUG
|
|
// is off by default, but turning it on to
|
|
// diagnose a problem must not hand a client an
|
|
// unbounded write into the log, so the same
|
|
// budgets apply here as in the access log.
|
|
s.log.Debug(
|
|
"auth middleware: unauthenticated request",
|
|
"path", logfield.Truncate(
|
|
r.URL.Path, logfield.MaxBytes,
|
|
),
|
|
"method", logfield.Truncate(
|
|
r.Method, maxLogMethodBytes,
|
|
),
|
|
)
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// This request authenticated with the session, so it
|
|
// counts as activity: push the idle deadline forward.
|
|
// This is the only place sessions are refreshed, which
|
|
// is what keeps an unauthenticated request from
|
|
// extending someone else's session. Touch advances the
|
|
// idle clock only -- the absolute cap is untouched --
|
|
// and reports false when nothing changed, so most
|
|
// requests do not re-issue the cookie. Save before the
|
|
// handler runs, while the headers are still ours to
|
|
// write.
|
|
if s.session.Touch(sess) {
|
|
saveErr := s.session.Save(r, w, sess)
|
|
if saveErr != nil {
|
|
s.log.Error(
|
|
"auth middleware: failed to refresh session",
|
|
"error", saveErr,
|
|
)
|
|
}
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Metrics returns middleware that records Prometheus HTTP metrics.
|
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
|
mdlw := ghmm.New(ghmm.Config{
|
|
Recorder: metrics.NewRecorder(metrics.Config{}),
|
|
})
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return std.Handler("", mdlw, next)
|
|
}
|
|
}
|
|
|
|
// MetricsAuth returns middleware that protects metrics endpoints
|
|
// with basic auth.
|
|
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|
return basicauth.New(
|
|
"metrics",
|
|
map[string][]string{
|
|
s.params.Config.MetricsUsername: {
|
|
s.params.Config.MetricsPassword,
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
// SecurityHeaders returns middleware that sets production security
|
|
// headers on every response: HSTS, X-Content-Type-Options,
|
|
// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy.
|
|
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
w.Header().Set(
|
|
"Strict-Transport-Security",
|
|
"max-age=63072000; includeSubDomains; preload",
|
|
)
|
|
w.Header().Set(
|
|
"X-Content-Type-Options", "nosniff",
|
|
)
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; "+
|
|
"script-src 'self' 'unsafe-inline'; "+
|
|
"style-src 'self' 'unsafe-inline'",
|
|
)
|
|
w.Header().Set(
|
|
"Referrer-Policy",
|
|
"strict-origin-when-cross-origin",
|
|
)
|
|
w.Header().Set(
|
|
"Permissions-Policy",
|
|
"camera=(), microphone=(), geolocation=()",
|
|
)
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// NoCache returns middleware that instructs browsers and
|
|
// intermediary proxies not to cache the response. It sets
|
|
// Cache-Control: no-store and Pragma: no-cache (the latter for
|
|
// older HTTP/1.0 intermediaries). Apply it to authenticated pages
|
|
// so webhook configuration and captured event data are not stored
|
|
// by caches.
|
|
func (s *Middleware) NoCache() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// bodyLimitedMethod reports whether the request method carries a
|
|
// body that the MaxBodySize middleware should cap.
|
|
func bodyLimitedMethod(method string) bool {
|
|
return method == http.MethodPost ||
|
|
method == http.MethodPut ||
|
|
method == http.MethodPatch
|
|
}
|
|
|
|
// MaxBodySize returns middleware that limits the size of
|
|
// POST/PUT/PATCH request bodies to maxBytes. It must be registered
|
|
// before any middleware that parses the body — notably CSRF, which
|
|
// calls r.PostFormValue — so that form parsing happens under this
|
|
// cap rather than net/http's 10 MB default.
|
|
//
|
|
// Two enforcement paths exist, because http.MaxBytesReader alone
|
|
// cannot produce a 413: it reports the overflow as an error from
|
|
// Read, by which point the body parser downstream has already
|
|
// converted that error into its own response.
|
|
//
|
|
// - Declared oversize: the request announces a Content-Length
|
|
// greater than maxBytes. The middleware answers 413 Request
|
|
// Entity Too Large immediately and does not call the next
|
|
// handler, so neither CSRF nor the endpoint handler runs.
|
|
// - Undeclared oversize: the request is chunked (Content-Length
|
|
// of -1) or lies about its Content-Length. There is nothing to
|
|
// check up front, so http.MaxBytesReader hard-caps the body at
|
|
// maxBytes and the request fails downstream — the form parse
|
|
// errors out and CSRF rejects it with 403. The response is less
|
|
// precise than a 413, but the body is still never buffered
|
|
// beyond the cap, which is the property that matters.
|
|
func (s *Middleware) MaxBodySize(
|
|
maxBytes int64,
|
|
) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if !bodyLimitedMethod(r.Method) {
|
|
next.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
if r.ContentLength > maxBytes {
|
|
// This runs ahead of RequireAuth (see
|
|
// setupUserRoutes and friends in
|
|
// internal/server/routes.go), so an
|
|
// unauthenticated client reaches it with a path
|
|
// of its own choosing and its own length —
|
|
// POST /source/<8 KB>/edit with an oversize
|
|
// declared Content-Length costs nothing to
|
|
// send. At WARN, on by default, that is a
|
|
// write into the operator's log sized by the
|
|
// attacker unless the path is capped. Same
|
|
// budgets as the access log, so this line
|
|
// cannot be wider than that one.
|
|
s.log.Warn(
|
|
"request body exceeds limit",
|
|
"method", logfield.Truncate(
|
|
r.Method, maxLogMethodBytes,
|
|
),
|
|
"path", logfield.Truncate(
|
|
r.URL.Path, logfield.MaxBytes,
|
|
),
|
|
"content_length", r.ContentLength,
|
|
"limit", maxBytes,
|
|
)
|
|
http.Error(
|
|
w,
|
|
"Request Entity Too Large",
|
|
http.StatusRequestEntityTooLarge,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|