fix(tts): resolve speak target to a known device IP (SSRF, CodeQL 305)

HandleTTSSpeak passed the request's `host` straight to
client.NewClientFromHost, so the resolved value flowed into the client's
baseURL and the outbound request (client.go post -> httpClient.Do) — a
caller could point the service at an arbitrary host:8090 (SSRF).

resolveTTSHost now always returns an IP looked up from the datastore:
match by deviceId, or by host equal to a known device's IP, and return
that stored IPAddress (never the caller-supplied string). Unknown
hosts/devices are rejected. This both mitigates the SSRF and breaks the
tainted data flow. Adds regression cases for unknown host/device.

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 40633f33c8
commit d413bf60ab
2 changed files with 25 additions and 12 deletions
+21 -12
View File
@@ -145,14 +145,18 @@ func (s *Server) HandleSpeakerAuth(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
// resolveTTSHost returns the speaker IP/hostname to target. An explicit Host
// wins; otherwise DeviceID is looked up in the datastore.
// resolveTTSHost returns the speaker IP to target, resolved from the datastore
// so the result is always a known device address — never a value taken straight
// from the request. This prevents the endpoint from being used as an SSRF proxy
// to arbitrary hosts (the resolved IP flows into client.NewClientFromHost ->
// baseURL -> the outbound request). Match by DeviceID, or by Host equal to a
// known device's IP; either way the returned string is the datastore's
// IPAddress, not the caller-supplied value.
func (s *Server) resolveTTSHost(req ttsSpeakRequest) (string, error) {
if h := strings.TrimSpace(req.Host); h != "" {
return h, nil
}
deviceID := strings.TrimSpace(req.DeviceID)
host := strings.TrimSpace(req.Host)
if strings.TrimSpace(req.DeviceID) == "" {
if deviceID == "" && host == "" {
return "", fmt.Errorf("either deviceId or host is required")
}
@@ -162,16 +166,21 @@ func (s *Server) resolveTTSHost(req ttsSpeakRequest) (string, error) {
}
for i := range devices {
if devices[i].DeviceID == req.DeviceID {
if devices[i].IPAddress == "" {
return "", fmt.Errorf("device %s has no known IP address", req.DeviceID)
}
ip := devices[i].IPAddress
if ip == "" {
continue
}
return devices[i].IPAddress, nil
if (deviceID != "" && devices[i].DeviceID == deviceID) || (host != "" && ip == host) {
return ip, nil
}
}
return "", fmt.Errorf("device %s not found", req.DeviceID)
if deviceID != "" {
return "", fmt.Errorf("device %s not found (or has no known IP)", deviceID)
}
return "", fmt.Errorf("host %s is not a known device", host)
}
// HandleTTSMedia serves a synthesized clip by id for the speaker to fetch.
@@ -163,6 +163,10 @@ func TestHandleTTSSpeakValidation(t *testing.T) {
{"empty text", `{"host":"192.0.2.10","text":" "}`, http.StatusBadRequest},
{"no target", `{"text":"hello"}`, http.StatusBadRequest},
{"bad json", `{not json}`, http.StatusBadRequest},
// SSRF guard: an arbitrary host that isn't a known device must be
// rejected, not connected to.
{"unknown host", `{"host":"203.0.113.99","text":"hello"}`, http.StatusBadRequest},
{"unknown device", `{"deviceId":"NOPE","text":"hello"}`, http.StatusBadRequest},
}
for _, tc := range cases {