From 49b0c831462bb959cd26f999dd3bb9d2b2d7ed58 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Wed, 2 Sep 2026 23:10:35 -0400 Subject: [PATCH 1/5] fix: let the unauthenticated voice roster route actually be reached (issue #1377) registerAudioVoicesRoute attaches audio.VoicesHandler() straight onto the mux with no authorizer and no tenant voice gate, deliberately: Open WebUI's voice dropdowns fetch GET /v1/audio/voices with no Authorization header at all, and gating it silently reinstates the hardcoded alloy-style fallback list that issue #996 exists to prevent. It was gated anyway, one layer out. authSelectorMiddleware wraps the whole mux and intercepts every path under /v1/, and auth.Selector routes to the API-key handler only when Authorization carries a Bearer hk_ credential. Everything else, a request with no Authorization header included, goes to the JWT middleware, which answers 401 before the mux is ever reached. So the unauthenticated registration could not take effect while JWT auth is configured, which it is on the box, and a plain curl there answered UNAUTHENTICATED. This is the same defect PR #1730 fixed for GET /v1/tools, and it takes the same mechanism: the path is named once as a constant, used at registration and again in the exemption, so the two cannot drift apart and leave a route registered and unreachable a second time. The exemption stays exact path and exact method. The three audio routes one segment away all spend credits and keep their authentication, as do the two web tool call routes and any non-GET to either exempt path, which each handler answers 405 for itself. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PGAcTcHd3PdD531LaLqXbw --- .../cmd/server/audio_voices_auth_test.go | 141 ++++++++++++++++++ apps/edge-api/cmd/server/main.go | 50 +++++-- 2 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 apps/edge-api/cmd/server/audio_voices_auth_test.go diff --git a/apps/edge-api/cmd/server/audio_voices_auth_test.go b/apps/edge-api/cmd/server/audio_voices_auth_test.go new file mode 100644 index 000000000..65449402d --- /dev/null +++ b/apps/edge-api/cmd/server/audio_voices_auth_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestVoiceRosterIsReachableWithoutACredential pins the second /v1/ route that +// carries no principal (issue #1377). +// +// registerAudioVoicesRoute attaches audio.VoicesHandler() straight onto the +// mux with no authorizer and no tenant voice gate, deliberately: Open WebUI's +// voice dropdowns fetch this with no Authorization header at all, and gating +// it silently reinstates the hardcoded alloy-style fallback list that issue +// #996 exists to prevent. +// +// It was gated anyway, one layer out. authSelectorMiddleware intercepts every +// path under /v1/, and auth.Selector sends anything that is not a "Bearer +// hk_..." request, a request with no Authorization header included, to the JWT +// middleware, which answers 401 before the mux is ever reached. So the +// unauthenticated registration could not take effect while JWT auth was +// configured, which it is on the box: +// +// $ curl -s http://localhost:8080/v1/audio/voices +// {"error":{"code":"UNAUTHENTICATED","message":"missing bearer",...}} +// +// This drives the real authSelectorMiddleware construction, the same one +// main() performs, with a jwtMW stand-in that always 401s, so reaching the mux +// can only happen through the exemption. +func TestVoiceRosterIsReachableWithoutACredential(t *testing.T) { + t.Setenv("OWUI_SHIM_KEY", "") + + var jwtInvoked, reachedMux bool + jwtMW := func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jwtInvoked = true + w.WriteHeader(http.StatusUnauthorized) + }) + } + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reachedMux = true + w.WriteHeader(http.StatusOK) + }) + + handler := authSelectorMiddleware(jwtMW, next) + + req := httptest.NewRequest(http.MethodGet, audioVoicesPath, nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if jwtInvoked { + t.Errorf("the voice roster must not be sent to the JWT path, where a missing bearer is a 401") + } + if !reachedMux { + t.Fatalf("GET %s with no credential did not reach the mux: status %d", audioVoicesPath, rr.Code) + } +} + +// TestOnlyTheTwoPublicReadsAreExemptFromAuth is the other half, and the half +// issue #1377 asks for by name: proof that no other /v1/ path gained an +// exemption alongside the voice roster. +// +// The neighbouring audio routes are the ones that matter. All three spend +// credits, all three sit one path segment away from the roster, and all three +// keep their authentication. So does a non-GET to the roster path itself, +// which the handler answers 405 for on its own. +func TestOnlyTheTwoPublicReadsAreExemptFromAuth(t *testing.T) { + t.Setenv("OWUI_SHIM_KEY", "") + + exempt := []struct { + method string + path string + }{ + {http.MethodGet, audioVoicesPath}, + {http.MethodGet, webToolsListPath}, + } + + gated := []struct { + method string + path string + }{ + {http.MethodPost, "/v1/audio/speech"}, + {http.MethodPost, "/v1/audio/transcriptions"}, + {http.MethodPost, "/v1/audio/translations"}, + {http.MethodPost, audioVoicesPath}, + {http.MethodDelete, audioVoicesPath}, + {http.MethodGet, audioVoicesPath + "/"}, + {http.MethodGet, audioVoicesPath + "x"}, + {http.MethodGet, "/v1/audio"}, + {http.MethodGet, "/v1/audio/"}, + {http.MethodPost, "/v1/tools/web_search"}, + {http.MethodPost, "/v1/tools/web_fetch"}, + {http.MethodGet, "/v1/models"}, + } + + for _, tc := range exempt { + var jwtInvoked, reachedMux bool + jwtMW := func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jwtInvoked = true + w.WriteHeader(http.StatusUnauthorized) + }) + } + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reachedMux = true + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(tc.method, tc.path, nil) + rr := httptest.NewRecorder() + authSelectorMiddleware(jwtMW, next).ServeHTTP(rr, req) + + if jwtInvoked || !reachedMux { + t.Errorf("%s %s is meant to be exempt but did not reach the mux (jwt invoked: %v)", tc.method, tc.path, jwtInvoked) + } + } + + for _, tc := range gated { + var jwtInvoked, reachedMux bool + jwtMW := func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jwtInvoked = true + w.WriteHeader(http.StatusUnauthorized) + }) + } + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reachedMux = true + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(tc.method, tc.path, nil) + rr := httptest.NewRecorder() + authSelectorMiddleware(jwtMW, next).ServeHTTP(rr, req) + + if reachedMux { + t.Errorf("%s %s reached the mux with no credential; the exemption is wider than it should be", tc.method, tc.path) + } + if !jwtInvoked { + t.Errorf("%s %s with no credential was not sent to the JWT path", tc.method, tc.path) + } + } +} diff --git a/apps/edge-api/cmd/server/main.go b/apps/edge-api/cmd/server/main.go index 87c74f2a1..23ae17a73 100644 --- a/apps/edge-api/cmd/server/main.go +++ b/apps/edge-api/cmd/server/main.go @@ -946,8 +946,16 @@ func registerMediaFileBatchRoutes(mux httpMux, imagesHandler, audioHandler, file // can register it in isolation and exercise assertMatrixCoverage against it. // See the call site in main() for why this route deliberately sits outside // every auth gate. +// audioVoicesPath is the voice roster route. One constant, for the same reason +// webToolsListPath below is one: it is named at registration and again in +// authSelectorMiddleware, which exempts exactly this path and method from +// authentication. Two string literals could drift apart, and the drift would +// be silent in the safe-looking direction, leaving the route registered and +// unreachable. That is precisely what issue #1377 reported. +const audioVoicesPath = "/v1/audio/voices" + func registerAudioVoicesRoute(mux httpMux) { - mux.Handle("/v1/audio/voices", audio.VoicesHandler()) + mux.Handle(audioVoicesPath, audio.VoicesHandler()) } // registerWebToolRoutes attaches the two web tool endpoints. Named rather @@ -1471,22 +1479,32 @@ func authSelectorMiddleware(jwtMW func(http.Handler) http.Handler, next http.Han next.ServeHTTP(w, r) return } - // GET /v1/tools is the one /v1/ route with no principal, and the - // selector is where that has to be honoured: everything under /v1/ - // with no Authorization header falls through to the JWT handler, - // which answers 401 on a missing bearer before any mux entry runs. - // The handler's own doc comment already calls this route - // "deliberately unauthenticated" (webtools.Handler.handleList), and - // the chat shim reads it with no credential on purpose, so without - // this the shim would receive 401 on every read, advertise nothing, - // and put every turn back on the legacy path. That is a merged - // feature that never runs, which is the exact shape of issue #776. + // The two /v1/ reads that carry no principal, and the selector is + // where that has to be honoured: everything under /v1/ with no + // Authorization header falls through to the JWT handler, which + // answers 401 on a missing bearer before any mux entry runs. Both + // routes are registered with no authorizer on purpose, and for both + // that registration is inert without this exemption. + // + // GET /v1/tools is the descriptor list. Its handler's own doc comment + // calls it "deliberately unauthenticated" (webtools.Handler.handleList) + // and the chat shim reads it with no credential, so without this the + // shim received 401 on every read, advertised nothing, and put every + // turn back on the legacy path: a merged feature that never runs, + // which is the shape of issue #776. + // + // GET /v1/audio/voices is the voice roster (issue #1377). Open WebUI's + // voice dropdowns fetch it with no Authorization header at all, so a + // 401 there sends get_available_voices to its hardcoded alloy-style + // fallback, which is the exact shape issue #996 was closed to prevent. + // The names in that fallback are not the voices the provider offers. // - // Exact path and method only. POST /v1/tools/web_search and - // /v1/tools/web_fetch spend credits and keep their authentication; - // so does anything else, including a non-GET to this same path, - // which the handler answers 405 for itself. - if r.Method == http.MethodGet && r.URL.Path == webToolsListPath { + // Exact paths and exact method only. Everything else keeps its + // authentication, including the three audio routes one segment away + // that spend credits, POST /v1/tools/web_search and /v1/tools/web_fetch + // which do the same, and any non-GET to either exempt path, which each + // handler answers 405 for itself. + if r.Method == http.MethodGet && (r.URL.Path == webToolsListPath || r.URL.Path == audioVoicesPath) { next.ServeHTTP(w, r) return } From 0ced4e586ec325e7d40a2704621699bc38a870ba Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Wed, 2 Sep 2026 23:21:16 -0400 Subject: [PATCH 2/5] test: close the two gaps an adversarial security review found (issue #1377) The exemption itself is unchanged. What was missing was proof that it stays narrow and proof that the two halves of the fix work together. Negative cases now cover the shapes an exemption written with a prefix match or a cleaned path would let through: a traversal back onto the credit-spending speech route, a doubled slash, a case variant, HEAD, and POST /v1/chat/completions. Each is a different string from the constant, so each stays gated; naming them is what turns a later rewrite to prefix matching red instead of letting it silently open a paid route. The new end-to-end test drives the real registerAudioVoicesRoute onto a real ServeMux, wraps it in the real authSelectorMiddleware, sends the request Open WebUI sends, and reads the body. Neither of the existing tests would have caught this issue: the route-matrix tests prove the path is registered and the middleware tests prove a request gets past, and #1377 is exactly the shape of a defect that hides between the two. It also fails if the roster ever comes back empty, which is the state that sends the dropdown to the hardcoded fallback list of issue #996. One comment corrected while here. An uncredentialed non-GET to either exempt path is refused at the JWT path, not by the handler's own 405, which answers a credentialed caller. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PGAcTcHd3PdD531LaLqXbw --- .../cmd/server/audio_voices_auth_test.go | 69 ++++++++++++++++++- apps/edge-api/cmd/server/main.go | 13 ++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/edge-api/cmd/server/audio_voices_auth_test.go b/apps/edge-api/cmd/server/audio_voices_auth_test.go index 65449402d..41f14ff73 100644 --- a/apps/edge-api/cmd/server/audio_voices_auth_test.go +++ b/apps/edge-api/cmd/server/audio_voices_auth_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -63,8 +64,9 @@ func TestVoiceRosterIsReachableWithoutACredential(t *testing.T) { // // The neighbouring audio routes are the ones that matter. All three spend // credits, all three sit one path segment away from the roster, and all three -// keep their authentication. So does a non-GET to the roster path itself, -// which the handler answers 405 for on its own. +// keep their authentication. So does a non-GET to the roster path itself: the +// exemption names one method, so an uncredentialed non-GET is refused at the +// JWT path before the handler's own 405 ever applies. func TestOnlyTheTwoPublicReadsAreExemptFromAuth(t *testing.T) { t.Setenv("OWUI_SHIM_KEY", "") @@ -85,6 +87,7 @@ func TestOnlyTheTwoPublicReadsAreExemptFromAuth(t *testing.T) { {http.MethodPost, "/v1/audio/translations"}, {http.MethodPost, audioVoicesPath}, {http.MethodDelete, audioVoicesPath}, + {http.MethodHead, audioVoicesPath}, {http.MethodGet, audioVoicesPath + "/"}, {http.MethodGet, audioVoicesPath + "x"}, {http.MethodGet, "/v1/audio"}, @@ -92,6 +95,15 @@ func TestOnlyTheTwoPublicReadsAreExemptFromAuth(t *testing.T) { {http.MethodPost, "/v1/tools/web_search"}, {http.MethodPost, "/v1/tools/web_fetch"}, {http.MethodGet, "/v1/models"}, + {http.MethodPost, "/v1/chat/completions"}, + // The shapes an exemption written with HasPrefix or with a cleaned + // path would let through. The comparison is equality on the decoded + // r.URL.Path, so each of these is a different string and stays gated; + // naming them here is what makes a later rewrite to prefix matching + // turn this red instead of silently opening a paid route. + {http.MethodGet, "/v1/audio/voices/../speech"}, + {http.MethodGet, "/v1//audio/voices"}, + {http.MethodGet, "/v1/audio/Voices"}, } for _, tc := range exempt { @@ -139,3 +151,56 @@ func TestOnlyTheTwoPublicReadsAreExemptFromAuth(t *testing.T) { } } } + +// TestVoiceRosterServesTheRealRosterWithNoCredential joins the two halves that +// each looked correct on their own while the route was unreachable. +// +// The exemption test above proves the middleware lets the request past. The +// route-matrix tests prove the path is registered. Neither notices if the +// handler behind it later grows an authorizer, or if the exemption and the +// registration stop naming the same path, and #1377 is exactly what that gap +// looks like from outside: a route registered "without the authorizer", a +// middleware that 401s it anyway, and no test anywhere that put the two +// together and read the body. +// +// So this one drives the real registerAudioVoicesRoute onto a real ServeMux, +// wraps it in the real authSelectorMiddleware, sends the request Open WebUI +// sends, and asserts a roster comes back. +func TestVoiceRosterServesTheRealRosterWithNoCredential(t *testing.T) { + t.Setenv("OWUI_SHIM_KEY", "") + + mux := http.NewServeMux() + registerAudioVoicesRoute(mux) + + jwtMW := func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + } + + req := httptest.NewRequest(http.MethodGet, audioVoicesPath, nil) + rr := httptest.NewRecorder() + authSelectorMiddleware(jwtMW, mux).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("GET %s with no credential answered %d, want 200: %s", audioVoicesPath, rr.Code, rr.Body.String()) + } + + var body struct { + Voices []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"voices"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("voice roster is not the JSON the dropdown parses: %v (body %s)", err, rr.Body.String()) + } + if len(body.Voices) == 0 { + t.Fatalf("voice roster came back empty, which sends the dropdown to its hardcoded fallback (issue #996): %s", rr.Body.String()) + } + for _, v := range body.Voices { + if v.ID == "" { + t.Errorf("a voice came back with no id, which the dropdown cannot select: %s", rr.Body.String()) + } + } +} diff --git a/apps/edge-api/cmd/server/main.go b/apps/edge-api/cmd/server/main.go index 23ae17a73..3639d9629 100644 --- a/apps/edge-api/cmd/server/main.go +++ b/apps/edge-api/cmd/server/main.go @@ -1499,11 +1499,14 @@ func authSelectorMiddleware(jwtMW func(http.Handler) http.Handler, next http.Han // fallback, which is the exact shape issue #996 was closed to prevent. // The names in that fallback are not the voices the provider offers. // - // Exact paths and exact method only. Everything else keeps its - // authentication, including the three audio routes one segment away - // that spend credits, POST /v1/tools/web_search and /v1/tools/web_fetch - // which do the same, and any non-GET to either exempt path, which each - // handler answers 405 for itself. + // Exact paths and exact method only, compared by equality rather than + // by prefix, so a traversal or an extra segment cannot widen this into + // a neighbouring route. Everything else keeps its authentication, + // including the three audio routes one segment away that spend + // credits, POST /v1/tools/web_search and /v1/tools/web_fetch which do + // the same, and any non-GET to either exempt path. A non-GET reaches + // the JWT path here and is refused there before the handler sees it; + // the handler's own 405 answers a credentialed caller, not this one. if r.Method == http.MethodGet && (r.URL.Path == webToolsListPath || r.URL.Path == audioVoicesPath) { next.ServeHTTP(w, r) return From b9fcd9df2faeeec34d3aebb1f5fd71c8f68c114e Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Wed, 2 Sep 2026 23:54:56 -0400 Subject: [PATCH 3/5] test: consolidate the auth exemption set into one table (issue #1377) From the Go review. The exemption itself is unchanged. The constant had been inserted between registerAudioVoicesRoute's doc comment and the function, so godoc attached the issue #1079 paragraph to the constant and left the function undocumented. The constant now sits above that comment with a blank line between them. The bigger point was two tests each naming themselves the complete exemption set. TestOnlyTheDescriptorListIsExemptFromAuth was true when PR #1730 wrote it and became false the moment this change exempted a second route, and false in the direction that still passes, since its table simply does not mention the voice roster. Rather than leave a stale claim next to a fresh one, both move into one table, TestAuthSelectorExemptions, which carries both exempt routes and every negative case for both. The web tools file keeps its reachability test and a note saying where the other half went and why it moved. Three copies of the same middleware closure setup collapse into one helper, exerciseAuthSelector, and the cases run under t.Run so a failure names itself. The real-roster test drops the Name field it decoded and never asserted, and its comment now says plainly what it does not guard: the roster's contents are pinned in internal/audio/handler_voices_test.go, so a hardcoded fallback list would satisfy this test. It guards against no roster, not against the wrong one. Verified the consolidated table can still go red: with the voice roster removed from the exemption, the roster case and the end-to-end test both fail and every other case stays green. Whole package passes, gofmt and go vet clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PGAcTcHd3PdD531LaLqXbw --- .../cmd/server/audio_voices_auth_test.go | 239 ++++++++---------- apps/edge-api/cmd/server/main.go | 12 +- .../cmd/server/webtools_list_auth_test.go | 82 ++---- 3 files changed, 130 insertions(+), 203 deletions(-) diff --git a/apps/edge-api/cmd/server/audio_voices_auth_test.go b/apps/edge-api/cmd/server/audio_voices_auth_test.go index 41f14ff73..0ac702245 100644 --- a/apps/edge-api/cmd/server/audio_voices_auth_test.go +++ b/apps/edge-api/cmd/server/audio_voices_auth_test.go @@ -7,32 +7,19 @@ import ( "testing" ) -// TestVoiceRosterIsReachableWithoutACredential pins the second /v1/ route that -// carries no principal (issue #1377). +// exerciseAuthSelector drives the real authSelectorMiddleware construction, +// the same one main() performs, with a JWT stand-in that always 401s. // -// registerAudioVoicesRoute attaches audio.VoicesHandler() straight onto the -// mux with no authorizer and no tenant voice gate, deliberately: Open WebUI's -// voice dropdowns fetch this with no Authorization header at all, and gating -// it silently reinstates the hardcoded alloy-style fallback list that issue -// #996 exists to prevent. +// One helper rather than a closure pair rebuilt at every call site: the table +// below and webtools_list_auth_test.go were each carrying their own copy, and +// several copies of a middleware harness is several places for the exemption's +// meaning to drift. // -// It was gated anyway, one layer out. authSelectorMiddleware intercepts every -// path under /v1/, and auth.Selector sends anything that is not a "Bearer -// hk_..." request, a request with no Authorization header included, to the JWT -// middleware, which answers 401 before the mux is ever reached. So the -// unauthenticated registration could not take effect while JWT auth was -// configured, which it is on the box: -// -// $ curl -s http://localhost:8080/v1/audio/voices -// {"error":{"code":"UNAUTHENTICATED","message":"missing bearer",...}} -// -// This drives the real authSelectorMiddleware construction, the same one -// main() performs, with a jwtMW stand-in that always 401s, so reaching the mux -// can only happen through the exemption. -func TestVoiceRosterIsReachableWithoutACredential(t *testing.T) { - t.Setenv("OWUI_SHIM_KEY", "") - - var jwtInvoked, reachedMux bool +// The JWT stand-in is what makes the result readable. Reaching the mux can only +// happen through an exemption, because every other path is answered by the +// stand-in before the mux is consulted, so reachedMux is a direct statement +// about the exemption rather than about any handler behind it. +func exerciseAuthSelector(method, path string) (jwtInvoked, reachedMux bool) { jwtMW := func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { jwtInvoked = true @@ -43,129 +30,118 @@ func TestVoiceRosterIsReachableWithoutACredential(t *testing.T) { reachedMux = true w.WriteHeader(http.StatusOK) }) - - handler := authSelectorMiddleware(jwtMW, next) - - req := httptest.NewRequest(http.MethodGet, audioVoicesPath, nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - if jwtInvoked { - t.Errorf("the voice roster must not be sent to the JWT path, where a missing bearer is a 401") - } - if !reachedMux { - t.Fatalf("GET %s with no credential did not reach the mux: status %d", audioVoicesPath, rr.Code) - } + req := httptest.NewRequest(method, path, nil) + authSelectorMiddleware(jwtMW, next).ServeHTTP(httptest.NewRecorder(), req) + return jwtInvoked, reachedMux } -// TestOnlyTheTwoPublicReadsAreExemptFromAuth is the other half, and the half -// issue #1377 asks for by name: proof that no other /v1/ path gained an -// exemption alongside the voice roster. +// TestAuthSelectorExemptions is the whole exemption set, in one table. // -// The neighbouring audio routes are the ones that matter. All three spend -// credits, all three sit one path segment away from the roster, and all three -// keep their authentication. So does a non-GET to the roster path itself: the -// exemption names one method, so an uncredentialed non-GET is refused at the -// JWT path before the handler's own 405 ever applies. -func TestOnlyTheTwoPublicReadsAreExemptFromAuth(t *testing.T) { +// Two routes under /v1/ carry no principal, and both are registered with no +// authorizer on purpose. GET /v1/tools is the descriptor list, which the chat +// shim reads with no credential (issue #776, PR #1730). GET /v1/audio/voices is +// the voice roster, which Open WebUI's voice dropdowns fetch with no +// Authorization header at all; a 401 there sends get_available_voices to its +// hardcoded fallback list, which is the shape issue #996 was closed to prevent. +// +// Both registrations are inert without an exemption here. authSelectorMiddleware +// intercepts every path under /v1/, and auth.Selector routes to the API-key +// handler only for a "Bearer hk_..." credential, sending everything else, +// including a request with no Authorization header at all, to the JWT +// middleware, which answers 401 before the mux is ever reached. That is what +// issue #1377 reported, from a plain curl on the box: +// +// $ curl -s http://localhost:8080/v1/audio/voices +// {"error":{"code":"UNAUTHENTICATED","message":"missing bearer",...}} +// +// One table for both routes, rather than a completeness claim per route file. +// Two tests that each name themselves the complete exemption set cannot both +// stay true, and the one that goes stale goes stale silently, in the direction +// that still passes. +func TestAuthSelectorExemptions(t *testing.T) { t.Setenv("OWUI_SHIM_KEY", "") - exempt := []struct { - method string - path string - }{ - {http.MethodGet, audioVoicesPath}, - {http.MethodGet, webToolsListPath}, - } - - gated := []struct { + cases := []struct { + name string method string path string + exempt bool }{ - {http.MethodPost, "/v1/audio/speech"}, - {http.MethodPost, "/v1/audio/transcriptions"}, - {http.MethodPost, "/v1/audio/translations"}, - {http.MethodPost, audioVoicesPath}, - {http.MethodDelete, audioVoicesPath}, - {http.MethodHead, audioVoicesPath}, - {http.MethodGet, audioVoicesPath + "/"}, - {http.MethodGet, audioVoicesPath + "x"}, - {http.MethodGet, "/v1/audio"}, - {http.MethodGet, "/v1/audio/"}, - {http.MethodPost, "/v1/tools/web_search"}, - {http.MethodPost, "/v1/tools/web_fetch"}, - {http.MethodGet, "/v1/models"}, - {http.MethodPost, "/v1/chat/completions"}, - // The shapes an exemption written with HasPrefix or with a cleaned - // path would let through. The comparison is equality on the decoded - // r.URL.Path, so each of these is a different string and stays gated; - // naming them here is what makes a later rewrite to prefix matching - // turn this red instead of silently opening a paid route. - {http.MethodGet, "/v1/audio/voices/../speech"}, - {http.MethodGet, "/v1//audio/voices"}, - {http.MethodGet, "/v1/audio/Voices"}, - } - - for _, tc := range exempt { - var jwtInvoked, reachedMux bool - jwtMW := func(http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jwtInvoked = true - w.WriteHeader(http.StatusUnauthorized) - }) - } - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - reachedMux = true - w.WriteHeader(http.StatusOK) - }) - req := httptest.NewRequest(tc.method, tc.path, nil) - rr := httptest.NewRecorder() - authSelectorMiddleware(jwtMW, next).ServeHTTP(rr, req) - - if jwtInvoked || !reachedMux { - t.Errorf("%s %s is meant to be exempt but did not reach the mux (jwt invoked: %v)", tc.method, tc.path, jwtInvoked) - } + {"the voice roster, unauthenticated", http.MethodGet, audioVoicesPath, true}, + {"the descriptor list, unauthenticated", http.MethodGet, webToolsListPath, true}, + + // The neighbours that spend credits. All three audio routes sit one + // path segment from the roster and all three keep their authentication. + {"speech", http.MethodPost, "/v1/audio/speech", false}, + {"transcriptions", http.MethodPost, "/v1/audio/transcriptions", false}, + {"translations", http.MethodPost, "/v1/audio/translations", false}, + {"web search", http.MethodPost, "/v1/tools/web_search", false}, + {"web fetch", http.MethodPost, "/v1/tools/web_fetch", false}, + {"models", http.MethodGet, "/v1/models", false}, + {"chat completions", http.MethodPost, "/v1/chat/completions", false}, + + // The exemption names one method. An uncredentialed non-GET is refused + // at the JWT path, before the handler's own 405, which answers a + // credentialed caller. + {"POST to the roster", http.MethodPost, audioVoicesPath, false}, + {"DELETE the roster", http.MethodDelete, audioVoicesPath, false}, + {"HEAD the roster", http.MethodHead, audioVoicesPath, false}, + {"POST to the descriptor list", http.MethodPost, webToolsListPath, false}, + {"DELETE the descriptor list", http.MethodDelete, webToolsListPath, false}, + + // The shapes an exemption written with a prefix match or a cleaned path + // would let through. Each is a different string from the constant, so + // each stays gated; naming them is what turns a later rewrite to prefix + // matching red instead of letting it quietly open a paid route. + {"traversal onto speech", http.MethodGet, "/v1/audio/voices/../speech", false}, + {"trailing slash on the roster", http.MethodGet, audioVoicesPath + "/", false}, + {"suffixed roster", http.MethodGet, audioVoicesPath + "x", false}, + {"doubled slash", http.MethodGet, "/v1//audio/voices", false}, + {"case variant", http.MethodGet, "/v1/audio/Voices", false}, + {"the audio prefix itself", http.MethodGet, "/v1/audio", false}, + {"the audio prefix with a slash", http.MethodGet, "/v1/audio/", false}, + {"trailing slash on the descriptor list", http.MethodGet, webToolsListPath + "/", false}, + {"suffixed descriptor list", http.MethodGet, webToolsListPath + "x", false}, } - for _, tc := range gated { - var jwtInvoked, reachedMux bool - jwtMW := func(http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jwtInvoked = true - w.WriteHeader(http.StatusUnauthorized) - }) - } - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - reachedMux = true - w.WriteHeader(http.StatusOK) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + jwtInvoked, reachedMux := exerciseAuthSelector(tc.method, tc.path) + if tc.exempt { + if jwtInvoked { + t.Errorf("%s %s was sent to the JWT path, where a missing bearer is a 401", tc.method, tc.path) + } + if !reachedMux { + t.Errorf("%s %s is meant to be exempt but did not reach the mux", tc.method, tc.path) + } + return + } + if reachedMux { + t.Errorf("%s %s reached the mux with no credential; the exemption is wider than it should be", tc.method, tc.path) + } + if !jwtInvoked { + t.Errorf("%s %s with no credential was not sent to the JWT path", tc.method, tc.path) + } }) - req := httptest.NewRequest(tc.method, tc.path, nil) - rr := httptest.NewRecorder() - authSelectorMiddleware(jwtMW, next).ServeHTTP(rr, req) - - if reachedMux { - t.Errorf("%s %s reached the mux with no credential; the exemption is wider than it should be", tc.method, tc.path) - } - if !jwtInvoked { - t.Errorf("%s %s with no credential was not sent to the JWT path", tc.method, tc.path) - } } } // TestVoiceRosterServesTheRealRosterWithNoCredential joins the two halves that // each looked correct on their own while the route was unreachable. // -// The exemption test above proves the middleware lets the request past. The -// route-matrix tests prove the path is registered. Neither notices if the -// handler behind it later grows an authorizer, or if the exemption and the -// registration stop naming the same path, and #1377 is exactly what that gap -// looks like from outside: a route registered "without the authorizer", a -// middleware that 401s it anyway, and no test anywhere that put the two -// together and read the body. +// The table above proves the middleware lets the request past. The route-matrix +// tests prove the path is registered. Neither notices if the handler behind it +// later grows an authorizer, or if the exemption and the registration stop +// naming the same path, and issue #1377 is exactly the shape of a defect that +// hides between the two. So this drives the real registerAudioVoicesRoute onto +// a real ServeMux, wraps it in the real authSelectorMiddleware, sends the +// request Open WebUI sends, and reads the body. // -// So this one drives the real registerAudioVoicesRoute onto a real ServeMux, -// wraps it in the real authSelectorMiddleware, sends the request Open WebUI -// sends, and asserts a roster comes back. +// Reachability and shape only. What the roster actually contains is pinned in +// internal/audio/handler_voices_test.go, which is where a change to the voices +// themselves belongs, and asserting it again here would be a second copy to +// keep in step. So this is deliberately not a guard against the wrong roster: a +// hardcoded fallback list would satisfy it. It guards against no roster. func TestVoiceRosterServesTheRealRosterWithNoCredential(t *testing.T) { t.Setenv("OWUI_SHIM_KEY", "") @@ -188,15 +164,14 @@ func TestVoiceRosterServesTheRealRosterWithNoCredential(t *testing.T) { var body struct { Voices []struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` } `json:"voices"` } if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { t.Fatalf("voice roster is not the JSON the dropdown parses: %v (body %s)", err, rr.Body.String()) } if len(body.Voices) == 0 { - t.Fatalf("voice roster came back empty, which sends the dropdown to its hardcoded fallback (issue #996): %s", rr.Body.String()) + t.Fatalf("voice roster came back empty, so the dropdown has nothing to offer: %s", rr.Body.String()) } for _, v := range body.Voices { if v.ID == "" { diff --git a/apps/edge-api/cmd/server/main.go b/apps/edge-api/cmd/server/main.go index 3639d9629..86ca2bf55 100644 --- a/apps/edge-api/cmd/server/main.go +++ b/apps/edge-api/cmd/server/main.go @@ -940,12 +940,6 @@ func registerMediaFileBatchRoutes(mux httpMux, imagesHandler, audioHandler, file mux.Handle("/v1/batches/", batchesHandler) } -// registerAudioVoicesRoute attaches GET /v1/audio/voices. Extracted (issue -// #1079 shipped this as a bare inline mux.Handle call, which is exactly what -// left it with no support-matrix.json entry) so route_matrix_guard_test.go -// can register it in isolation and exercise assertMatrixCoverage against it. -// See the call site in main() for why this route deliberately sits outside -// every auth gate. // audioVoicesPath is the voice roster route. One constant, for the same reason // webToolsListPath below is one: it is named at registration and again in // authSelectorMiddleware, which exempts exactly this path and method from @@ -954,6 +948,12 @@ func registerMediaFileBatchRoutes(mux httpMux, imagesHandler, audioHandler, file // unreachable. That is precisely what issue #1377 reported. const audioVoicesPath = "/v1/audio/voices" +// registerAudioVoicesRoute attaches GET /v1/audio/voices. Extracted (issue +// #1079 shipped this as a bare inline mux.Handle call, which is exactly what +// left it with no support-matrix.json entry) so route_matrix_guard_test.go +// can register it in isolation and exercise assertMatrixCoverage against it. +// See the call site in main() for why this route deliberately sits outside +// every auth gate. func registerAudioVoicesRoute(mux httpMux) { mux.Handle(audioVoicesPath, audio.VoicesHandler()) } diff --git a/apps/edge-api/cmd/server/webtools_list_auth_test.go b/apps/edge-api/cmd/server/webtools_list_auth_test.go index 41be2a2c7..60deb58be 100644 --- a/apps/edge-api/cmd/server/webtools_list_auth_test.go +++ b/apps/edge-api/cmd/server/webtools_list_auth_test.go @@ -2,13 +2,13 @@ package main import ( "net/http" - "net/http/httptest" - "strings" "testing" ) -// TestDescriptorListIsReachableWithoutACredential pins the one /v1/ route that -// carries no principal. +// TestDescriptorListIsReachableWithoutACredential pins the descriptor list, +// one of the two /v1/ routes that carry no principal. The other is the voice +// roster (issue #1377); TestAuthSelectorExemptions in audio_voices_auth_test.go +// carries both, and every negative case for both. // // GET /v1/tools serves a compiled-in constant and the chat shim reads it with // no Authorization header on purpose (deploy/docker/owui-patches/ @@ -25,72 +25,24 @@ import ( func TestDescriptorListIsReachableWithoutACredential(t *testing.T) { t.Setenv("OWUI_SHIM_KEY", "") - var jwtInvoked, reachedMux bool - jwtMW := func(http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jwtInvoked = true - w.WriteHeader(http.StatusUnauthorized) - }) - } - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - reachedMux = true - w.WriteHeader(http.StatusOK) - }) - - handler := authSelectorMiddleware(jwtMW, next) - - req := httptest.NewRequest(http.MethodGet, webToolsListPath, nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + jwtInvoked, reachedMux := exerciseAuthSelector(http.MethodGet, webToolsListPath) if jwtInvoked { t.Errorf("the descriptor list must not be sent to the JWT path, where a missing bearer is a 401") } if !reachedMux { - t.Fatalf("GET %s with no credential did not reach the mux: status %d", webToolsListPath, rr.Code) + t.Fatalf("GET %s with no credential did not reach the mux", webToolsListPath) } } -// TestOnlyTheDescriptorListIsExemptFromAuth is the other half. The exemption -// is one path and one method; the two routes that spend credits, a non-GET to -// the list path, and every neighbouring path keep their authentication. -func TestOnlyTheDescriptorListIsExemptFromAuth(t *testing.T) { - t.Setenv("OWUI_SHIM_KEY", "") - - cases := []struct { - method string - path string - }{ - {http.MethodPost, "/v1/tools/web_search"}, - {http.MethodPost, "/v1/tools/web_fetch"}, - {http.MethodPost, webToolsListPath}, - {http.MethodDelete, webToolsListPath}, - {http.MethodGet, webToolsListPath + "/"}, - {http.MethodGet, webToolsListPath + "x"}, - {http.MethodGet, "/v1/models"}, - } - - for _, tc := range cases { - var jwtInvoked, reachedMux bool - jwtMW := func(http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jwtInvoked = true - w.WriteHeader(http.StatusUnauthorized) - }) - } - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - reachedMux = true - w.WriteHeader(http.StatusOK) - }) - - handler := authSelectorMiddleware(jwtMW, next) - req := httptest.NewRequest(tc.method, tc.path, strings.NewReader("{}")) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - if !jwtInvoked || reachedMux { - t.Errorf("%s %s reached the mux with no credential: it must stay authenticated (jwtInvoked=%v reachedMux=%v)", - tc.method, tc.path, jwtInvoked, reachedMux) - } - } -} +// The other half, "and nothing else is exempt", used to live here as +// TestOnlyTheDescriptorListIsExemptFromAuth. It moved to +// TestAuthSelectorExemptions in audio_voices_auth_test.go when issue #1377 +// added the voice roster as a second exempt route, and it took the web tools +// negative cases with it. +// +// It moved rather than gaining a neighbour because the claim is about the +// middleware, not about either route: a test named for one route that asserts +// the complete exemption set becomes quietly false the moment a second route +// is exempted, and false in the direction that still passes. One table now +// carries both routes and every negative case for both. From 706c4538350e152712765cbe0a97798174778ef2 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Wed, 2 Sep 2026 23:57:07 -0400 Subject: [PATCH 4/5] test: let the voice roster guard survive naming the path (issue #1377) The #996 guard in scripts/test_owui_rag_env_config.py pinned the literal `mux.Handle("/v1/audio/voices", audio.VoicesHandler())`. Naming that path as a constant, which is the whole mechanism of this fix, therefore read as a regression and turned the repo policy lints red. The guard now checks the thing it was protecting rather than the spelling it happened to have. Three assertions: the constant carries the path, the registration serves it with VoicesHandler, and the exemption in authSelectorMiddleware still names it. That last one is new and is the half #1377 exists for, since a registration with no exemption is inert and the route answers 401 while looking correct in a diff. Verified it can go red: with the roster removed from the exemption the third assertion fails and names the issue. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PGAcTcHd3PdD531LaLqXbw --- scripts/test_owui_rag_env_config.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/test_owui_rag_env_config.py b/scripts/test_owui_rag_env_config.py index 8cacda127..258fe4958 100644 --- a/scripts/test_owui_rag_env_config.py +++ b/scripts/test_owui_rag_env_config.py @@ -510,15 +510,32 @@ def test_gateway_serves_the_voice_roster_the_ui_offers() -> None: fails. edge-api now serves that endpoint with the provider's real roster, so the Settings > Audio dropdowns can only offer voices hive-tts accepts. Asserted against main.go because a dropped registration line would send - every dropdown silently back to the alloy fallback.""" + every dropdown silently back to the alloy fallback. + + The path is matched through the constant rather than as a literal inside + the mux.Handle call. Issue #1377 gave it a name, audioVoicesPath, because + the same path is spelled twice, at registration and in the exemption in + authSelectorMiddleware, and two literals could drift apart and leave the + route registered and unreachable. Pinning the pre-#1377 literal here would + have made that fix look like a regression, so both halves are checked: + the constant carries the path, and the registration serves it with + VoicesHandler.""" main_go = ( Path(__file__).resolve().parents[1] / "apps" / "edge-api" / "cmd" / "server" / "main.go" ).read_text(encoding="utf-8") - assert 'mux.Handle("/v1/audio/voices", audio.VoicesHandler())' in main_go, ( + assert 'audioVoicesPath = "/v1/audio/voices"' in main_go, ( "edge-api must serve GET /v1/audio/voices or Open WebUI's voice " "dropdowns fall back to OpenAI's alloy-style list (#996)" ) + assert "mux.Handle(audioVoicesPath, audio.VoicesHandler())" in main_go, ( + "the voice roster path must still be registered against VoicesHandler " + "or Open WebUI's dropdowns fall back to the alloy-style list (#996)" + ) + assert "r.URL.Path == audioVoicesPath" in main_go, ( + "the voice roster must stay exempt in authSelectorMiddleware or the " + "registration above is inert and the route answers 401 (#1377)" + ) def test_compose_routes_chat_transcription_through_the_gateway() -> None: From 818a8e921153f503d81d7452fbee5928a6f9996c Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 3 Sep 2026 01:15:04 -0400 Subject: [PATCH 5/5] test: pin the decoded-path semantics the exemption actually has (issue #1377) Two review findings, both about the gap between what the comment claimed and what the code does. The comparison is against r.URL.Path, which net/http has already decoded, so the exemption is not quite "one literal string". /v1/%61udio/voices and /v1/audio/voice%73 decode to the exempt path and are exempt. That is harmless rather than merely tolerable: net/http decodes the same way before matching, so every spelling that clears the check lands on the same static handler. The one divergence is /v1/audio%2fvoices, exempt at the middleware and a 404 at the mux, since an encoded separator matches no registered pattern. No encoded form reaches a different route and none reaches a credit-spending one. The comment said the comparison is by equality rather than prefix, which is true of traversals and extra segments and is what a reader would generalise into "only the literal string". It now says what the decoded comparison actually admits, and points at the test that pins it. Three cases join the table, asserted as exempt because that is what the middleware does. Asserting them as gated would claim a stricter rule than the code implements and would go red against correct code. If the exemption is ever made strict about the raw spelling, the table is where that change shows up. This also closes a claim the pull request body made without evidence. It said the security review's missing encoding negative was added; traversal, case and HEAD were there and no percent-encoded case was, since the doubled-slash entry is a literal rather than an encoded separator. The body is corrected alongside this. Whole package passes, gofmt and go vet clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PGAcTcHd3PdD531LaLqXbw --- .../cmd/server/audio_voices_auth_test.go | 14 ++++++++++++++ apps/edge-api/cmd/server/main.go | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/edge-api/cmd/server/audio_voices_auth_test.go b/apps/edge-api/cmd/server/audio_voices_auth_test.go index 0ac702245..c091a6df9 100644 --- a/apps/edge-api/cmd/server/audio_voices_auth_test.go +++ b/apps/edge-api/cmd/server/audio_voices_auth_test.go @@ -70,6 +70,20 @@ func TestAuthSelectorExemptions(t *testing.T) { {"the voice roster, unauthenticated", http.MethodGet, audioVoicesPath, true}, {"the descriptor list, unauthenticated", http.MethodGet, webToolsListPath, true}, + // The decoded-path semantics, pinned rather than assumed. r.URL.Path is + // decoded before this comparison, so these spell the exempt path and + // are exempt. Asserting them as exempt documents what the code actually + // does; asserting them as gated would claim a stricter rule than the + // middleware implements and would go red against correct code. If the + // exemption is ever made strict about the raw spelling, this is where + // that change shows up. + {"percent-encoded spelling of the roster", http.MethodGet, "/v1/%61udio/voices", true}, + {"percent-encoded final character", http.MethodGet, "/v1/audio/voice%73", true}, + // Exempt at the middleware and a 404 at the mux, because an encoded + // separator matches no registered pattern. Named so that the divergence + // is recorded rather than discovered. + {"encoded separator", http.MethodGet, "/v1/audio%2fvoices", true}, + // The neighbours that spend credits. All three audio routes sit one // path segment from the roster and all three keep their authentication. {"speech", http.MethodPost, "/v1/audio/speech", false}, diff --git a/apps/edge-api/cmd/server/main.go b/apps/edge-api/cmd/server/main.go index 86ca2bf55..84b78b377 100644 --- a/apps/edge-api/cmd/server/main.go +++ b/apps/edge-api/cmd/server/main.go @@ -1501,7 +1501,21 @@ func authSelectorMiddleware(jwtMW func(http.Handler) http.Handler, next http.Han // // Exact paths and exact method only, compared by equality rather than // by prefix, so a traversal or an extra segment cannot widen this into - // a neighbouring route. Everything else keeps its authentication, + // a neighbouring route. + // + // Equality, but on r.URL.Path, which is the DECODED path. So this is + // not quite "one literal string": /v1/%61udio/voices and + // /v1/audio/voice%73 decode to the exempt path and are exempt too. That + // is harmless rather than merely tolerable, because net/http decodes + // the same way before matching, so every spelling that clears this + // check lands on the same static handler. The one divergence is + // /v1/audio%2fvoices, which is exempt here and 404s at the mux, since + // an encoded separator cannot match any registered pattern. No encoded + // form reaches a different route, and none reaches a credit-spending + // one: audio_voices_auth_test.go pins both the exempt spellings and the + // refusals. + // + // Everything else keeps its authentication, // including the three audio routes one segment away that spend // credits, POST /v1/tools/web_search and /v1/tools/web_fetch which do // the same, and any non-GET to either exempt path. A non-GET reaches