From 2c80f7a56a304cd92040af1c1df9126ccfa0956b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 23:05:08 -0700 Subject: [PATCH] fix(uploads): sign Azure upload URLs create-only getBlobPresignedUploadUrl signed its SAS with BlobSASPermissions.parse('w'). Per Azure's service-SAS reference, `w` is "create or write content" and permits overwriting an existing blob; `c` is "write a new blob" and does not. main signed `c` before this signer moved out of core/storage-service.ts, so Azure deployments lost create-only enforcement in the move. The `If-None-Match: '*'` the signer returns cannot carry the guarantee on its own: an Azure service-SAS string-to-sign covers the resource, times, permissions and the five rsc* response-header overrides, never request headers, so a client is free to drop it. The header is signed on the other two providers -- inside the PutObjectCommand on S3, and as x-goog-if-generation-match in signed extensionHeaders on GCS -- which is why only Azure regressed. Without this, a signed upload URL stayed a plain overwrite grant on the final key for its full hour. A caller could replace the object after complete had already verified size and content type, written the workspace file row and metered storage from that verified HEAD, leaving durable metadata and billing describing content that no longer exists. The multipart block-staging signer keeps `w`: block staging is overwrite-shaped and matches main. The existing test asserted parse('w'), so it locked the defect in; it now asserts create-only, and its name states the guarantee so a future flip reads as deleting a security property rather than adjusting a value. --- .../en/platform/self-hosting/object-storage.mdx | 5 +++-- apps/sim/lib/uploads/providers/blob/client.test.ts | 6 +++--- apps/sim/lib/uploads/providers/blob/client.ts | 13 +++++++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx index 43dbc764f41..9df9f78731f 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx @@ -234,8 +234,9 @@ AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos Direct browser uploads require a Blob service CORS rule on the storage account. Allow your exact Sim origin, `GET` and `PUT`, the `Content-Type` header, and the `x-ms-*` prefix used by signed blob -and metadata headers. Small-file uploads also send `If-None-Match` so a signed URL cannot overwrite -an existing final object. Multipart uploads also read `ETag` from the browser response: +and metadata headers. Small-file uploads also send `If-None-Match`; the signature itself is +create-only, so a signed URL cannot overwrite an existing final object. Multipart uploads also read +`ETag` from the browser response: ```bash az storage cors add \ diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts index 99363a6213c..378ff8508f2 100644 --- a/apps/sim/lib/uploads/providers/blob/client.test.ts +++ b/apps/sim/lib/uploads/providers/blob/client.test.ts @@ -165,8 +165,8 @@ describe('Azure Blob Storage Client', () => { 'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net', } - it('signs a PUT with the required blob and metadata headers', async () => { - mockBlobSASPermissionsParse.mockReturnValueOnce('w') + it('signs a create-only PUT with the required blob and metadata headers', async () => { + mockBlobSASPermissionsParse.mockReturnValueOnce('c') const result = await getBlobPresignedUploadUrl({ key: 'workspace/workspace-1/file.bin', @@ -176,7 +176,7 @@ describe('Azure Blob Storage Client', () => { expiresIn: 600, }) - expect(mockBlobSASPermissionsParse).toHaveBeenCalledWith('w') + expect(mockBlobSASPermissionsParse).toHaveBeenCalledWith('c') expect(result).toEqual({ url: expect.stringContaining('?sv=2021-06-08'), headers: { diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index 7cb350fda7d..c35752963fe 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -275,7 +275,16 @@ export async function getPresignedUrlWithConfig( return `${blockBlobClient.url}?${sasToken}` } -/** Generates a create-only SAS-backed single-object PUT for a caller-selected final key. */ +/** + * Generates a create-only SAS-backed single-object PUT for a caller-selected final key. + * + * The permission must stay `c` (create), not `w`: `w` is "create or write + * content" and authorizes overwriting an existing blob, so a still-valid + * signature could replace a completed upload. `If-None-Match` is returned for + * parity with the S3 and GCS signers, but Azure does not cover request headers + * in a service-SAS string-to-sign, so it is advisory and cannot carry this on + * its own. + */ export async function getBlobPresignedUploadUrl(params: { key: string contentType: string @@ -301,7 +310,7 @@ export async function getBlobPresignedUploadUrl(params: { { containerName: params.customConfig.containerName, blobName: params.key, - permissions: BlobSASPermissions.parse('w'), + permissions: BlobSASPermissions.parse('c'), startsOn, expiresOn, },