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, },