fix(web): stop TTS proxy from using a browser-supplied service URL (SSRF)

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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-31 22:35:31 +02:00
co-authored by Claude Opus 4.8
parent 382c68d2b6
commit 258cc6198f
4 changed files with 38 additions and 48 deletions
@@ -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
+11 -12
View File
@@ -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 <https://your-aftertouch-host> or enter it in the TTS settings.",
"TTS requires the AfterTouch service URL. Start soundtouch-web with --service-url <https://your-aftertouch-host>.",
http.StatusBadRequest)
return
+2 -2
View File
@@ -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 }),
}),
};
@@ -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()}
/>
<button class="btn-primary" onClick=${startSpeak} disabled=${!text.trim()}>🔊 Speak</button>
<button class="btn-primary" onClick=${startSpeak} disabled=${!text.trim() || !serverServiceUrl}>🔊 Speak</button>
</div>
<div class="tunein-toolbar" style="margin-top:.4rem">
<input
type="url"
class="tunein-search-input"
placeholder="AfterTouch URL (https://…)"
value=${serviceUrl}
onInput=${(e) => 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"
/>
</div>
<div class="track-meta" style="margin-top:.4rem">
Uses the AfterTouch service's configured TTS provider (Settings → Integrations).
<div class="track-meta" style="margin-top:.4rem; opacity:.85">
${serverServiceUrl
? html`Synthesized by the AfterTouch service (Settings → Integrations) and played on the speaker.`
: html`<strong>TTS is unavailable:</strong> start soundtouch-web with <code>--service-url</code>.`}
<br/>
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.)
</div>
${status && html`<div class="track-meta" style="margin-top:.6rem">${status}</div>`}