diff --git a/src/OpenClaw.Shared/Audio/AllowedAssetDownload.cs b/src/OpenClaw.Shared/Audio/AllowedAssetDownload.cs new file mode 100644 index 000000000..82e5c6313 --- /dev/null +++ b/src/OpenClaw.Shared/Audio/AllowedAssetDownload.cs @@ -0,0 +1,76 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.Audio; + +internal static class AllowedAssetDownload +{ + internal const int MaximumRedirects = 5; + + internal static HttpClient CreateClient(TimeSpan timeout) => + new(new SocketsHttpHandler { AllowAutoRedirect = false }) + { + Timeout = timeout + }; + + internal static async Task GetAsync( + HttpClient client, + string url, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(client); + var current = new Uri(url); + ValidateDownloadUri(current, initialRequest: true); + for (var redirect = 0; redirect <= MaximumRedirects; redirect++) + { + var response = await client.GetAsync( + current, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + if (!IsRedirect(response.StatusCode)) + return response; + + var location = response.Headers.Location; + response.Dispose(); + if (location is null || redirect == MaximumRedirects) + throw new InvalidOperationException("The model download exceeded the redirect limit."); + + var next = location.IsAbsoluteUri ? location : new Uri(current, location); + ValidateDownloadUri(next, initialRequest: false); + current = next; + } + + throw new InvalidOperationException("The model download exceeded the redirect limit."); + } + + internal static void ValidateDownloadUri(Uri uri, bool initialRequest) + { + if (!uri.IsAbsoluteUri || + uri.Scheme != Uri.UriSchemeHttps || + !string.IsNullOrEmpty(uri.UserInfo) || + !string.IsNullOrEmpty(uri.Fragment)) + { + throw new InvalidOperationException("The model download URI must be credential-free HTTPS."); + } + + var allowed = string.Equals(uri.Host, "huggingface.co", StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase) || + (!initialRequest && + (uri.Host.EndsWith(".huggingface.co", StringComparison.OrdinalIgnoreCase) || + uri.Host.EndsWith(".hf.co", StringComparison.OrdinalIgnoreCase) || + uri.Host.EndsWith(".githubusercontent.com", StringComparison.OrdinalIgnoreCase))); + if (!allowed) + throw new InvalidOperationException("The model download redirected to an untrusted host."); + } + + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is + HttpStatusCode.MovedPermanently or + HttpStatusCode.Redirect or + HttpStatusCode.RedirectMethod or + HttpStatusCode.TemporaryRedirect or + HttpStatusCode.PermanentRedirect; +} diff --git a/src/OpenClaw.Shared/Audio/PiperVoiceManager.cs b/src/OpenClaw.Shared/Audio/PiperVoiceManager.cs index 4da8fd928..d8ac90b8f 100644 --- a/src/OpenClaw.Shared/Audio/PiperVoiceManager.cs +++ b/src/OpenClaw.Shared/Audio/PiperVoiceManager.cs @@ -187,9 +187,8 @@ private async Task DownloadVoiceCoreAsync( { Directory.CreateDirectory(voiceDir); File.WriteAllText(installMarkerPath, string.Empty); - using var httpClient = new HttpClient(); - httpClient.Timeout = TimeSpan.FromMinutes(10); - using var response = await httpClient.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + using var httpClient = AllowedAssetDownload.CreateClient(TimeSpan.FromMinutes(10)); + using var response = await AllowedAssetDownload.GetAsync(httpClient, info.DownloadUrl, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var totalBytes = response.Content.Headers.ContentLength ?? 0; diff --git a/src/OpenClaw.Shared/Audio/WhisperModelManager.cs b/src/OpenClaw.Shared/Audio/WhisperModelManager.cs index ce8e97256..f5d2c1456 100644 --- a/src/OpenClaw.Shared/Audio/WhisperModelManager.cs +++ b/src/OpenClaw.Shared/Audio/WhisperModelManager.cs @@ -123,9 +123,8 @@ private async Task DownloadModelCoreAsync( try { - using var httpClient = new HttpClient(); - httpClient.Timeout = TimeSpan.FromMinutes(30); - using var response = await httpClient.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var httpClient = AllowedAssetDownload.CreateClient(TimeSpan.FromMinutes(30)); + using var response = await AllowedAssetDownload.GetAsync(httpClient, info.DownloadUrl, cancellationToken); response.EnsureSuccessStatusCode(); var totalBytes = response.Content.Headers.ContentLength ?? info.ApproximateSizeBytes; diff --git a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/ElevenLabsTextToSpeechClient.cs b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/ElevenLabsTextToSpeechClient.cs index c1ee3d389..56d555b45 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/ElevenLabsTextToSpeechClient.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/ElevenLabsTextToSpeechClient.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -28,6 +29,7 @@ public sealed class ElevenLabsTextToSpeechClient : IDisposable { private const string DefaultBaseUrl = "https://api.elevenlabs.io"; public const int MaxTextLength = TtsCapability.MaxTextLength; + internal const int MaxResponseBytes = 32 * 1024 * 1024; internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; @@ -82,7 +84,7 @@ public async Task SynthesizeAsync( httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + var bytes = await ReadBoundedResponseAsync(response.Content, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) throw new InvalidOperationException(BuildFailureMessage(response.StatusCode, bytes)); @@ -96,6 +98,33 @@ public async Task SynthesizeAsync( }; } + private static async Task ReadBoundedResponseAsync( + HttpContent content, + CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is > MaxResponseBytes) + throw new InvalidOperationException($"ElevenLabs response exceeds {MaxResponseBytes} bytes."); + + var initialCapacity = content.Headers.ContentLength is > 0 and <= MaxResponseBytes + ? (int)content.Headers.ContentLength.Value + : 0; + using var destination = new MemoryStream(initialCapacity); + using var source = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await source.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + if (read == 0) + break; + if (destination.Length + read > MaxResponseBytes) + throw new InvalidOperationException($"ElevenLabs response exceeds {MaxResponseBytes} bytes."); + + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + } + + return destination.ToArray(); + } + internal static string BuildFailureMessage(HttpStatusCode statusCode, byte[] bodyBytes) { var bodyNote = bodyBytes.Length > 0 ? " Provider returned an error body; see provider logs for details." : ""; diff --git a/tests/OpenClaw.Shared.Tests/AllowedAssetDownloadTests.cs b/tests/OpenClaw.Shared.Tests/AllowedAssetDownloadTests.cs new file mode 100644 index 000000000..3fe58a09d --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/AllowedAssetDownloadTests.cs @@ -0,0 +1,38 @@ +using System; +using OpenClaw.Shared.Audio; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public class AllowedAssetDownloadTests +{ + [Fact] + public void ValidateDownloadUri_AcceptsPinnedHttpsHosts() + { + AllowedAssetDownload.ValidateDownloadUri( + new Uri("https://huggingface.co/owner/model/resolve/main/model.bin"), + initialRequest: true); + AllowedAssetDownload.ValidateDownloadUri( + new Uri("https://cdn-lfs.huggingface.co/repos/model.bin"), + initialRequest: false); + } + + [Fact] + public void ValidateDownloadUri_RejectsOffAllowlistRedirect() + { + var ex = Assert.Throws(() => + AllowedAssetDownload.ValidateDownloadUri( + new Uri("https://evil.example/model.bin"), + initialRequest: false)); + Assert.Contains("untrusted host", ex.Message); + } + + [Fact] + public void ValidateDownloadUri_RejectsHttp() + { + Assert.Throws(() => + AllowedAssetDownload.ValidateDownloadUri( + new Uri("http://huggingface.co/model.bin"), + initialRequest: true)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs b/tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs index 7a7e4f189..24383a2cd 100644 --- a/tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs +++ b/tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs @@ -99,6 +99,28 @@ public async Task SynthesizeAsync_RejectsOversizedTextBeforeNetwork() Assert.Null(handler.LastRequest); } + [Fact] + public async Task SynthesizeAsync_RejectsOversizedResponse() + { + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(new byte[ElevenLabsTextToSpeechClient.MaxResponseBytes + 1]) + { + Headers = { ContentType = new("audio/mpeg") } + } + }); + var client = new ElevenLabsTextToSpeechClient(handler, "https://example.test"); + + var ex = await Assert.ThrowsAsync(() => client.SynthesizeAsync(new ElevenLabsSynthesisRequest + { + ApiKey = "key-123", + VoiceId = "voice-1", + Text = "Hello" + })); + + Assert.Contains(ElevenLabsTextToSpeechClient.MaxResponseBytes.ToString(), ex.Message); + } + [Fact] public void Constructor_SetsRequestTimeout() {