binarybeachio: fix presigned-PUT signature mismatch on empty Content-Type

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) <noreply@anthropic.com>
This commit is contained in:
binarybeach 2026-05-01 00:30:24 -10:00
parent c7ddc4648b
commit d950222749
2 changed files with 19 additions and 5 deletions

View file

@ -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):

View file

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