Go MaxBytesReader: cap a Sume webhook body at 1 MiB, 413 before HMAC

A webhook endpoint that reads an unbounded body invites memory abuse. A Go handler with http.MaxBytesReader, an HMAC check and a refusal of empty secrets.

4 min readSume
All posts

A public webhook URL accepts POSTs from anyone, and io.ReadAll(r.Body) will read as much as is sent before you can check a signature. Wrap the body with http.MaxBytesReader first so an oversized request fails at the limit and answers 413. Sume's webhook bodies are small job events, so 1 MiB is far above anything legitimate.

The handler below runs on Go 1.27.1. In a local test it returned 200 for a valid signed request, 413 for a 2 MB body, and 401 for a wrong signature; calling it with an empty secret panics at construction, by design.

The handler

Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends x-sume-webhook-signature: sume-v1=<hex> (comma-separated entries during a secret rotation). This version checks the signature of the bytes it read and nothing else, so see the stale-timestamp post for the 300-second window.

package main

import ("crypto/hmac"; "crypto/sha256"; "encoding/hex"; "fmt"; "io"; "net/http"; "os"; "strconv"; "strings")

func handler(secret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // 1 MiB cap
		if err != nil { http.Error(w, "too large", http.StatusRequestEntityTooLarge); return }
		ts, _ := strconv.Atoi(r.Header.Get("x-sume-webhook-timestamp"))
		m := hmac.New(sha256.New, []byte(secret))
		m.Write([]byte(fmt.Sprintf("%d.", ts))); m.Write(body)
		want := hex.EncodeToString(m.Sum(nil))
		for _, e := range strings.Split(r.Header.Get("x-sume-webhook-signature"), ",") {
			if hmac.Equal([]byte(strings.TrimPrefix(strings.TrimSpace(e), "sume-v1=")), []byte(want)) {
				w.WriteHeader(http.StatusOK); return
			}
		}
		http.Error(w, "bad signature", http.StatusUnauthorized)
	}
}

func main() {
	secret := os.Getenv("SUME_COM_WEBHOOK_SIGNING_SECRET")
	if secret == "" { panic("refusing to start with an empty signing secret") }
	http.Handle("/hook", handler(secret))
	http.ListenAndServe(":8894", nil)
}

Order matters

Cap the size, read the raw bytes, compare the HMAC with hmac.Equal, and only then parse the JSON. Re-encoding the body before the check changes the bytes and breaks the signature. Return 2xx quickly once verified and do the real work afterwards, because Sume retries a non-2xx delivery.

Fetch the signing secret from GET /v1/webhooks/signing-secret and keep it in your secret store, not in the source file.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume