Uploading Files To Amazon S3 Using Pre-Signed (Query String Authentication) URLs
In the past, I've looked at generating pre-signed (Query String Authentication) URLs as a means to grant download-access to files on Amazon S3 (Simple Storage Service). But, pre-signed URLs can also be used to grant upload-access to files on Amazon S3. This is similar to an S3 upload policy; but it's far easier to generate due to its less flexible nature.
To demonstrate, I'm going to generate a pre-signed URL for a specific S3 resource. Then, I'm going to use ColdFusion's HTTP client to upload (ie, "PUT") a binary value to said resource. When we generate the signature for this pre-signed request, take note that I am defining the Content-Type of the upload. Unlike a GET-based pre-signed URL, which cannot send a Content-Type (and therefore cannot include it in the signature), a PUT-based request does have the opportunity to define the Content-Type of the upload.
<cfscript> | |
/** | |
* I get the expiration in seconds based on the given expires-at date. This takes | |
* care of the UTC conversion and expects to receive a date in local time. | |
* | |
* @output false | |
*/ | |
public numeric function getExpirationInSeconds( required date expiresAt ) { | |
var localEpoch = dateConvert( "utc2local", "1970/01/01" ); | |
return( dateDiff( "s", localEpoch, expiresAt ) ); | |
} | |
/** | |
* I generate the signature for the given resource which will be available until | |
* the given expiration date (in seconds). | |
* | |
* For GET requests, the contentType is expected to be the empty-string; for PUT | |
* requests, the contentType is expected to match one of the HTTP request headers. | |
* | |
* @output false | |
*/ | |
public string function generateSignature( | |
required string method, | |
required string contentType, | |
required string resource, | |
required numeric expirationInSeconds | |
) { | |
var stringToSignParts = [ | |
ucase( method ), | |
"", | |
contentType, | |
expirationInSeconds, | |
resource | |
]; | |
var stringToSign = arrayToList( stringToSignParts, chr( 10 ) ); | |
var signature = hmac( stringToSign, aws.secretKey, "HmacSHA1", "utf-8" ); | |
// By default, ColdFusion returns the Hmac in Hex; we need to convert it to | |
// base64 for usag in the pre-signed URL. | |
return( | |
binaryEncode( binaryDecode( signature, "hex" ), "base64" ) | |
); | |
} | |
/** | |
* I encode the given S3 object key for use in a url. Amazon S3 keys have some non- | |
* standard behavior for encoding - see this Amazon forum thread for more information: | |
* https://forums.aws.amazon.com/thread.jspa?threadID=55746 | |
* | |
* @output false | |
*/ | |
public string function urlEncodeS3Key( required string key ) { | |
key = urlEncodedFormat( key, "utf-8" ); | |
// At this point, we have a key that has been encoded too aggressively by | |
// ColdFusion. Now, we have to go through and un-escape the characters that | |
// AWS does not expect to be encoded. | |
// The following are "unreserved" characters in the RFC 3986 spec for Uniform | |
// Resource Identifiers (URIs) - http://tools.ietf.org/html/rfc3986#section-2.3 | |
key = replace( key, "%2E", ".", "all" ); | |
key = replace( key, "%2D", "-", "all" ); | |
key = replace( key, "%5F", "_", "all" ); | |
key = replace( key, "%7E", "~", "all" ); | |
// Technically, the "/" characters can be encoded and will work. However, if the | |
// bucket name is included in this key, then it will break (since it will bleed | |
// into the S3 domain: "s3.amazonaws.com%2fbucket"). As such, I like to unescape | |
// the slashes to make the function more flexible. Plus, I think we can all agree | |
// that regular slashes make the URLs look nicer. | |
key = replace( key, "%2F", "/", "all" ); | |
// This one isn't necessary; but, I think it makes for a more attactive URL. | |
// -- | |
// NOTE: That said, it looks like Amazon S3 may always interpret a "+" as a | |
// space, which may not be the way other servers work. As such, we are leaving | |
// the "+"" literal as the encoded hex value, %2B. | |
key = replace( key, "%20", "+", "all" ); | |
return( key ); | |
} | |
// ------------------------------------------------------ // | |
// ------------------------------------------------------ // | |
// Include my AWS credentials (so they are not in the code). Creates the structure: | |
// * aws.bucket | |
// * aws.accessID | |
// * aws.secretKey | |
include "./credentials.cfm"; | |
// Define the upload location (key) of the file. | |
key = urlEncodeS3Key( "signed-urls/upload-test/monkey.jpg" ); | |
// Define the full resource of our key in our bucket. | |
resource = ( "/" & aws.bucket & "/" & key ); | |
// Define the expiration after which this pre-signed URL is no longer valid (and will | |
// be rejected by AWS). | |
expirationInSeconds = getExpirationInSeconds( dateAdd( "n", 30, now() ) ); | |
// Generate the signature for the query-string authentication. | |
// -- | |
// NOTE: The content-type in the signature has to match the content type in the | |
// outgoing HTTP request headers. | |
signature = generateSignature( "PUT", "image/jpg", resource, expirationInSeconds ); | |
urlEncodedSignature = urlEncodedFormat( signature ); | |
// Create our pre-signed URL from the various parts. | |
preSignedUrl = "https://s3.amazonaws.com#resource#?AWSAccessKeyId=#aws.accessID#&Expires=#expirationInSeconds#&Signature=#urlEncodedSignature#"; | |
// ------------------------------------------------------ // | |
// ------------------------------------------------------ // | |
// Now that we have our pre-signed URL, we can use it to upload a file to Amazon S3. | |
// Note that the pre-signed URL is specific to a given file and expiration date. As | |
// such, this doesn't grant free-range; but, rather very targeted access based on | |
// both the resource key and the file type. | |
uploadRequest = new Http( | |
method = "put", | |
url = preSignedUrl, | |
getAsBinary = "yes" | |
); | |
// This header is required if the contentType is non-empty in the signature. | |
uploadRequest.addParam( | |
type = "header", | |
name = "Content-Type", | |
value = "image/jpg" | |
); | |
uploadRequest.addParam( | |
type = "body", | |
value = fileReadBinary( expandPath( "./monkey.jpg" ) ) | |
); | |
result = uploadRequest.send(); | |
// Output the results of the pre-signed URL upload. | |
writeOutput( result.getPrefix().statusCode ); | |
writeOutput( "<br />" ); | |
writeOutput( charsetEncode( result.getPrefix().fileContent, "utf-8" ) ); | |
</cfscript> |
As you can see, the URL of the HTTP PUT is the pre-signed URL. And, when we run the above code, we get the following CFDump output:
200 OK
The image binary was successfully upload to Amazon S3 and resides at the resource defined in the pre-signed URL. By default, the uploaded object is "private"; if you want to make it public, you have to add additional Amazon Headers to the request (though, I have not tried this personally).
While the Amazon S3 upload policy allows for a lot more flexibility, uploading files using a pre-signed URL is certainly much more straightforward. Now that I see this, I'll have to go back and try to refactor some of my "upload policy" experiments.
Want to use code from this post? Check out the license.
Reader Comments
@All,
So, last night, I tried to play around with HTTP Form posts to Amazon S3 using pre-signed URLs; but, it looks like that isn't supported. In some older documentation for POST, I found this:
> Query string authentication is not supported for POST.
And, it seems rather complicated to get PUT to work from the browser.
That said, it seems like a good use-case for the PUT-based upload with pre-signed URLs is if you need to pass an upload URL to some other server-side process that needs to upload a file but doesn't necessarily have access to your credentials (such as a Message Queue job).
Very helpful example!
I was relying on <cffile> operations and was experiencing an issue where .jpg files were uploaded with a metadata content type of application/octet.
This example contained the content type definition I was looking for.