Is there any way to allow Content-Type
header with multipart uploads to presigned s3 url?
Let's begin with the following code:
import boto3import requestsBUCKET_NAME = "foo"# No, it's global in this MRE onlyclient = boto3.client('s3')def create(key): response = client.create_multipart_upload(Bucket=BUCKET_NAME, Key=key) return response['UploadId']def get_url(key, upload_id, chunk_number): signed_url = client.generate_presigned_url( ClientMethod='upload_part', Params={"Bucket": BUCKET_NAME,"Key": key,"PartNumber": chunk_number,"UploadId": upload_id, # "ContentType": "application/x-www-form-urlencoded", }, ExpiresIn=60 * 60, # seconds ) return signed_urldef complete(key, upload_id, parts): client.complete_multipart_upload( Bucket=BUCKET_NAME, Key=key, UploadId=upload_id, MultipartUpload={"Parts": parts} )def test_upload(): key = 'data/foo.bar' upload_id = create(key) url = get_url(key, upload_id, 1) with open("/tmp/foo.bar", "rb") as src: response = requests.put(url, data=src.read()) etag = response.headers["ETag"] complete(key, upload_id, {"ETag": etag, "PartNumber": 1})
Hooray, this works. However, let's try to do the same from frontend, replacing requests
call with
fetch(uploadTo, { method: 'PUT', body: blob,})
(no matter how blob is defined here, this is irrelevant to our problem).
And this fails, returning 403 SignatureDoesNotMatch
. Why? Because Content-Type
header is set (and fetch
cannot do without it), and this is part of S3-side backend signature verification. Content-Type
is not a part of generated URL, so with any content type fetch
tries to set this will not match. I know this is the case, because here's what response looks like (with only URL being different, ignore this incompatibilities, uploadId=1
is just a fake - same thing happens with real URL; pay attention to StringToSign tag):
<?xml version="1.0" encoding="UTF-8"?><Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message><AWSAccessKeyId>...</AWSAccessKeyId><StringToSign>PUT application/x-www-form-urlencoded 1684064749 x-amz-security-token:FwoGZXIvYXdzEOT//////////wEaDE+2gqVw4NTt1c2eOCKGAVTf4uDCD+GJ8P6lG2vBg8yQ2dqyU7/6aHg4hXMljyDFByT7hJ1/F/GPwBi84eAMDZqGzXpIySe8PhU80ak5C4vg7vcGOOSaB3cXk7TtQ2q0pWb8MB0AYb3LGAJ6sahySjHSdArFFADB60u6SskWhq9HHSijilW9hKIiUgdceZAPhLH1J59oKITngqMGMijigFpTERPtZLB+MOjIqJIpHvPJrrfRg4mzwAmZbk+rropyYha4rBNP /sim-cal-bucket/temp/foo.mp4?partNumber=1&uploadId=1</StringToSign><SignatureProvided>miwrnxtxoPdGnuAEqiP52ZMscBQ=</SignatureProvided><StringToSignBytes>50 55 54 0a 0a 61 70 70 6c 69 63 61 74 69 6f 6e 2f 78 2d 77 77 77 2d 66 6f 72 6d 2d 75 72 6c 65 6e 63 6f 64 65 64 0a 31 36 38 34 30 36 34 37 34 39 0a 78 2d 61 6d 7a 2d 73 65 63 75 72 69 74 79 2d 74 6f 6b 65 6e 3a 46 77 6f 47 5a 58 49 76 59 58 64 7a 45 4f 54 2f 2f 2f 2f 2f 2f 2f 2f 2f 2f 77 45 61 44 45 2b 32 67 71 56 77 34 4e 54 74 31 63 32 65 4f 43 4b 47 41 56 54 66 34 75 44 43 44 2b 47 4a 38 50 36 6c 47 32 76 42 67 38 79 51 32 64 71 79 55 37 2f 36 61 48 67 34 68 58 4d 6c 6a 79 44 46 42 79 54 37 68 4a 31 2f 46 2f 47 50 77 42 69 38 34 65 41 4d 44 5a 71 47 7a 58 70 49 79 53 65 38 50 68 55 38 30 61 6b 35 43 34 76 67 37 76 63 47 4f 4f 53 61 42 33 63 58 6b 37 54 74 51 32 71 30 70 57 62 38 4d 42 30 41 59 62 33 4c 47 41 4a 36 73 61 68 79 53 6a 48 53 64 41 72 46 46 41 44 42 36 30 75 36 53 73 6b 57 68 71 39 48 48 53 69 6a 69 6c 57 39 68 4b 49 69 55 67 64 63 65 5a 41 50 68 4c 48 31 4a 35 39 6f 4b 49 54 6e 67 71 4d 47 4d 69 6a 69 67 46 70 54 45 52 50 74 5a 4c 42 2b 4d 4f 6a 49 71 4a 49 70 48 76 50 4a 72 72 66 52 67 34 6d 7a 77 41 6d 5a 62 6b 2b 72 72 6f 70 79 59 68 61 34 72 42 4e 50 0a 2f 73 69 6d 2d 63 61 6c 2d 62 75 63 6b 65 74 2f 74 65 6d 70 2f 66 6f 6f 2e 6d 70 34 3f 70 61 72 74 4e 75 6d 62 65 72 3d 31 26 75 70 6c 6f 61 64 49 64 3d 31</StringToSignBytes><RequestId>073V6QJXMA0XAKWS</RequestId><HostId>1pR1Pz4RSnRilgjUbb0AVDcMWiqCq05dMrAVU+0t4a0HF5ytfXmNiIecxH80urVoiKtxtHhxS2o=</HostId></Error>
So, we need to pass a Content-Type
to signed url somehow. Neither generate_signed_url
nor its Params
(that must match params of upload_part
) accept ContentType
option. This looks like a dead end... To double confirm, here's what works in JS - ContentType
is passed to the signer.
Well, for now I'm just monkeypatching botocore to allow passing ContentType
parameter to upload_part
(cloned botocore/data/s3/2006-03-01/service-2.json
and added this parameter to UploadPartRequest
definition, patching this file in venv in Dockerfile), but it's certainly not what I want. However, this confirms that I really need to pass ContentType
, and no other solution can allow setting this header. After uncommenting ContentType
key in the sample above, everything is fine.
Just to compare, below are urls without and with content type - this arg is included in url directly. The latter URL works with frontend fetch
flawlessly.
https://sim-cal-bucket.s3.amazonaws.com/temp/foo.mp4?partNumber=1&uploadId=1&AWSAccessKeyId=...&Signature=miwrnxtxoPdGnuAEqiP52ZMscBQ%3D&x-amz-security-token=FwoGZXIvYXdzEOT%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDE%2B2gqVw4NTt1c2eOCKGAVTf4uDCD%2BGJ8P6lG2vBg8yQ2dqyU7%2F6aHg4hXMljyDFByT7hJ1%2FF%2FGPwBi84eAMDZqGzXpIySe8PhU80ak5C4vg7vcGOOSaB3cXk7TtQ2q0pWb8MB0AYb3LGAJ6sahySjHSdArFFADB60u6SskWhq9HHSijilW9hKIiUgdceZAPhLH1J59oKITngqMGMijigFpTERPtZLB%2BMOjIqJIpHvPJrrfRg4mzwAmZbk%2BrropyYha4rBNP&Expires=1684064749https://sim-cal-bucket.s3.amazonaws.com/temp/foo.mp4?partNumber=1&uploadId=1&AWSAccessKeyId=...&Signature=1FeHhXi7QRtL0wCT7kJ%2BVEcBeso%3D&content-type=application%2Fx-www-form-urlencoded&x-amz-security-token=FwoGZXIvYXdzEOT%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDE%2B2gqVw4NTt1c2eOCKGAVTf4uDCD%2BGJ8P6lG2vBg8yQ2dqyU7%2F6aHg4hXMljyDFByT7hJ1%2FF%2FGPwBi84eAMDZqGzXpIySe8PhU80ak5C4vg7vcGOOSaB3cXk7TtQ2q0pWb8MB0AYb3LGAJ6sahySjHSdArFFADB60u6SskWhq9HHSijilW9hKIiUgdceZAPhLH1J59oKITngqMGMijigFpTERPtZLB%2BMOjIqJIpHvPJrrfRg4mzwAmZbk%2BrropyYha4rBNP&Expires=1684065651
Solutions suggesting to make a request without Content-Type
are unacceptable, because this is a part of public API, and I do not want to make customers jump through hoops trying to send such request.