Cap request body size and add baseline security headers

Part of a security-hardening pass (see wiki for the full backlog).
decodeJSON had no size limit at all, so every JSON endpoint -- including
the two unauthenticated ones (bootstrap, the Alertmanager webhook) --
would buffer an attacker-supplied body of unbounded size before it was
even validated. decodeJSON now wraps the body in http.MaxBytesReader at
a 1 MiB default; the webhook gets its own 8 MiB cap via decodeJSONLimit,
since a real Alertmanager batch can be bigger than an ordinary API body.

Also adds a securityHeaders middleware, applied globally: nosniff on
every response (previously only the static site got it), and HSTS
(180-day max-age, conservative on purpose) whenever cookieSecure's
signal says the browser is on HTTPS. Checked the chart/gateway config
first -- neither sets HSTS anywhere, so this was a real gap.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Niklas Ye
2026-10-07 21:57:00 +02:00
parent df83adfe47
commit 7cd6fbf571
5 changed files with 162 additions and 1 deletions
+7 -1
View File
@@ -94,10 +94,16 @@ func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc
}
}
// maxWebhookBodyBytes is larger than maxBodyBytes: a real Alertmanager batch
// can carry many alerts, each with several labels and annotations, and the
// sender is a trusted piece of infrastructure rather than an arbitrary
// caller.
const maxWebhookBodyBytes = 8 << 20
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, src alertSource) {
teamID := src.teamID
var payload amPayload
if err := decodeJSON(r, &payload); err != nil {
if err := decodeJSONLimit(r, &payload, maxWebhookBodyBytes); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid payload"))
return
}
+17
View File
@@ -72,8 +72,25 @@ func respond(w http.ResponseWriter, status int, v any) {
json.NewEncoder(w).Encode(v)
}
// maxBodyBytes caps an ordinary JSON request body. 1 MiB is far more than any
// endpoint below needs — it exists so an unauthenticated caller (signup,
// login, bootstrap) can't make the server buffer an arbitrarily large body
// before the request is even validated.
const maxBodyBytes = 1 << 20
func decodeJSON(r *http.Request, v any) error {
return decodeJSONLimit(r, v, maxBodyBytes)
}
// decodeJSONLimit is decodeJSON with an explicit cap, for the one endpoint
// (the Alertmanager webhook, see maxWebhookBodyBytes) whose real payloads can
// legitimately be larger than maxBodyBytes.
func decodeJSONLimit(r *http.Request, v any, limit int64) error {
defer r.Body.Close()
// w is nil: there is no ResponseWriter here to disable keep-alive with,
// which net/http documents as fine — the limit is still enforced, the
// connection just isn't closed early on a request that blows past it.
r.Body = http.MaxBytesReader(nil, r.Body, limit)
return json.NewDecoder(r.Body).Decode(v)
}
+22
View File
@@ -79,6 +79,28 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
}
}
// securityHeaders sets headers that cost nothing to send on every response,
// API or static site alike. nosniff is unconditional; HSTS only fires once
// cookieSecure's signal says the browser is actually looking at this server
// over HTTPS — TLS terminates at the gateway, which (as of this writing) sets
// neither header itself.
//
// max-age is 180 days rather than the usual year-plus: short enough that if
// HTTPS here ever broke for real, the header would age out of a browser's
// cache well within a release cycle instead of locking anyone out of a
// working server. Raise it once this has run clean for a while.
func securityHeaders(publicURL string) 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("X-Content-Type-Options", "nosniff")
if cookieSecure(publicURL, r) {
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
}
// AdminOnly rejects a caller who is not a system administrator. It runs inside
// AuthMiddleware's group, so by the time it sees a request the caller is known.
//
+1
View File
@@ -30,6 +30,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(securityHeaders(notify.PublicURL))
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, map[string]string{"status": "ok"})
+115
View File
@@ -0,0 +1,115 @@
package api_test
import (
"bytes"
"net/http"
"strings"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// ---------------------------------------------------------------------------
// Security headers
// ---------------------------------------------------------------------------
func TestSecurityHeaders_NosniffAlwaysSet(t *testing.T) {
s := newTS(t) // no PublicURL: the HTTPS signal is off
resp := s.req(t, http.MethodGet, "/api/me", nil)
defer resp.Body.Close()
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
t.Errorf("Strict-Transport-Security = %q, want unset without an https PublicURL", got)
}
}
func TestSecurityHeaders_HSTSWhenPublicURLIsHTTPS(t *testing.T) {
s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
resp := s.req(t, http.MethodGet, "/api/me", nil)
defer resp.Body.Close()
got := resp.Header.Get("Strict-Transport-Security")
if !strings.HasPrefix(got, "max-age=") || !strings.Contains(got, "includeSubDomains") {
t.Errorf("Strict-Transport-Security = %q, want a max-age with includeSubDomains", got)
}
}
// ---------------------------------------------------------------------------
// Request body size limits
// ---------------------------------------------------------------------------
// TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody confirms an
// unauthenticated endpoint can't be made to buffer an arbitrarily large body:
// past maxBodyBytes, decodeJSON fails exactly as it would on any other
// malformed body, rather than the server reading the whole thing first.
func TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody(t *testing.T) {
s := newTS(t)
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB, past the 1 MiB default
body := []byte(`{"username":"` + string(huge) + `","password":"x"}`)
resp, err := http.Post(s.URL+"/api/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST /api/login: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want %d (oversized body treated as invalid)", resp.StatusCode, http.StatusBadRequest)
}
}
// TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault confirms the
// Alertmanager webhook's separate, larger cap actually takes effect: a body
// bigger than the ordinary default but within maxWebhookBodyBytes is still
// accepted, not rejected by the smaller limit every other endpoint gets.
func TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault(t *testing.T) {
s := newTS(t)
// Padding kept inside one alert's annotation, comfortably past the 1 MiB
// default and still well under the webhook's 8 MiB cap.
padding := strings.Repeat("a", 3<<20) // 3 MiB
payload := `{"version":"4","status":"firing","groupKey":"big-group",` +
`"groupLabels":{"alertname":"BigAlert"},"alerts":[{"status":"firing",` +
`"labels":{"alertname":"BigAlert"},"annotations":{"note":"` + padding + `"},` +
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
`"fingerprint":"fp-big"}]}`
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
"application/json", strings.NewReader(payload))
if err != nil {
t.Fatalf("POST webhook: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d (body under the webhook's own cap)", resp.StatusCode, http.StatusOK)
}
}
// TestBodySizeLimit_WebhookRejectsPastItsOwnCap confirms the webhook's larger
// cap is still a cap, not an exemption from one.
func TestBodySizeLimit_WebhookRejectsPastItsOwnCap(t *testing.T) {
s := newTS(t)
huge := strings.Repeat("a", 9<<20) // 9 MiB, past the 8 MiB webhook cap
payload := `{"version":"4","status":"firing","groupKey":"huge-group",` +
`"groupLabels":{"alertname":"HugeAlert"},"alerts":[{"status":"firing",` +
`"labels":{"alertname":"HugeAlert"},"annotations":{"note":"` + huge + `"},` +
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
`"fingerprint":"fp-huge"}]}`
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
"application/json", strings.NewReader(payload))
if err != nil {
t.Fatalf("POST webhook: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want %d (body past the webhook's own cap)", resp.StatusCode, http.StatusBadRequest)
}
}