From 258cc6198f5b0d8c7fbb9693a7ab017205aebf27 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 31 May 2026 22:12:56 +0200 Subject: [PATCH] fix(web): stop TTS proxy from using a browser-supplied service URL (SSRF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged "uncontrolled data used in network request": the soundtouch-web TTS proxy built its outbound request URL from the client-supplied serviceUrl, letting any LAN caller use the endpoint as an SSRF proxy. The proxy target must be the operator-configured --service-url. - handler: use only app.ServiceURL; drop the client-supplied serviceUrl field and fallback. - web TTS view: show the configured service URL read-only with an explanation of why it can't be edited here (Play URL differs — its URL is handed to the speaker, not fetched by soundtouch-web, so no SSRF). - api.speak no longer sends serviceUrl. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/reference/SPEAKER-ENDPOINT.md | 6 +-- pkg/service/soundtouchweb/handlers_tts.go | 23 ++++---- pkg/service/soundtouchweb/static/js/api.js | 4 +- .../soundtouchweb/static/js/components/TTS.js | 53 ++++++++----------- 4 files changed, 38 insertions(+), 48 deletions(-) diff --git a/docs/content/docs/reference/SPEAKER-ENDPOINT.md b/docs/content/docs/reference/SPEAKER-ENDPOINT.md index 8edd7c0..cc1389c 100644 --- a/docs/content/docs/reference/SPEAKER-ENDPOINT.md +++ b/docs/content/docs/reference/SPEAKER-ENDPOINT.md @@ -333,9 +333,9 @@ soundtouch-cli speaker tts-cloud \ --method speaker ``` -Web UI: the TTS source view (and the Play URL view) include a "Say something…" -box. soundtouch-web proxies it to the service, so start it with `--service-url` -(or enter the service URL in the view). +Web UI: the TTS source view has a "Say something…" box. soundtouch-web proxies +it to the service, so it must be started with `--service-url` (the target is +server-configured, not entered in the browser, to avoid an SSRF proxy). ### Notes and limitations diff --git a/pkg/service/soundtouchweb/handlers_tts.go b/pkg/service/soundtouchweb/handlers_tts.go index 5fe9c1b..ea3deb8 100644 --- a/pkg/service/soundtouchweb/handlers_tts.go +++ b/pkg/service/soundtouchweb/handlers_tts.go @@ -30,11 +30,10 @@ func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) { } var req struct { - Text string `json:"text"` - Language string `json:"language,omitempty"` - Voice string `json:"voice,omitempty"` - Volume *int `json:"volume,omitempty"` - ServiceURL string `json:"serviceUrl,omitempty"` + Text string `json:"text"` + Language string `json:"language,omitempty"` + Voice string `json:"voice,omitempty"` + Volume *int `json:"volume,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -47,15 +46,15 @@ func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) { return } - // Server-side --service-url wins; fall back to the client-supplied value. - serviceURL := app.ServiceURL - if serviceURL == "" { - serviceURL = strings.TrimRight(req.ServiceURL, "/") - } - + // The TTS request is made server-side by soundtouch-web, so its target must + // be the operator-configured service URL — never a client-supplied value + // (that would let any LAN caller use this endpoint as an SSRF proxy). This + // differs from Play URL, where the URL is handed to the speaker, not fetched + // by soundtouch-web. + serviceURL := strings.TrimRight(app.ServiceURL, "/") if serviceURL == "" { app.sendError(w, - "TTS requires the AfterTouch service URL. Start soundtouch-web with --service-url or enter it in the TTS settings.", + "TTS requires the AfterTouch service URL. Start soundtouch-web with --service-url .", http.StatusBadRequest) return diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index d6873e2..3690bf6 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -50,9 +50,9 @@ export const api = { headers: JSON_HEADERS, body: JSON.stringify({ url, name, imageUrl, serviceUrl }), }), - speak: (deviceId, text, serviceUrl) => req(`/api/device-speak/${deviceId}`, { + speak: (deviceId, text) => req(`/api/device-speak/${deviceId}`, { method: 'POST', headers: JSON_HEADERS, - body: JSON.stringify({ text, serviceUrl }), + body: JSON.stringify({ text }), }), }; diff --git a/pkg/service/soundtouchweb/static/js/components/TTS.js b/pkg/service/soundtouchweb/static/js/components/TTS.js index 6405550..1398d89 100644 --- a/pkg/service/soundtouchweb/static/js/components/TTS.js +++ b/pkg/service/soundtouchweb/static/js/components/TTS.js @@ -1,38 +1,22 @@ import { h } from 'preact'; -import { useState, useEffect } from 'preact/hooks'; +import { useState } from 'preact/hooks'; import htm from 'htm'; import { api } from '../api.js'; const html = htm.bind(h); -// Shared with PlayURL so the AfterTouch service URL only has to be entered once. -const LS_KEY = 'aftertouch_service_url'; - // TTS is a "source" view (like PlayURL / TuneIn / RadioBrowser): enter text, -// pick a device, and the AfterTouch service synthesizes and plays it. Synthesis -// and credentials live in the service; this collects text, a target, and the -// service URL (the service hosts synthesized clips for the speaker to fetch). +// pick a device, and the AfterTouch service synthesizes and plays it. Synthesis, +// credentials, and the service URL all live server-side — soundtouch-web proxies +// to the service it was started with (--service-url). The service URL is shown +// read-only: unlike Play URL (whose URL is handed to the speaker), here +// soundtouch-web makes the request itself, so a browser-supplied URL would be an +// open SSRF proxy. export function TTS({ devices, serverServiceUrl }) { const [text, setText] = useState(''); - const [serviceUrl, setServiceUrl] = useState(() => localStorage.getItem(LS_KEY) || ''); const [pendingSpeak, setPendingSpeak] = useState(null); const [status, setStatus] = useState(null); - useEffect(() => { - if (serverServiceUrl && !localStorage.getItem(LS_KEY)) { - setServiceUrl(serverServiceUrl); - } - }, [serverServiceUrl]); - - function onServiceUrlChange(val) { - setServiceUrl(val); - if (val) { - localStorage.setItem(LS_KEY, val); - } else { - localStorage.removeItem(LS_KEY); - } - } - function startSpeak() { const trimmed = text.trim(); if (!trimmed) return; @@ -45,7 +29,7 @@ export function TTS({ devices, serverServiceUrl }) { setPendingSpeak(null); setStatus('Speaking…'); try { - const resp = await api.speak(deviceId, item.text, serviceUrl.trim()); + const resp = await api.speak(deviceId, item.text); setStatus(resp.success ? 'Speaking' : 'Error: ' + (resp.error || 'Unknown error')); } catch (e) { setStatus('Error: ' + e.message); @@ -65,20 +49,27 @@ export function TTS({ devices, serverServiceUrl }) { onInput=${(e) => setText(e.target.value)} onKeyDown=${(e) => e.key === 'Enter' && startSpeak()} /> - +
onServiceUrlChange(e.target.value)} - title="AfterTouch service base URL — the service synthesizes speech and hosts the clip for the speaker to play" + value=${serverServiceUrl || ''} + placeholder="(not configured — start soundtouch-web with --service-url)" + readonly + title="AfterTouch service URL — set server-side via --service-url" />
-
- Uses the AfterTouch service's configured TTS provider (Settings → Integrations). +
+ ${serverServiceUrl + ? html`Synthesized by the AfterTouch service (Settings → Integrations) and played on the speaker.` + : html`TTS is unavailable: start soundtouch-web with --service-url.`} +
+ The service URL is fixed server-side and can't be edited here: soundtouch-web + makes the request itself, so a browser-supplied URL would let anyone use it as + an SSRF proxy. (Play URL differs — its URL is handed to the speaker, not fetched + here.)
${status && html`
${status}
`}