Calling AWS SDK v2 Directly from CFML, Part 2: Uploading Directly to Amazon S3

Let ColdFusion authorize the upload while the browser sends the file directly to a private Amazon S3 bucket.

In part one, we loaded the Amazon Web Services software development kit into ColdFusion and built a factory capable of creating service clients. That was a substantial amount of work to accomplish something ColdFusion already does quite well.

ColdFusion can upload files to Amazon Simple Storage Service using its native file functions. If the file has already reached the ColdFusion server, those functions may be all we need.

Our goal is to prevent the file from reaching ColdFusion in the first place. A traditional upload travels through our infrastructure twice:

Browser
    |
    v
ColdFusion
    |
    v
Amazon S3

The browser sends the complete file to ColdFusion. ColdFusion stores it in memory or temporary storage, validates it, and sends it to Amazon. During that process, a ColdFusion request remains occupied moving bytes from one network connection to another. This is a perfectly reasonable arrangement if our long-term infrastructure plan is to make ColdFusion pretend to be a garden hose.

A direct upload changes the route:

Browser ---- request permission ----> ColdFusion
Browser -------- file data ---------> Amazon S3
Browser ---- report completion -----> ColdFusion
ColdFusion ----- verify object -----> Amazon S3

ColdFusion still authenticates the user and decides whether the upload is allowed. It chooses the bucket and object key, restricts the content type, and creates a short-lived presigned request. The browser then sends the file directly to Amazon using that request. The browser never receives our Amazon credentials. The bucket doesn’t become public. The presigned address temporarily authorizes one operation against one object key using the permissions of the identity that created it.

Amazon documents this pattern in its guide to uploading objects with presigned addresses.

There are three important rules:

  • The server chooses the object key.
  • The presigned request expires quickly.
  • The server verifies the resulting object before trusting it.

A filename supplied by a browser is display information. It is not a storage path. If somebody uploads ../../quarterly-report.pdf, we shouldn’t reward their creativity by incorporating it into an object key. We’ll generate our own identifier and initially place the object beneath a quarantine prefix:

quarantine/3fc42cf5-7e13-4db5-94f4-cdd96a20cb58

After verification, Amazon will copy it to:

objects/3fc42cf5-7e13-4db5-94f4-cdd96a20cb58

That copy is important because a presigned request can be reused until it expires. If we accepted the quarantine key as the permanent object, somebody holding the address could replace the file after we verified it. Instead, we verify the quarantine object, copy that exact version to its final key, and delete the temporary object. Reusing the presigned request can recreate the quarantine object, but it can’t alter the accepted copy. A bucket lifecycle rule can remove abandoned quarantine objects after a day. Security engineering frequently consists of asking, “What if they do it twice?” and then becoming unhappy with the answer.

The browser will be sending a cross-origin request. Our application might be running at:

https://app.example.com

while the upload goes to an address resembling:

https://example-private-uploads.s3.ca-central-1.amazonaws.com

The bucket therefore needs a cross-origin resource sharing configuration. Add this configuration through the Amazon Simple Storage Service console or your infrastructure management system:

[
    {
        "AllowedHeaders": [ "content-type", "x-amz-meta-upload-id" ],
        "AllowedMethods": [ "PUT" ],
        "AllowedOrigins": [ "https://app.example.com" ],
        "ExposeHeaders": [ "ETag" ],
        "MaxAgeSeconds": 300
    }
]

Replace the example origin with the exact origin serving your application. Don’t use * unless every website on the internet is genuinely supposed to upload through these requests.

The cross-origin configuration doesn’t grant permission to the bucket. Bucket policies and presigned-request authorization still apply. It only tells the browser that JavaScript from the named origin may perform the permitted request. Amazon explains the distinction in its cross-origin resource sharing documentation.

The Amazon identity used by ColdFusion needs permission to upload, inspect, copy, and delete objects within the two prefixes:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:s3:::example-private-uploads/quarantine/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::example-private-uploads/objects/*"
            ]
        }
    ]
}

Replace the bucket name and tighten the policy further if your application has a more specific object structure. The bucket should retain its block-public-access settings. We’re issuing temporary permission to one object, not reopening the public-access debate and inviting 2012 back into our lives.

Part one’s client factory can already create an Amazon Simple Storage Service client. Add this method so it can create a presigner using the same region and credential provider:

public any function createS3Presigner(
    string region_name = variables.default_region
) {
    var region = createObject( "java", "software.amazon.awssdk.regions.Region" ).of( trim( arguments.region_name ) );

    return createObject( "java", "software.amazon.awssdk.services.s3.presigner.S3Presigner" )
        .builder()
        .region( region )
        .credentialsProvider( variables.credentials_provider )
        .build();
}

The presigner doesn’t contact Amazon when it creates a signed request. It uses our credentials locally to calculate a signature containing the operation, bucket, key, expiration, and any signed request headers. Create models/direct_s3_upload_service.cfc:

component output="false" {
    public any function init(
        required any client_factory,
        required string bucket_name,
        numeric maximum_bytes = 10485760,
        numeric signature_minutes = 5
    ) {
        variables.bucket_name = trim( arguments.bucket_name );
        variables.maximum_bytes = fix( arguments.maximum_bytes );
        variables.signature_minutes = fix( arguments.signature_minutes );
        variables.allowed_content_types = {
            "image/jpeg": true,
            "image/png": true
        };

        if ( !len( variables.bucket_name ) ) {
            throw( type = "DirectS3Upload.InvalidConfiguration", message = "An upload bucket is required." );
        }

        if ( variables.maximum_bytes < 1 ) {
            throw( type = "DirectS3Upload.InvalidConfiguration", message = "The maximum upload size must be greater than zero." );
        }

        if ( variables.signature_minutes < 1 || variables.signature_minutes > 15 ) {
            throw( type = "DirectS3Upload.InvalidConfiguration", message = "The signature lifetime must be between one and fifteen minutes." );
        }

        variables.s3_client = arguments.client_factory.createClient( "s3" );
        variables.presigner = arguments.client_factory.createS3Presigner();

        return this;
    }

    public struct function createUpload(
        required string user_id,
        required string content_type,
        required numeric content_length
    ) {
        var normalized_user_id = trim( arguments.user_id );
        var normalized_content_type = lCase( trim( arguments.content_type ) );
        var normalized_content_length = fix( arguments.content_length );

        if ( !len( normalized_user_id ) ) {
            throw( type = "DirectS3Upload.InvalidUser", message = "An authenticated user is required." );
        }

        if ( !structKeyExists( variables.allowed_content_types, normalized_content_type ) ) {
            throw( type = "DirectS3Upload.InvalidContentType", message = "Only JPEG and PNG images are accepted." );
        }

        if ( normalized_content_length < 1 || normalized_content_length > variables.maximum_bytes ) {
            throw( type = "DirectS3Upload.InvalidSize", message = "The file is empty or exceeds the upload limit." );
        }

        var upload_id = lCase( createUUID() );
        var staging_key = "quarantine/" & upload_id;
        var final_key = "objects/" & upload_id;

        var metadata = createObject( "java", "java.util.HashMap" ).init();
        metadata.put( javaCast( "string", "upload-id" ), javaCast( "string", upload_id ) );

        var object_request = createObject( "java", "software.amazon.awssdk.services.s3.model.PutObjectRequest" )
            .builder()
            .bucket( variables.bucket_name )
            .key( staging_key )
            .contentType( normalized_content_type )
            .metadata( metadata )
            .build();

        var duration = createObject( "java", "java.time.Duration" ).ofMinutes( javaCast( "long", variables.signature_minutes ) );

        var presign_request = createObject( "java", "software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest" )
            .builder()
            .signatureDuration( duration )
            .putObjectRequest( object_request )
            .build();

        var signed_request = variables.presigner.presignPutObject( presign_request );

        return {
            intent: {
                upload_id: upload_id,
                user_id: normalized_user_id,
                staging_key: staging_key,
                final_key: final_key,
                content_type: normalized_content_type,
                content_length: normalized_content_length,
                expires_at: dateAdd( "n", variables.signature_minutes, now() )
            },
            browser: {
                upload_id: upload_id,
                method: "PUT",
                url: signed_request.url().toExternalForm(),
                headers: {
                    "Content-Type": normalized_content_type,
                    "x-amz-meta-upload-id": upload_id
                }
            }
        };
    }

    public struct function verifyAndFinalize( required struct intent ) {
        var head_request = createObject( "java", "software.amazon.awssdk.services.s3.model.HeadObjectRequest" )
            .builder()
            .bucket( variables.bucket_name )
            .key( arguments.intent.staging_key )
            .build();

        try {
            var head_response = variables.s3_client.headObject( head_request );
        } catch ( any error ) {
            throw(
                type = "DirectS3Upload.ObjectNotFound",
                message = "The uploaded object could not be verified.",
                detail = error.message
            );
        }

        var actual_content_length = val( head_response.contentLength() );
        var actual_content_type = lCase( toString( head_response.contentType() ?: "" ) );
        var actual_upload_id = "";

        if ( head_response.metadata().containsKey( "upload-id" ) ) {
            actual_upload_id = toString( head_response.metadata().get( "upload-id" ) );
        }

        var violations = [];

        if ( actual_upload_id != arguments.intent.upload_id ) {
            arrayAppend( violations, "upload identifier" );
        }

        if ( actual_content_length != arguments.intent.content_length ) {
            arrayAppend( violations, "content length" );
        }

        if ( actual_content_length < 1 || actual_content_length > variables.maximum_bytes ) {
            arrayAppend( violations, "upload limit" );
        }

        if ( actual_content_type != arguments.intent.content_type ) {
            arrayAppend( violations, "content type" );
        }

        if ( arrayLen( violations ) ) {
            deleteQuietly( arguments.intent.staging_key );

            throw(
                type = "DirectS3Upload.VerificationFailed",
                message = "The uploaded object did not match the authorized upload.",
                detail = arrayToList( violations )
            );
        }

        var copy_request = createObject( "java", "software.amazon.awssdk.services.s3.model.CopyObjectRequest" )
            .builder()
            .copySource( variables.bucket_name & "/" & arguments.intent.staging_key )
            .copySourceIfMatch( head_response.eTag() )
            .destinationBucket( variables.bucket_name )
            .destinationKey( arguments.intent.final_key )
            .build();

        try {
            var copy_response = variables.s3_client.copyObject( copy_request );
        } catch ( any error ) {
            throw(
                type = "DirectS3Upload.FinalizationFailed",
                message = "The uploaded object could not be finalized.",
                detail = error.message
            );
        }

        deleteQuietly( arguments.intent.staging_key );

        return {
            upload_id: arguments.intent.upload_id,
            object_key: arguments.intent.final_key,
            content_type: actual_content_type,
            content_length: actual_content_length,
            entity_tag: toString( copy_response.copyObjectResult().eTag() ?: "" )
        };
    }

    public void function discardUpload(
        required string staging_key
    ) {
        deleteQuietly( arguments.staging_key );
    }

    public void function shutdown() {
        try {
            variables.presigner.close();
        } catch ( any ignored ) {}

        try {
            variables.s3_client.close();
        } catch ( any ignored ) {}
    }

    private void function deleteQuietly(
        required string object_key
    ) {
        try {
            var delete_request = createObject( "java", "software.amazon.awssdk.services.s3.model.DeleteObjectRequest" )
                .builder()
                .bucket( variables.bucket_name )
                .key( arguments.object_key )
                .build();

            variables.s3_client.deleteObject( delete_request );
        } catch ( any error ) {
            writeLog(
                type = "warning",
                text = "Unable to delete temporary upload object: " & arguments.object_key
            );
        }
    }
}

The service accepts only JPEG and PNG content types and limits the declared file size to ten megabytes. Adjust both rules for your application.

The browser controls the declared content type, so this value is not proof of the file’s actual format. It is an authorization constraint. In part three, Amazon Rekognition will inspect the image itself before we accept it. The content type and upload identifier become part of the signed request. The browser must send those exact headers. Changing either one invalidates the signature.

We deliberately don’t sign the content length. Browsers control that header and don’t allow JavaScript to set it directly. We check the declared size before signing and compare it with the actual object size afterward.

The server also attaches the upload identifier as object metadata. Because that header is signed, the browser can’t substitute another identifier without invalidating the request.

The entity tag from HeadObject is passed to copySourceIfMatch(). Amazon will only copy the quarantine object if it still matches the object we inspected. This closes the gap between verification and finalization. Without that condition, our code could inspect one object while somebody replaced it, then copy the replacement.

Time-of-check and time-of-use races are where perfectly reasonable code goes after dark.

Create the service when the application starts:

public boolean function onApplicationStart() {
    application.aws_client_factory = new models.aws_client_factory(
        default_region = application.configuration.aws_region
    );

    application.direct_s3_upload_service = new models.direct_s3_upload_service(
        client_factory = application.aws_client_factory,
        bucket_name = application.configuration.upload_bucket,
        maximum_bytes = 10485760,
        signature_minutes = 5
    );

    return true;
}

public void function onApplicationEnd( required struct applicationScope ) {
    if ( structKeyExists( arguments.applicationScope, "direct_s3_upload_service" ) ) {
        arguments.applicationScope.direct_s3_upload_service.shutdown();
    }
}

The service client and presigner are created once and reused. We close them when the application ends. Our first endpoint creates the upload request. This example uses the ColdFusion session to store the pending upload. A database or shared cache is a better choice when requests can reach multiple ColdFusion nodes.

Create uploads/create.cfm:

<cfscript>
    // Run your normal authentication and cross-site request forgery checks before this code.
    if ( !structKeyExists( session, "user" ) ) {
        throw( type = "DirectS3Upload.Unauthorized", message = "Authentication is required." );
    }

    payload = deserializeJSON( toString( getHttpRequestData().content ) );

    plan = application.direct_s3_upload_service.createUpload(
        user_id = session.user.id,
        content_type = payload.content_type,
        content_length = payload.content_length
    );

    lock scope="session" type="exclusive" timeout="5" {
        if ( !structKeyExists( session, "pending_s3_uploads" ) ) {
            session.pending_s3_uploads = {};
        }

        session.pending_s3_uploads[ plan.intent.upload_id ] = plan.intent;
    }

    cfcontent( type = "application/json; charset=utf-8", reset = true );
    writeOutput( serializeJSON( plan.browser ) );
</cfscript>

Only the browser portion of the plan is returned. The bucket name, final object key, expected user, and verification state remain on the server. Use your application’s existing authentication and cross-site request forgery protection on both upload endpoints. A presigned request shouldn’t be obtainable merely because somebody discovered the address that creates one. The completion endpoint retrieves the server-owned upload intent and verifies the object. Create uploads/complete.cfm:

<cfscript>
    // Run your normal authentication and cross-site request forgery checks before this code.
    if ( !structKeyExists( session, "user" ) ) {
        throw( type = "DirectS3Upload.Unauthorized", message = "Authentication is required." );
    }

    payload = deserializeJSON( toString( getHttpRequestData().content ) );
    upload_id = lCase( trim( payload.upload_id ?: "" ) );
    intent = {};

    lock scope="session" type="readonly" timeout="5" {
        if ( structKeyExists( session, "pending_s3_uploads" ) && structKeyExists( session.pending_s3_uploads, upload_id ) ) {
            intent = duplicate( session.pending_s3_uploads[ upload_id ] );
        }
    }

    if ( structIsEmpty( intent ) || intent.user_id != session.user.id ) {
        throw( type = "DirectS3Upload.InvalidIntent", message = "The upload request is not valid." );
    }

    if ( dateCompare( now(), intent.expires_at, "s" ) > 0 ) {
        application.direct_s3_upload_service.discardUpload( intent.staging_key );
        throw( type = "DirectS3Upload.Expired", message = "The upload request has expired." );
    }

    result = application.direct_s3_upload_service.verifyAndFinalize( intent );

    lock scope="session" type="exclusive" timeout="5" {
        structDelete( session.pending_s3_uploads, upload_id );
    }

    // Save result.object_key with the application record here.

    cfcontent( type = "application/json; charset=utf-8", reset = true );
    writeOutput( serializeJSON( {
        success: true,
        upload: result
    } ) );
</cfscript>

The comment immediately before the response is where application-specific persistence belongs. Don’t save the browser’s filename or proposed object key as though they prove anything. Save the final key returned by the verification service.

The browser needs three requests: create permission, upload the file, and report completion.

<script>
    async function uploadFileDirectly(file, requestToken) {
        const createResponse = await fetch("/uploads/create.cfm", {
            method: "POST",
            credentials: "same-origin",
            headers: {
                "Content-Type": "application/json",
                "X-Request-Token": requestToken
            },
            body: JSON.stringify({
                content_type: file.type,
                content_length: file.size
            })
        });

        if (!createResponse.ok) {
            throw new Error("The application refused the upload.");
        }

        const plan = await createResponse.json();

        const uploadResponse = await fetch(plan.url, {
            method: plan.method,
            headers: plan.headers,
            body: file
        });

        if (!uploadResponse.ok) {
            throw new Error("Amazon did not accept the upload.");
        }

        const completeResponse = await fetch("/uploads/complete.cfm", {
            method: "POST",
            credentials: "same-origin",
            headers: {
                "Content-Type": "application/json",
                "X-Request-Token": requestToken
            },
            body: JSON.stringify({
                upload_id: plan.upload_id
            })
        });

        if (!completeResponse.ok) {
            throw new Error("The application could not verify the upload.");
        }

        return completeResponse.json();
    }
</script>

X-Request-Token represents whatever request-verification mechanism your application already uses. The two ColdFusion endpoints change server state and should receive the same protection as any other authenticated form submission.

Don’t log the presigned address. Anybody holding it can perform the signed operation until it expires. Amazon treats presigned addresses as bearer credentials and notes that they may be used more than once during their valid lifetime. Amazon’s presigned-address security documentation describes the available restrictions.

We should also configure a lifecycle rule that removes objects beneath quarantine/ after a short period. Browsers close, laptops sleep, wireless connections disappear, and users occasionally begin an upload before wandering away to pursue a richer life. Not every abandoned object indicates an attack. Every abandoned object does eventually become a storage bill.

We now have a complete direct-upload flow:

  • ColdFusion authenticates and authorizes the user.
  • ColdFusion generates the object key.
  • ColdFusion issues short-lived upload permission.
  • The browser sends the file directly to Amazon.
  • ColdFusion verifies the actual object.
  • Amazon copies the verified object to its permanent key.
  • ColdFusion removes the quarantine object.
  • The application stores only the finalized result.

The file data never passes through ColdFusion. Our server handles decisions while Amazon handles bytes, which is a healthier division of labour for everyone involved. In part three, we’ll insert Amazon Rekognition between verification and finalization. Rekognition can inspect the quarantined object directly from Amazon Simple Storage Service, so ColdFusion still won’t need to download the image.

We have successfully removed ourselves from the data path. Historically, this is when software begins working much better.