From b51a7da522847ab20f7241d906f0b314217717c3 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Mon, 6 Apr 2026 07:26:20 +0200 Subject: [PATCH] control: enforce JSON content type for config API --- docs/API.md | 2 ++ internal/control/control.go | 22 +++++++++++++++++++- internal/control/control_test.go | 35 ++++++++++++++++++++++++++++---- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/docs/API.md b/docs/API.md index bde17a6..c5fdebc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -144,6 +144,8 @@ The control snapshot (GET /config) only reflects new values once they pass valid **Request body:** JSON with any subset of patchable fields. +**Content-Type:** `application/json` (charset parameters allowed). Requests without it are rejected with 415 Unsupported Media Type. + **Response:** ```json {"ok": true, "live": true} diff --git a/internal/control/control.go b/internal/control/control.go index 7f98c03..dd1ac59 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -4,6 +4,7 @@ import ( _ "embed" "encoding/json" "io" + "mime" "net/http" "strings" "sync" @@ -50,7 +51,22 @@ type Server struct { streamSrc *audio.StreamSource // optional, for live audio ingest } -const maxConfigBodyBytes = 64 << 10 // 64 KiB +const ( + maxConfigBodyBytes = 64 << 10 // 64 KiB + configContentTypeHeader = "application/json" +) + +func isJSONContentType(r *http.Request) bool { + ct := strings.TrimSpace(r.Header.Get("Content-Type")) + if ct == "" { + return false + } + mediaType, _, err := mime.ParseMediaType(ct) + if err != nil { + return false + } + return strings.EqualFold(mediaType, configContentTypeHeader) +} type ConfigPatch struct { FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` @@ -299,6 +315,10 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(cfg) case http.MethodPost: + if !isJSONContentType(r) { + http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) + return + } r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes) var patch ConfigPatch if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 6d883c5..8a86cd2 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -127,7 +127,7 @@ func TestConfigPatch(t *testing.T) { srv := NewServer(cfgpkg.Default()) body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))) + srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != 200 { t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) } @@ -139,13 +139,34 @@ func TestConfigPatchRejectsOversizeBody(t *testing.T) { payload := bytes.Repeat([]byte("x"), maxConfigBodyBytes+32) body := append([]byte(`{"ps":"`), payload...) body = append(body, []byte(`"}`)...) - req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)) + req := newConfigPostRequest(body) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("expected 413, got %d response=%q", rec.Code, rec.Body.String()) } } +func TestConfigPatchRejectsMissingContentType(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`))) + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnsupportedMediaType { + t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code) + } +} + +func TestConfigPatchRejectsNonJSONContentType(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`))) + req.Header.Set("Content-Type", "text/plain") + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnsupportedMediaType { + t.Fatalf("expected 415 for non-JSON Content-Type, got %d", rec.Code) + } +} + func TestRuntimeWithoutDriver(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() @@ -334,7 +355,7 @@ func TestConfigPatchUpdatesSnapshot(t *testing.T) { rec := httptest.NewRecorder() body := []byte(`{"outputDrive":1.2}`) - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))) + srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } @@ -363,7 +384,7 @@ func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) { body := []byte(`{"outputDrive":2.2}`) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))) + srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } @@ -379,6 +400,12 @@ func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) { } } +func newConfigPostRequest(body []byte) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + type fakeTXController struct { updateErr error resetErr error