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:
parent
c7ddc4648b
commit
d950222749
2 changed files with 19 additions and 5 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue