To create a presigned URL, we have to provide matching headers in the HTTP upload -- but us-east-1 doesn't require all of them, while other regions do.
I've done most of my development in us-east-1 since I lived in Northern Virginia and worked in the area; heck, I've probably ridden past one of the unlabeled AWS datacenters along the W & OD bike tail in Loudon County.
Our work for a certain US space agency hosted most most of our compute in us-east-1. We've done lots of apps that use presigned URLs to upload to S3, and they've worked fine.
Until recently, I hadn't deployed to other regions (besides GovCloud). I now live in Spain and want to deploy to a closer region -- Paris seemed proximate. So I configured my Serverless Framework to use eu-west-3, and it deployed fine there. But the presigned URLs broke: exact same code, just a different region. I then tried us-east-2 (Ohio), and had the same failures. It seems us-east-1 is "special", which is not good.
My app's using Python and the boto3 library. I went down a rat-hole with S3 client specification and Configuration, and Signature methods to no avail. I also read that newly-created S3 buckets take a while to propagate their DNS names, and I've seen this when I tried to PUT and got an HTTP Temporary Redirect from S3: changing the URL would surely invalidate the signature. But these turned out not to be the problem either.
My real app's code runs in a Lambda where the Execution Role gives it permission to upload to S3. To debug the problem I extracted the main presigned URL upload logic here so I can run it locally.
I want to be able to accept an HTTP Content-Type and
Content-Disposition on upload so downloads will be named properly;
I also want to store the uploaded file's name in the S3 object
metadata, and perhaps other info like the file's owner. The boto3
docs for put_object
suggest that many headers may be sent to configure the uploaded
object, like ContentType, ContentDispostion that we use here.
This AWS re:post
mentions them but not their provenance. I have not found a definitive
list of similar values I can provide to Params in
generate_presigned_url().
As I said, I started working in us-east-1. My client app (and curl) set the
HTTP Content-Type header upon upload to the presigned URL, so I
was obliged to include those in the presigned URL:
def _get_presigned_url_put(bucket, key, filename, mimetype, expire_seconds):
params = {
"Bucket": bucket,
"Key": key,
"ContentType": mimetype,
"Metadata": {"filename": filename,
"Content-Disposition": f"attachment; filename={filename}"},
}
url = S3C.generate_presigned_url(
ClientMethod="put_object",
ExpiresIn=expire_seconds,
HttpMethod="PUT",
Params=params,
)
return url
And this worked fine in us-east-1. But when I moved to app and bucket in us-east-2 or eu-west-3, it failed. This was repeatable.
I can set "system defined" characteristics of the file in the
presigned URL in Params, and "user defined" attributes in
Metadata:
content_disposition = f'attachment; filename="{filename}"'
http_method = "PUT"
mimetype = mimetypes.guess_type(filename)[0]
metadata = {
"filename": filename,
"magic_words": "Squeamish Ossifrage",
}
s3c = boto3.client("s3", region_name=region)
url = s3c.generate_presigned_url(
ClientMethod="put_object",
ExpiresIn=3600,
HttpMethod=http_method,
Params={
"Bucket": bucket,
"Key": filename,
"ContentDisposition": content_disposition,
"ContentType": mimetype,
"Metadata": metadata,
},
)
The Params attributes (ContentDisposition and
ContentType) had to be matched on the upload by properly-spelled
HTTP headers Content-Disposition and Content-Type. The custom
settings in Metadata had to be matched with key-value pairs, where
the key names were downcased and prefixed by x-amz-meta-:
headers = {
"Content-Type": mimetype,
"Content-Disposition": content_disposition,
}
headers.update({f"x-amz-meta-{k.lower()}": v for k, v in metadata.items()})
Note that if I change the Params I'll need to update the
headers to match, since they're spelled differently than the HTTP
header names.
To make it easier for the client uploader, I return not only the presigned URL but also the headers it will need to supply, with the right spelling for HTTP.
I use the serverless.yml file to define my infrastructure, extracted from my larger app. I deploy three times, one for each region in which I want an S3 bucket.
If we run the code, it tries the three identically-configured buckets in three regions: us-east-1, us-east-2, eu-west-3. The upload succeeds in each case.
But if we suppress the part where we add headers for the custom
Metadata items:
headers = {}
# headers = {f"x-amz-meta-{k.lower()}": v for k, v in metadata.items()}
we see that us-east-1 is happy to accept the file, but the other regions are not:
./psurl.py
region='us-east-1' method='PUT'
headers={'Content-Type': 'image/png', 'Content-Disposition': 'attachment; filename="fire.png"'}
put_url[:90]='https://psurl-dev-s3assets-111savi37w6pt.s3.amazonaws.com/fire.png?AWSAccessKeyId=AKIASGHG'
res.status_code=200 res.reason='OK'
region='us-east-2' method='PUT'
headers={'Content-Type': 'image/png', 'Content-Disposition': 'attachment; filename="fire.png"'}
put_url[:90]='https://psurl-dev-s3assets-1btlz2jfl73sj.s3.amazonaws.com/fire.png?X-Amz-Algorithm=AWS4-HM'
res.status_code=403 res.reason='Forbidden'
### ERROR b'<?xml version="1.0" encoding="UTF-8"?>\n<Error><Code>SignatureDoesNotMatch</Code><Message>T'
region='eu-west-3' method='PUT'
headers={'Content-Type': 'image/png', 'Content-Disposition': 'attachment; filename="fire.png"'}
put_url[:90]='https://psurl-dev-s3assets-19rz00qdke5v6.s3.amazonaws.com/fire.png?X-Amz-Algorithm=AWS4-HM'
res.status_code=403 res.reason='Forbidden'
### ERROR b'<?xml version="1.0" encoding="UTF-8"?>\n<Error><Code>SignatureDoesNotMatch</Code><Message>T'
Note that the URL for us-east-1 starts with AWSAccessKeyId while
the other regions' URL starts with X-Amz-Algorithm. That's not what I'd
expect.
Restoring the headers for Metadata allows all regions to succeed again.
As usual, us-east-1 is a snowflake.