fix(uploads): sign Azure upload URLs create-only (#6607)

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.
This commit is contained in:
Waleed
2026-08-11 23:10:42 -07:00
committed by GitHub
parent b5d9e93797
commit 892401a8d0
3 changed files with 17 additions and 7 deletions
@@ -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 \
@@ -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: {
+11 -2
View File
@@ -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,
},