From 673be16f4f1913806835badf94d3c2e00b0b8eae Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 15 May 2026 14:07:34 +0200 Subject: [PATCH] fix(bmx): restore the Authorization gate on /core02/.../orion/station MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f3a4658 dropped the auth check on HandleOrionPlayback while moving the orion routes to their registry-advertised paths. The rationale at the time was "data is the speaker's own input, nothing privileged" and parity with soundcork's reference impl. On reflection, requiring the Authorization header is the right default here for two reasons: 1. Parity with the rest of our BMX playback surface (TuneIn variants — see TestBMXUnauthorized's table — all gate on a non-empty Authorization header). Orion being the lone unguarded exception was a footgun, not a feature. 2. Real speakers obtain a Bearer token via the orion /token endpoint before they follow a LOCAL_INTERNET_RADIO preset, so the gate doesn't cost any legitimate caller. A callerless GET (curl, scraper, casual probe) gets a clean 401 instead of a working playback resolver. The check itself is the same shape as the other BMX handlers: empty Authorization header → s.writeBMXUnauthorized → 401. Token contents are not validated, only presence — sufficient for the parity contract. Test side: - TestOrionPlayback regains its Bearer header (it had one before the GET-method switch in f3a4658). - TestBMXUnauthorized's table regains a sibling row for the orion station endpoint with the GET + query-string shape. - TestIssue218_OrionStationResolvesPresetStreamURL sends a Bearer header on the loop-closing GET — added with a doc comment naming the orion /token bootstrap a real speaker would do. No route-table changes; the registry advertisement and route paths from f3a4658 stay as they are. Refs #218. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/handlers/handlers_bmx.go | 14 +++++++++++--- pkg/service/handlers/handlers_bmx_test.go | 7 ++----- pkg/service/handlers/issue218_regression_test.go | 12 +++++++++++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index 3d78fac..6431c25 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -182,10 +182,18 @@ func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) { // rewrap it into the Bose BmxPlaybackResponse shape via // bmx.PlayCustomStream. // -// No auth check: the data is the speaker's own input, nothing -// privileged is being protected, and soundcork's reference impl -// behaves the same way. +// Requires a Bearer token in the `Authorization` header — same as +// the rest of the BMX playback surface (TuneIn variants and the +// orion token endpoint). Real speakers obtain the token via +// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken) +// before they ever follow a LOCAL_INTERNET_RADIO preset, so this +// check shouldn't cost any legitimate caller. func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + s.writeBMXUnauthorized(w) + return + } + data := r.URL.Query().Get("data") resp, err := bmx.PlayCustomStream(data) diff --git a/pkg/service/handlers/handlers_bmx_test.go b/pkg/service/handlers/handlers_bmx_test.go index b7be322..180b3bc 100644 --- a/pkg/service/handlers/handlers_bmx_test.go +++ b/pkg/service/handlers/handlers_bmx_test.go @@ -94,6 +94,7 @@ func TestOrionPlayback(t *testing.T) { // emits (Go's url package re-encodes any `=` padding for transport). req, _ := http.NewRequest("GET", ts.URL+"/core02/svc-bmx-adapter-orion/prod/orion/station?data="+url.QueryEscape(data), nil) + req.Header.Set("Authorization", "Bearer mock-token") res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) @@ -170,11 +171,7 @@ func TestBMXUnauthorized(t *testing.T) { {"GET", "/bmx/tunein/v1/playback/station/s123"}, {"GET", "/bmx/tunein/v1/playback/episodes/p123"}, {"GET", "/bmx/tunein/v1/playback/episode/p123"}, - // Note: /core02/.../prod/orion/station is intentionally NOT in this - // list — orion playback takes its `data` blob from the speaker's - // own preset payload, there's no privileged material to gate, and - // soundcork's reference impl makes the same call (no auth on the - // station endpoint). See HandleOrionPlayback's doc comment. + {"GET", "/core02/svc-bmx-adapter-orion/prod/orion/station?data=AAAA"}, } for _, tc := range paths { diff --git a/pkg/service/handlers/issue218_regression_test.go b/pkg/service/handlers/issue218_regression_test.go index e069e84..2bfd751 100644 --- a/pkg/service/handlers/issue218_regression_test.go +++ b/pkg/service/handlers/issue218_regression_test.go @@ -79,7 +79,17 @@ func TestIssue218_OrionStationResolvesPresetStreamURL(t *testing.T) { resolved := ts.URL + parsedLocation.RequestURI() - resp, err := http.Get(resolved) //nolint:noctx + // Real speakers retrieve an orion token from + // POST /core02/svc-bmx-adapter-orion/prod/orion/token before they + // ever follow a LOCAL_INTERNET_RADIO preset; the playback handler + // rejects an empty Authorization header for parity with the other + // BMX playback routes. Use a sentinel Bearer token to match that + // shape — HandleOrionPlayback doesn't validate the token contents, + // only its presence. + req, _ := http.NewRequest("GET", resolved, nil) + req.Header.Set("Authorization", "Bearer mock-token") + + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("GET %s: %v", resolved, err) }