Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/OpenClaw.Shared/Audio/AllowedAssetDownload.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> 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;
}
5 changes: 2 additions & 3 deletions src/OpenClaw.Shared/Audio/PiperVoiceManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions src/OpenClaw.Shared/Audio/WhisperModelManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -82,7 +84,7 @@ public async Task<ElevenLabsSynthesisResult> 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));
Expand All @@ -96,6 +98,33 @@ public async Task<ElevenLabsSynthesisResult> SynthesizeAsync(
};
}

private static async Task<byte[]> 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." : "";
Expand Down
38 changes: 38 additions & 0 deletions tests/OpenClaw.Shared.Tests/AllowedAssetDownloadTests.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() =>
AllowedAssetDownload.ValidateDownloadUri(
new Uri("https://evil.example/model.bin"),
initialRequest: false));
Assert.Contains("untrusted host", ex.Message);
}

[Fact]
public void ValidateDownloadUri_RejectsHttp()
{
Assert.Throws<InvalidOperationException>(() =>
AllowedAssetDownload.ValidateDownloadUri(
new Uri("http://huggingface.co/model.bin"),
initialRequest: true));
}
}
22 changes: 22 additions & 0 deletions tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(() => client.SynthesizeAsync(new ElevenLabsSynthesisRequest
{
ApiKey = "key-123",
VoiceId = "voice-1",
Text = "Hello"
}));

Assert.Contains(ElevenLabsTextToSpeechClient.MaxResponseBytes.ToString(), ex.Message);
}

[Fact]
public void Constructor_SetsRequestTimeout()
{
Expand Down
Loading