From d413bf60abb9858c462292a2d06e3b59f67f05d4 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 31 May 2026 22:31:27 +0200 Subject: [PATCH] fix(tts): resolve speak target to a known device IP (SSRF, CodeQL 305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/service/handlers/handlers_tts.go | 33 ++++++++++++++--------- pkg/service/handlers/handlers_tts_test.go | 4 +++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/pkg/service/handlers/handlers_tts.go b/pkg/service/handlers/handlers_tts.go index 8bf40ef..c25a1a5 100644 --- a/pkg/service/handlers/handlers_tts.go +++ b/pkg/service/handlers/handlers_tts.go @@ -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. diff --git a/pkg/service/handlers/handlers_tts_test.go b/pkg/service/handlers/handlers_tts_test.go index 0844fc3..527ec32 100644 --- a/pkg/service/handlers/handlers_tts_test.go +++ b/pkg/service/handlers/handlers_tts_test.go @@ -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 {