From d95022274928974a35a0147b27abcca228e20414 Mon Sep 17 00:00:00 2001 From: binarybeach Date: Fri, 1 May 2026 00:30:24 -1000 Subject: [PATCH] binarybeachio: fix presigned-PUT signature mismatch on empty Content-Type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plane's frontend calls getFileMetaDataForUpload() which uses the file-type library to sniff MIME from magic bytes. For unsniffable formats (plain text, .json, .csv, etc.) it returns "" — and that empty string was being threaded through to S3Storage.generate_presigned_post(), signing the presigned URL with `Content-Type=""`. Browsers can't reliably send an empty Content-Type header, so the SigV4 signature never matched and R2 returned 403 SignatureDoesNotMatch. UI showed an opaque upload error. Two-sided fix: * apps/api/plane/settings/storage.py — default file_type to "application/octet-stream" when empty/None. The signed URL now always has a non-empty Content-Type the browser can match. * packages/services/src/file/helper.ts — generateFileUploadPayload now prefers the signed Content-Type from upload_data.fields["Content-Type"] over file.type. The browser must send EXACTLY the signed value, not its own MIME guess from extension. Belt-and-suspenders defense alongside the backend default. Reproduced empirically against R2 with the new keys 2026-05-01: empty Content-Type signs, then PUT with `Content-Type: text/plain` returns 403 SignatureDoesNotMatch. With this patch, signing "application/octet-stream" + sending it back verbatim returns 200. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/plane/settings/storage.py | 12 ++++++++++-- packages/services/src/file/helper.ts | 12 +++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index 1087cad0f..e7b6601d9 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -87,13 +87,21 @@ class S3Storage(S3Boto3Storage): """ if expiration is None: expiration = self.signed_url_expiration + # Default to application/octet-stream when caller passes empty/None. + # The file-type library Plane's frontend uses returns "" for unsniffable + # formats (plain text, .json, etc.), which would sign a presigned URL + # with `Content-Type=""`. Browsers can't reliably send an empty + # Content-Type header, so the SigV4 signature would never match and PUT + # would 403. We resolve this by signing a definite default; the + # frontend then sends the signed value verbatim (see helper.ts). + signed_content_type = file_type or "application/octet-stream" try: url = self.s3_client.generate_presigned_url( "put_object", Params={ "Bucket": self.aws_storage_bucket_name, "Key": object_name, - "ContentType": file_type, + "ContentType": signed_content_type, }, ExpiresIn=expiration, HttpMethod="PUT", @@ -105,7 +113,7 @@ class S3Storage(S3Boto3Storage): return { "url": url, "method": "PUT", - "fields": {"Content-Type": file_type, "key": object_name}, + "fields": {"Content-Type": signed_content_type, "key": object_name}, } def _get_content_disposition(self, disposition, filename=None): diff --git a/packages/services/src/file/helper.ts b/packages/services/src/file/helper.ts index c7ac56015..3f4518493 100644 --- a/packages/services/src/file/helper.ts +++ b/packages/services/src/file/helper.ts @@ -87,13 +87,19 @@ export const generateFileUploadPayload = ( headers: { "Content-Type": "multipart/form-data" }, }; } + // Content-Type MUST exactly match what the backend signed in the presigned + // PUT URL — the AWS SigV4 signature includes Content-Type as a signed header. + // If we send the browser's `file.type` (which guesses from extension) but + // the backend signed `fileMetaData.type` (from the file-type library, which + // sniffs file magic bytes), they often disagree and R2 returns 403 + // SignatureDoesNotMatch. Always prefer the signed value. + const signedContentType = + data.fields["Content-Type"] || file.type || "application/octet-stream"; return { url: data.url, method: "PUT", body: file, - headers: { - "Content-Type": file.type || data.fields["Content-Type"] || "application/octet-stream", - }, + headers: { "Content-Type": signedContentType }, }; };