From da668863a1124548151468f81ed63404ea58d096 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 6 Apr 2026 08:12:50 +0200 Subject: [PATCH] Harden /audio/stream uploads --- docs/API.md | 3 ++- docs/pro-runtime-hardening-workboard.md | 2 ++ internal/control/control.go | 11 +++++++++++ internal/control/control_test.go | 22 ++++++++++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index e7f89b0..ce1538b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -294,7 +294,7 @@ Push raw audio data into the live stream buffer. Format: **S16LE stereo PCM** at Requires `--audio-stdin`, `--audio-http`, or another configured stream source to feed the buffer. -**Request:** Binary body, `application/octet-stream`, raw S16LE stereo PCM bytes. Set `Content-Type` to `application/octet-stream` or `audio/L16`; other media types are rejected. +**Request:** Binary body, `application/octet-stream`, raw S16LE stereo PCM bytes. Set `Content-Type` to `application/octet-stream` or `audio/L16`; other media types are rejected. Requests larger than 512 MiB are rejected with `413 Request Entity Too Large`. **Response:** ```json @@ -325,6 +325,7 @@ ffmpeg -i song.mp3 -f s16le -ar 44100 -ac 2 - | \ **Errors:** - `405` if not POST - `415` if Content-Type is missing or unsupported (must be `application/octet-stream` or `audio/L16`) +- `413` if the upload body exceeds the 512 MiB limit - `503` if no audio stream configured --- diff --git a/docs/pro-runtime-hardening-workboard.md b/docs/pro-runtime-hardening-workboard.md index 304d61f..46473dc 100644 --- a/docs/pro-runtime-hardening-workboard.md +++ b/docs/pro-runtime-hardening-workboard.md @@ -443,12 +443,14 @@ Diese Punkte könnten ggf. vorgezogen werden, auch wenn WS-05 formal nach WS-01/ ## WS-05 Entscheidungslog - 2026-04-06: `/audio/stream` now enforces a binary `Content-Type` (`application/octet-stream` or `audio/L16`) before queuing any samples. +- 2026-04-06: `/audio/stream` caps uploads at 512 MiB and rejects larger bodies with `413 Request Entity Too Large` before touching the ring buffer. ## WS-05 Verifikation | Datum | Fokus | Ergebnis | |---|---|---| | 2026-04-05 | `/audio/stream` rejects non-POST requests | `TestAudioStreamRejectsNonPost` enforces POST-only access to `/audio/stream` before a stream source is configured | | 2026-04-06 | `/audio/stream` enforces binary Content-Type headers | `TestAudioStreamRejectsMissingContentType` and `TestAudioStreamRejectsUnsupportedContentType` confirm 415 when the media type is missing or wrong | +| 2026-04-06 | `/audio/stream` rejects oversized uploads | `TestAudioStreamRejectsBodyTooLarge` confirms a 413 Request Entity Too Large before buffering when the HTTP body exceeds the 512 MiB guard | --- diff --git a/internal/control/control.go b/internal/control/control.go index 283ac96..25f5386 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -3,6 +3,7 @@ package control import ( _ "embed" "encoding/json" + "errors" "io" "mime" "net/http" @@ -56,6 +57,7 @@ const ( configContentTypeHeader = "application/json" noBodyErrMsg = "request must not include a body" audioStreamContentTypeError = "Content-Type must be application/octet-stream or audio/L16" + audioStreamBodyLimitDefault = 512 << 20 // 512 MiB ) var audioStreamAllowedMediaTypes = []string{ @@ -63,6 +65,8 @@ var audioStreamAllowedMediaTypes = []string{ "audio/l16", } +var audioStreamBodyLimit = int64(audioStreamBodyLimitDefault) // bytes allowed per /audio/stream request; tests may override. + func isJSONContentType(r *http.Request) bool { ct := strings.TrimSpace(r.Header.Get("Content-Type")) if ct == "" { @@ -284,6 +288,8 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { return } + r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit) + // Read body in chunks and push to ring buffer buf := make([]byte, 32768) totalFrames := 0 @@ -296,6 +302,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { if err == io.EOF { break } + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + http.Error(w, maxErr.Error(), http.StatusRequestEntityTooLarge) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/control/control_test.go b/internal/control/control_test.go index e20a0b3..8195b67 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -388,6 +388,28 @@ func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) { } } +func TestAudioStreamRejectsBodyTooLarge(t *testing.T) { + orig := audioStreamBodyLimit + t.Cleanup(func() { + audioStreamBodyLimit = orig + }) + audioStreamBodyLimit = 1024 + limit := int(audioStreamBodyLimit) + body := make([]byte, limit+1) + srv := NewServer(cfgpkg.Default()) + srv.SetStreamSource(audio.NewStreamSource(256, 44100)) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/octet-stream") + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413 for oversized body, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "request body too large") { + t.Fatalf("unexpected response body: %q", rec.Body.String()) + } +} + func TestTXStartWithoutController(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder()