7cd6fbf571
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>
116 lines
4.5 KiB
Go
116 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|