5

I am using the Java Amazon SDK to work with S3 for storing uploaded files. I would like to retain the original filename, and I am putting it at the end of the key, but I am also using virtual directory structure - something like <dirname>/<uuid>/<originalFilename>.

The problem is that when I want to generate a presigned URL for downloading using the api like:

URL url = s3Client.generatePresignedUrl(generatePresignedUrlRequest);
return url.toExternalForm();

The sdk url escapes the entire key, including the slashes. While it still works, it means that the name of the file downloaded includes the entire key instead of just the original filename bit at the end. I know that it should be possible to do this without escaping the slashes, but I'm trying to avoid rewriting a lot of the code already in the SDK. Is there a common solution to this? I know I've used web apps that follow the same pattern and do not have the slash escape problem.

Russell Leggett
  • 8,795
  • 3
  • 31
  • 45

3 Answers3

7

This is a bug in the current Java SDK:

If you look at https://github.com/aws/aws-sdk-java/blob/master/src/main/java/com/amazonaws/services/s3/AmazonS3Client.java#L2820

The method presignRequest which is called internally has the following code:

    String resourcePath = "/" +
        ((bucketName != null) ? bucketName + "/" : "") +
        ((key != null) ? ServiceUtils.urlEncode(key) : "") +
        ((subResource != null) ? "?" + subResource : "");

The key is URL encoded here before signing which I think is the error.

You might be able to inherit from the AmazonS3Client and override the funcion to fix this.

In some places it is suggested to use url.getQuery() and prefix this with your original awsURL (https://forums.aws.amazon.com/thread.jspa?messageID=356271). However as you said yourself this will produce an error, because the resource key will not match the signature.

The following problem might also be related, I did not check out the proposed workarround:

How to generate pre-signed Amazon S3 url for a vanity domain, using amazon sdk?

Amazon recognized and fixed a similar bug before: https://forums.aws.amazon.com/thread.jspa?messageID=418537

So I hope it will be fixed in the next version.

Community
  • 1
  • 1
aKzenT
  • 7,775
  • 2
  • 36
  • 65
  • Have you tried this? I did something similar and got a 403 signature doesn't match. Same was described in that forum post. "Unfortunately it's not quite correct to say you can do it both ways. It's fine if you're using the URL generated by the Java SDK in a generic fashion. Unfortunately if you hand that URL off to a .NET app and it uses it's standard WebRequest class to consume the URL, .NET decodes the %2F to a / and the request then fails with a 403 - Signature does not match." – Russell Leggett Mar 21 '13 at 17:09
  • As I understood your post it works if you manually use the slahes, but you don't want to write the code to do this, no? Using url.getQuery should do exactly this. – aKzenT Mar 21 '13 at 17:19
  • You can't mix and match. When generatePresignedUrl is called it takes the entire key and escapes it, and then creates a signature using the escaped key. You cannot use the signature from the escaped key and combine it with the unescaped key in the URL. Hypothetically, if you could perform the signature code without escaping, you could use the key without escaping. I was just wondering if someone has an easy way of doing this. – Russell Leggett Mar 21 '13 at 17:32
  • Hey, sorry for this. I responded too fast. Doing some more research and looking at the code, I came to the same conclusion. See my recent edit. Long story short I think the easiest way would be to override the erronous method or alternatively implement your own version by checking out how Amazon does it internally. – aKzenT Mar 21 '13 at 17:36
  • Yes, I came to the same conclusion and walked through the code and its at a frustratingly inconvenient place. That's why I came here :) Figured someone probably did it already. – Russell Leggett Mar 21 '13 at 17:39
1

I'm still hoping for a better solution than this, but seeing as @aKzenT has confirmed my conclusion that there is not an existing solution for this I wrote one. Its just a simple subclass of AmazonS3Client. I worry it's brittle because I had to copy a lot of code from the method I overrode, but it seems like the most minimal solution. I can confirm that it works fine in my own code base. I posted the code in a gist, but for the sake of a complete answer:

import com.amazonaws.AmazonWebServiceRequest;
import com.amazonaws.HttpMethod;
import com.amazonaws.Request;
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.handlers.RequestHandler;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.Headers;
import com.amazonaws.services.s3.internal.S3QueryStringSigner;
import com.amazonaws.services.s3.internal.ServiceUtils;

import java.util.Date;

/**
 * This class should be a drop in replacement for AmazonS3Client as long as you use the single credential
 * constructor. It could probably be modified to add additional constructors if needed, but this is the one we use.
 * Supporting all of them didn't seem trivial because of some dependencies in the original presignRequest method.
 *
 * The only real purpose of this class is to change the behavior of generating presigned URLs. The original version
 * escaped slashes in the key and this one does not. Pretty url paths are kept intact.
 *
 * @author Russell Leggett
 */
public class PrettyUrlS3Client extends AmazonS3Client{
    private AWSCredentials awsCredentials;

    /**
     * This constructor is the only one provided because it is only one I needed, and it
     * retains awsCredentials which might be needed in the presignRequest method
     *
     * @param awsCredentials
     */
    public PrettyUrlS3Client(AWSCredentials awsCredentials) {
        super(awsCredentials);
        this.awsCredentials = awsCredentials;
    }

    /**
     * WARNING: This method is an override of the AmazonS3Client presignRequest
     * and copies most of the code. Should be careful of updates to the original.
     *
     * @param request
     * @param methodName
     * @param bucketName
     * @param key
     * @param expiration
     * @param subResource
     * @param <T>
     */
    @Override
    protected <T> void presignRequest(Request<T> request, HttpMethod methodName, String bucketName, String key, Date expiration, String subResource) {

        // Run any additional request handlers if present
        if (requestHandlers != null) {
            for (RequestHandler requestHandler : requestHandlers) {
                requestHandler.beforeRequest(request);
            }
        }
        String resourcePath = "/" +
                ((bucketName != null) ? bucketName + "/" : "") +
                ((key != null) ? keyToEscapedPath(key)/* CHANGED: this is the primary change */ : "") +
                ((subResource != null) ? "?" + subResource : "");

        //the request apparently needs the resource path without a starting '/'
        request.setResourcePath(resourcePath.substring(1));//CHANGED: needed to match the signature with the URL generated from the request
        AWSCredentials credentials = awsCredentials;
        AmazonWebServiceRequest originalRequest = request.getOriginalRequest();
        if (originalRequest != null && originalRequest.getRequestCredentials() != null) {
            credentials = originalRequest.getRequestCredentials();
        }

        new S3QueryStringSigner<T>(methodName.toString(), resourcePath, expiration).sign(request, credentials);

        // The Amazon S3 DevPay token header is a special exception and can be safely moved
        // from the request's headers into the query string to ensure that it travels along
        // with the pre-signed URL when it's sent back to Amazon S3.
        if (request.getHeaders().containsKey(Headers.SECURITY_TOKEN)) {
            String value = request.getHeaders().get(Headers.SECURITY_TOKEN);
            request.addParameter(Headers.SECURITY_TOKEN, value);
            request.getHeaders().remove(Headers.SECURITY_TOKEN);
        }
    }

    /**
     * A simple utility method which url escapes an S3 key, but leaves the
     * slashes (/) unescaped so they can stay part of the url.
     * @param key
     * @return
     */
    public static String keyToEscapedPath(String key){
        String[] keyParts = key.split("/");
        StringBuilder result = new StringBuilder();
        for(String part : keyParts){
            if(result.length()>0){
                result.append("/");
            }
            result.append(ServiceUtils.urlEncode(part));
        }
        return result.toString().replaceAll("%7E","~");
    }
}

UPDATE I updated the gist and this code to fix an issue I was having with ~'s. It was occurring even using the standard client, but unescaping the ~ fixed it. See gist for more details/track any further changes I might make.

Russell Leggett
  • 8,795
  • 3
  • 31
  • 45
1

version 1.4.3 of the Java SDK seems to have fixed this problem. Perhaps, it was fixed earlier, but I can confirm that it is working correctly in 1.4.3.

David Small
  • 156
  • 2
  • 3