diff --git a/packages/Storage/Storage.Tests/Buckets/StorageBucketApiContractTests.cs b/packages/Storage/Storage.Tests/Buckets/StorageBucketApiContractTests.cs
index 8a3e80a3..3370a8df 100644
--- a/packages/Storage/Storage.Tests/Buckets/StorageBucketApiContractTests.cs
+++ b/packages/Storage/Storage.Tests/Buckets/StorageBucketApiContractTests.cs
@@ -165,6 +165,15 @@ public async Task PurgeBucketCache_ShouldRequestTransformationsOnly_GivenTheTran
this.SingleRequest().Query.Should().Contain(pair => pair.Key == "transformations" && pair.Value.Contains("true"));
}
+ [TestMethod]
+ public async Task PurgeBucketCache_ShouldEncodeDelimiters_GivenABucketIdWithUrlDelimiters()
+ {
+ this.Respond("/storage/v1/cdn/*", "DELETE", 200, "{\"message\":\"success\"}");
+ await this.client.PurgeBucketCache("my?bucket");
+ this.SingleRequest().AbsoluteUrl.Should().EndWith("/storage/v1/cdn/my%3Fbucket",
+ "a raw '?' in the id would start a query string and purge the wrong bucket");
+ }
+
private void Respond(string path, string method, int statusCode, string body) =>
this.server.Given(Request.Create().WithPath(path).UsingMethod(method))
.RespondWith(Response.Create().WithStatusCode(statusCode)
diff --git a/packages/Storage/Storage.Tests/Files/StorageFileApiContractTests.cs b/packages/Storage/Storage.Tests/Files/StorageFileApiContractTests.cs
index 0a6c051a..9f37c2d3 100644
--- a/packages/Storage/Storage.Tests/Files/StorageFileApiContractTests.cs
+++ b/packages/Storage/Storage.Tests/Files/StorageFileApiContractTests.cs
@@ -351,6 +351,15 @@ public async Task PurgeCache_ShouldDeleteTheCdnObjectPathWithNoBodyAndReturnTheM
}
}
+ [TestMethod]
+ public async Task PurgeCache_ShouldEncodeDelimiters_GivenAKeyWithUrlDelimiters()
+ {
+ this.Respond("/storage/v1/cdn/*", "DELETE", 200, "{\"message\":\"success\"}");
+ await this.client.From(Bucket).PurgeCache("folder/a?b#c.png");
+ this.SingleRequest().AbsoluteUrl.Should().EndWith($"/storage/v1/cdn/{Bucket}/folder/a%3Fb%23c.png",
+ "an unescaped '?' or '#' truncates the key into a query string or fragment");
+ }
+
[TestMethod]
public async Task Upload_ShouldSurfaceStorageException_GivenNonJsonError()
{
diff --git a/packages/Storage/Storage/Helpers.cs b/packages/Storage/Storage/Helpers.cs
index 08e1119e..723c201e 100644
--- a/packages/Storage/Storage/Helpers.cs
+++ b/packages/Storage/Storage/Helpers.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text;
@@ -168,6 +169,13 @@ HttpRequestMessage CreateRequest()
StorageInstrumentation.RecordRequest(method, builder.Uri, statusCode, errorType, startTimestamp);
}
}
+
+ ///
+ /// Percent-encodes each segment of a storage path so a ? or # in a key can't start
+ /// a query string or fragment, while / stays literal as the separator.
+ ///
+ internal static string EncodePath(string path) =>
+ string.Join("/", path.Split('/').Select(Uri.EscapeDataString));
}
public class GenericResponse
diff --git a/packages/Storage/Storage/StorageBucketApi.cs b/packages/Storage/Storage/StorageBucketApi.cs
index 8f9bc474..17c5736b 100644
--- a/packages/Storage/Storage/StorageBucketApi.cs
+++ b/packages/Storage/Storage/StorageBucketApi.cs
@@ -168,7 +168,7 @@ public async Task CreateBucket(string id, BucketUpsertOptions? options =
CancellationToken cancellationToken = default
)
{
- var url = options.ToPurgeUrl($"{this.Url}/cdn/{id}");
+ var url = options.ToPurgeUrl($"{this.Url}/cdn/{Helpers.EncodePath(id)}");
return Helpers.MakeRequestAsync(this.requestClient, this.Options.Retry, HttpMethod.Delete, url, null, this.Headers, cancellationToken);
}
}
diff --git a/packages/Storage/Storage/StorageFileApi.cs b/packages/Storage/Storage/StorageFileApi.cs
index 1e73664f..38a9c673 100644
--- a/packages/Storage/Storage/StorageFileApi.cs
+++ b/packages/Storage/Storage/StorageFileApi.cs
@@ -811,7 +811,7 @@ public async Task CreateUploadSignedUrl(string supabasePath)
CancellationToken cancellationToken = default
)
{
- var url = options.ToPurgeUrl($"{this.Url}/cdn/{this.GetFinalPath(path)}");
+ var url = options.ToPurgeUrl($"{this.Url}/cdn/{Helpers.EncodePath(this.GetFinalPath(path))}");
return Helpers.MakeRequestAsync(this.requestClient, this.Options.Retry, HttpMethod.Delete, url, null, this.Headers, cancellationToken);
}