Security: Extend request body size cap to all JSON API endpoints (GHSA-28pq-6qxg-wg5r)

The fix for GHSA-fpxj-m5q8-fphw only capped POST /api/v1/send.
Four sibling endpoints (SetReadStatus, DeleteMessages, SetMessageTags,
ReleaseMessage) decoded json.NewDecoder(r.Body) with no size limit,
allowing an unauthenticated attacker to drive unbounded memory growth
via a large IDs array.

Apply a 5 MB cap in middleWareFunc so all current and future API
handlers inherit it automatically. POST /api/v1/send is exempt via a
bodyLimitKey context value set in sendAPIAuthMiddleware, preserving
its existing config.MaxMessageSize (default 50 MB) limit.

Also fix TestAPIv1SendMaxMessageSize, which was broken by a Go 1.26
change: json.Decoder now wraps reader errors in *json.SyntaxError
rather than returning *http.MaxBytesError directly, causing the
errors.As check to miss it and return 400 instead of 413. Reading
the body with io.ReadAll before decoding surfaces the raw error,
restoring correct 413 behaviour on Go 1.25 and 1.26.
This commit is contained in:
Ralph Slooten
2026-05-28 19:39:17 +12:00
parent fdf3cde030
commit 5754c821d3
2 changed files with 32 additions and 6 deletions

View File

@@ -47,11 +47,11 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
}
decoder := json.NewDecoder(r.Body)
data := sendMessageParams{}
if err := decoder.Decode(&data.Body); err != nil {
// Read body before decoding so that MaxBytesReader errors are returned directly.
// In Go 1.26+, json.Decoder wraps reader errors in *json.SyntaxError, which
// prevents errors.As from finding *http.MaxBytesError to return a 413.
body, err := io.ReadAll(r.Body)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
@@ -60,6 +60,13 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
return
}
data := sendMessageParams{}
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&data.Body); err != nil {
httpJSONError(w, err.Error())
return
}
var httpAuthUser *string
if user, _, ok := r.BasicAuth(); ok {
httpAuthUser = &user

View File

@@ -41,7 +41,14 @@ var (
// auth.UICredentials pointer (which is a data race under concurrent load).
type contextKey int
const skipUIAuthKey contextKey = iota
const (
skipUIAuthKey contextKey = iota
// bodyLimitKey carries an optional request body size cap (in bytes) through the
// context. middleWareFunc reads it and applies it instead of the default 5 MB cap.
// A value of 0 means unlimited. Used by sendAPIAuthMiddleware to honour
// config.MaxMessageSize for the send endpoint.
bodyLimitKey
)
// Listen will start the httpd
func Listen() {
@@ -232,6 +239,10 @@ func basicAuthResponse(w http.ResponseWriter) {
// auth.UICredentials pointer, which would be a data race under concurrent load.
func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Override the default 5 MB body cap with the send-specific limit so that
// middleWareFunc applies config.MaxMessageSize (0 = unlimited) instead.
r = r.WithContext(context.WithValue(r.Context(), bodyLimitKey, int64(config.MaxMessageSize)*1024*1024))
// If send API auth accept any is enabled, bypass all authentication.
if config.SendAPIAuthAcceptAny {
ctx := context.WithValue(r.Context(), skipUIAuthKey, true)
@@ -277,6 +288,14 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
// and gzip compression.
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Limit request body size to 5 MB to prevent memory-exhaustion DoS via large
// JSON bodies. sendAPIAuthMiddleware sets bodyLimitKey in the context to signal
// that the handler manages its own limit (send.go uses config.MaxMessageSize),
// so we skip the cap here for that route only.
if _, ok := r.Context().Value(bodyLimitKey).(int64); !ok {
r.Body = http.MaxBytesReader(w, r.Body, 5*1024*1024)
}
w.Header().Set("Referrer-Policy", "no-referrer")
// generate a new random nonce on every request