All checks were successful
check / check (push) Successful in 2m52s
MaxBodySize logged r.URL.Path untruncated at WARN, and routes.go registers it ahead of RequireAuth, so an unauthenticated POST /source/<8 KB>/edit with an oversize declared Content-Length wrote attacker-chosen text of attacker-chosen length into the operator's log, for the cost of a request with no body. The 2,560-byte per-line budget #146 established did not reach it: that budget lives in the access-log field capping and this is a separate slog call. The capping mechanism moves out of internal/middleware into internal/logfield so there is one budget and one implementation rather than a second ad-hoc truncation. Truncate and EncodedBytes are unchanged; the access log now spends logfield.MaxBytes where it spent maxLogFieldBytes. The sweep the issue asked for found five more call sites of the same shape, all reachable unauthenticated, all now capped: the CSRF 403 (also registered ahead of RequireAuth), the rate limiters' 429 (the per-entrypoint receiver limiter is unauthenticated), RequireAuth's own DEBUG line, the unknown-entrypoint DEBUG line on the receiver, and the failed-login DEBUG lines. DEBUG being off by default is not a bound: an operator turning it on to diagnose a flood must not thereby hand the flood an unbounded write. Every other slog call in the tree was read and judged; the PR body lists all of them, including the ones left alone and why. MaxBodySize stays ahead of RequireAuth. An oversize body should be refused before the request buys a cookie decrypt and a session load, and rejecting first is what keeps an unauthenticated flood from choosing how much session work the process does. The ordering and what it costs are now written at the registration, on maxFormBodySize. MaxAccessLogLineBytes is restated as the ceiling on every slog line carrying a client-supplied value, not just the access log's: each of these lines carries strictly fewer client-supplied fields than the access log does, so none can be wider. That is asserted per line under both handlers rather than argued. Two writers are called out as NOT covered, so the figure is not read as more than it is: the log delivery target, which exists to emit the whole event and is deliberate, and GORM's default logger, which prints the interpolated SQL to stdout on a record-not-found and is unbounded on the receiver and login lookups. That second one is a real defect this audit turned up and is filed separately as #178, not fixed here. Tests drive 8 KB of client-chosen text at all six sites, through both handlers internal/logger can install and through each character they escape — including a bare C0 control, which costs six bytes on the line against the one it cost to send and is the case a raw-byte budget breaks on first. Each holds the encoded line to the ceiling, holds the whole flood's output to what that ceiling allows, and asserts the markers at the far end of the input are absent, so a value that merely happened to be short cannot pass. internal/logfield gains a test that measures the per-rune charge against what the handlers really emit over roughly 3,000 code points on each, so an undercharged rune fails a test instead of quietly falsifying the ceiling. Verified by mutation: reverting the MaxBodySize cap alone fails 12 subtests with a 16,583-byte line against the 2,560 ceiling; reverting the other five fails 70; budgeting raw bytes instead of encoded ones fails 23 across three packages.
99 lines
3.8 KiB
Go
99 lines
3.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/gorilla/csrf"
|
|
"sneak.berlin/go/webhooker/internal/logfield"
|
|
)
|
|
|
|
// CSRFToken retrieves the CSRF token from the request context.
|
|
// Returns an empty string if the gorilla/csrf middleware has not run.
|
|
func CSRFToken(r *http.Request) string {
|
|
return csrf.Token(r)
|
|
}
|
|
|
|
// isClientTLS reports whether the client-facing connection uses TLS.
|
|
// It checks for a direct TLS connection (r.TLS) or a TLS-terminating
|
|
// reverse proxy that sets the standard X-Forwarded-Proto header.
|
|
func isClientTLS(r *http.Request) bool {
|
|
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
|
}
|
|
|
|
// CSRF returns middleware that provides CSRF protection using the
|
|
// gorilla/csrf library. The middleware uses the session authentication
|
|
// key to sign a CSRF cookie and validates a masked token submitted via
|
|
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
|
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
|
// token receive a 403 Forbidden response.
|
|
//
|
|
// The middleware detects the client-facing transport protocol per-request
|
|
// using r.TLS and the X-Forwarded-Proto header. This allows correct
|
|
// behavior in all deployment scenarios:
|
|
//
|
|
// - Direct HTTPS: strict Referer/Origin checks, Secure cookies.
|
|
// - Behind a TLS-terminating reverse proxy: strict checks (the
|
|
// browser is on HTTPS, so Origin/Referer headers use https://),
|
|
// Secure cookies (the browser sees HTTPS from the proxy).
|
|
// - Direct HTTP: relaxed Referer/Origin checks via PlaintextHTTPRequest,
|
|
// non-Secure cookies so the browser sends them over HTTP.
|
|
//
|
|
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
|
// (for TLS) and one without (for plaintext HTTP) — because the
|
|
// csrf.Secure option is set at creation time, not per-request.
|
|
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
|
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// CSRF is registered ahead of RequireAuth on every route
|
|
// group that uses it, so this WARN is reachable by an
|
|
// unauthenticated client: a POST with no token to
|
|
// /source/<any length of any text>/edit lands here. The
|
|
// method and path are capped against the same budgets as
|
|
// the access log. remote_addr is set by net/http from the
|
|
// accepted connection rather than by the client, and
|
|
// csrf.FailureReason returns one of gorilla/csrf's own
|
|
// fixed error values, so neither is client-sized.
|
|
m.log.Warn("csrf: token validation failed",
|
|
"method", logfield.Truncate(
|
|
r.Method, maxLogMethodBytes,
|
|
),
|
|
"path", logfield.Truncate(
|
|
r.URL.Path, logfield.MaxBytes,
|
|
),
|
|
"remote_addr", r.RemoteAddr,
|
|
"reason", csrf.FailureReason(r),
|
|
)
|
|
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
|
})
|
|
|
|
key := m.session.GetKey()
|
|
baseOpts := []csrf.Option{
|
|
csrf.FieldName("csrf_token"),
|
|
csrf.SameSite(csrf.SameSiteLaxMode),
|
|
csrf.Path("/"),
|
|
csrf.ErrorHandler(csrfErrorHandler),
|
|
}
|
|
|
|
// Two middleware instances with different Secure flags but the
|
|
// same signing key, so cookies are interchangeable between them.
|
|
tlsProtect := csrf.Protect(key, append(baseOpts, csrf.Secure(true))...)
|
|
httpProtect := csrf.Protect(key, append(baseOpts, csrf.Secure(false))...)
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
tlsCSRF := tlsProtect(next)
|
|
httpCSRF := httpProtect(next)
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if isClientTLS(r) {
|
|
// Client is on TLS (directly or via reverse proxy).
|
|
// Use Secure cookies and strict Origin/Referer checks.
|
|
tlsCSRF.ServeHTTP(w, r)
|
|
} else {
|
|
// Plaintext HTTP: use non-Secure cookies and tell
|
|
// gorilla/csrf to use "http" for scheme comparisons,
|
|
// skipping the strict Referer check that assumes TLS.
|
|
httpCSRF.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
|
}
|
|
})
|
|
}
|
|
}
|