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); }