mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
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:
co-authored by
Claude Opus 4.8
parent
382c68d2b6
commit
258cc6198f
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user