Generating Secure URL Signatures To Prevent Tampering In ColdFusion
Earlier this week, I was talking to my colleague, Max Nunes, about generating a URL that was locked-down for a single user's consumption. We settled on including a secure URL signature that prevented tampering. I've used URL signatures in the past, but I don't believe that I've ever addressed them directly. As such, I wanted to run through generating and validating a URL signature in ColdFusion.
A URL signature is a URL component that ensures that the rest of the URL components are being provided as intended by the application; and, that the integrity of the URL has not been corrupted by a malicious actor. In this case, the URL signature is acting as a Message Authentication Code (MAC). And, is generated by combining the public parts of the URL—that any user can see—with a secret key—that is only known to the ColdFusion application server(s).
In this exploration, we're going to use HMAC. HMAC is a version of MAC that depends on a cryptographic, one-way hashing function. Java supports several HMAC algorithms which are then exposed in the ColdFusion layer via the hmac()
function. HMAC algorithms vary in security; and, not all algorithms are provided by all runtimes.
Aside: In the past, I've looked at using
BouncyCastleProvider
to enable RSA-based algorithms; but, I could not get that to work in my CommandBox install with the Lucee CFMLhmac()
function. I may have been doing something wrong.
When it comes to securing a URL with an HMAC signature, you have to be explicit about which components of the URL you're trying to protect. Meaning, you can only secure the parts of the URL that are included in the signature generation. For example, if we wanted to secure the following URL:
GET https://example.com/my-resource?id=3
But, we generated the signature by including only the path and the ID:
signature( "path:/my-resource", "id:3" )
It means that portions of the URL could be changed by a malicious actor without violating the HMAC signature:
GET
can be changed toDELETE
.https
can be changed tohttp
.example.com
can be changed toacme.example.com
.
If we care about these potential mutations, and we don't want them to be changed, we'd have to include the desired values in the signature generation. Essentially, any value that has to remain constant, from a security standpoint, has to be included in the signature generation.
There's no rule about how much of the URL has to be included in the signature generation. Some APIs are more locked down; some are less locked down. It all depends on what kind of damage can be done by an insecure portion of the URL.
For the sake of simplicity, I'm only going to lock down the HTTP host and two query-string parameter in my demo; but, the technique I'm demonstrating can be scaled-up to include as much of the URL as you want.
First, let's abstract the concept of a URL signature behind a ColdFusion component, UrlSigner.cfc
. This component has two methods:
generateSignature( inputs )
testSignature( inputs, signature )
The first method is used by the ColdFusion application that is generating the URL. The inputs
parameter is a Struct that contains the key-value pairs to be included in the HMAC signature.
The second method is used by the ColdFusion application that is serving the given URL and needs to ensure that the URL has not been altered.
Both of the ColdFusion applications need to know the shared secret key so that the signature generated by the source server can be recreated and verified by the destination server. Note that these can be the same server or two different servers.
Instead of providing the secret key with each method invocation, I'm requiring that the secret key be included as a constructor argument for the UrlSigner.cfc
instance. I felt like this created a cleaner API; but, there's no requirement that the API be organized like this.
Internally to this UrlSigner.cfc
component, there's really only one method that has any significant logic: buildMessage()
. This method takes the inputs
struct and serializes it into a string:
component {
/**
* I collapse the set of inputs down into a consistent string.
*/
private string function buildMessage( required struct inputs ) {
var parts = [];
inputs.each(
( key, value ) => {
// NOTE: We're lower-casing the keys, see sorting note below.
parts.append( "#lcase( key )#:#value#" );
}
);
// Since ColdFusion provides both ordered structs and unordered structs, we're not
// going to make any assumptions about the key iteration. Instead, we're doing to
// explicitly sort the parts.
parts.sort( "text", "asc" );
return( parts.toList( "|" ) );
}
}
Since ColdFusion structs are case-insensitive (by default) and have an unpredictable ordering of keys (unless you're using an ordered struct), this method attempts to normalize the key-casing, the sorting of keys, and the stringification of the values. Which means, both this inputs
argument:
<cfscript>
buildMessage({
"HELLO": "world",
"FOO": "bar",
"NUMBER": 1
});
</cfscript>
... and this inputs
argument:
<cfscript>
buildMessage({
"number": "1",
"foo": "bar",
"hello": "world"
});
</cfscript>
... both produce the same exact message return value:
"foo:bar|hello:world|number:1"
And, this is the "message" for which we are then generating the message authentication code.
It's critical that the behavior of the buildMessage()
method be "pure". Meaning, when given the same inputs
struct, this method should always product the same return value. And, this needs to hold true no matter which ColdFusion server is executing the code. If there is any variability to the way in which this method produces its output, signature verification will fail.
One could argue that the logic in the buildMessage()
method should be the responsibility of the calling context; and, that the UrlSigner.cfc
should only operate on a single string. But, this logic feels generic enough to warrant encapsulation. Plus, I believe that using a struct will make it easier for the developer to aggregate the inputs that need to be signed.
The generateSignature()
and testSignature()
methods then do little more than orchestrate calls between the buildMessage()
method and the native hmac()
function. Here's the entirety of the UrlSigner.cfc
ColdFusion component:
component
output = false
hint = "I provide methods for signing and testing a secure URL."
{
/**
* I initialize the url signer with the given properties. Note that not all algorithms
* are always available. Some ColdFusion installs may have advanced RSA algorithms
* installed while others may not.
*
* The secret key can be provided as either a String or a Binary value.
*/
public void function init(
required any secretKey,
string algorithm = "HmacSHA256",
string encoding = "base64Url"
) {
variables.secretKey = arguments.secretKey;
variables.algorithm = arguments.algorithm;
variables.encoding = arguments.encoding;
}
// ---
// PUBLIC METHODS.
// ---
/**
* I generate a consistent signature for the given inputs. Each input is expected to be
* a simple value that can be converted to a string.
*/
public string function generateSignature( required struct inputs ) {
var message = buildMessage( inputs );
var signature = hmac( message, secretKey, algorithm, "utf-8" );
return( encodeSignature( signature ) );
}
/**
* I test the given user-provided signature against the internally-generated, expected
* signature. If the signatures match, this method exits quietly. If the signatures do
* not match, an error is thrown.
*/
public void function testSignature(
required struct inputs,
required string signature
) {
var expectedSignature = generateSignature( inputs );
if ( compare( signature, expectedSignature ) ) {
throw(
type = "UrlSigner.SignatureMismatch",
message = "Signature did not match expected signature.",
detail = "Signature [#signature#], expected signature [#expectedSignature#]",
extendedInfo = "Inputs: #serializeJson( inputs )#"
);
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I collapse the set of inputs down into a consistent string.
*/
private string function buildMessage( required struct inputs ) {
var parts = [];
inputs.each(
( key, value ) => {
// NOTE: We're lower-casing the keys, see sorting note below.
parts.append( "#key.lcase()#:#value#" );
}
);
// Since ColdFusion provides both ordered structs and unordered structs, we're not
// going to make any assumptions about the key iteration. Instead, we're doing to
// explicitly sort the parts.
parts.sort( "text", "asc" );
return( parts.toList( "|" ) );
}
/**
* The native hmac() function always returns the digest as a hex-encoded value. I take
* that encoding and convert it to the desired encoding for the URL signature.
*/
private string function encodeSignature( required string hexSignature ) {
if ( encoding == "hex" ) {
// NOTE: lcase() usage is a purely aesthetic preference - it has no bearing on
// the functionality of the hex-encoded value.
return( hexSignature.lcase() );
}
var bytes = binaryDecode( hexSignature, "hex" );
if ( encoding == "base64" ) {
return( binaryEncode( bytes, "base64" ) );
}
// Fallback to base64Url.
var encodedValue = binaryEncode( bytes, "base64" )
.replace( "+", "-", "all" )
.replace( "/", "_", "all" )
.replace( "=", "", "all" )
;
return( encodedValue );
}
}
As you can see, the generateSignature()
method does little more than call buildMessage()
and pass the message
into the hmac()
function. And, the testSignature()
method does nothing more that re-generate the expected signature using the given inputs
; and then, compares that to the signature provided by the URL. Over all, there's very little actually happening in this ColdFusion component. But, it's enough to create a lot of security!
That said, let's see it in action. To demonstrate the security of URL signatures, I'm going to create one page that generates a URL that is valid for 20-seconds. Then, I'm going to create another page that tests the signature of said URL along with its expiration date.
For this demo, I'm using the HmacSha256
hashing algorithm (which is the default hashing algorithm in the UrlSigner.cfc
constructor). I'm not a security expert; and I don't fully understand the implications of secret key selection. But, according to this Stack Exchange post, the size of the secret key should match the size of the generated hash.
The HmacSha256
algorithm generates a 32-byte hash. Which means that we need a 32-byte secret key. To generate this secret key using a cryptographically secure pseudo-random number generator, I'm using the openssl
command-line tool that comes packaged with my MacOS:
openssl rand -base64 32
This generates 32 random bytes and then presents it as a base64-encoded string. Since both my source page and my destination page need to know this secret key, I'm going to save it to a file and then use the binaryDecode()
method to read it into my ColdFusion application runtime.
In the following ColdFusion page, I'm generating the signed URL using three components of said URL:
- The HTTP host.
- The
resource
search parameter. - The
expiresAt
search parameter.
The expiresAt
value has no inherent meaning in the UrlSigner.cfc
- it's just one more key-value pair to include in the message serialization. The "business value" of the expiresAt
timestamp will be used after the URL has been verified.
<cfscript>
// I generated a secret key using OpenSSL: `openssl rand -base64 32`.
secretKey = binaryDecode( fileRead( "./secret.key" ).trim(), "base64" );
urlSigner = new UrlSigner( secretKey );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// This is the resource that we're securing with the URL signature.
resource = "private-image.jpg";
// In this demo, the signature will only be valid for the next 20-seconds (this will
// be tested by the target page logic and will be verified by the signature). I'm
// passing this value through as the UTC milliseconds so that it can be easily
// compared to the getTickCount() function.
expiresAt = now()
.add( "s", 20 )
.getTime()
;
// In order to make sure that neither the resource nor the expiration have been
// tampered with, we're going to include a signature for the secure parts of the URL.
// --
// NOTE: We can include anything we want in the signature to help us lock down the
// request integrity. We can even use stuff that isn't passed explicitly in the URL.
// For example, we can include the HTTP_HOST in the signature in order to verify the
// origin and target servers are the same.
signature = urlSigner.generateSignature({
httpHost: cgi.http_host,
resource: resource,
expiresAt: expiresAt
});
// FOR THE BLOG POST, I'm constructing the URL using an Array just so it's easier to
// see the mechanics of each aspect of the URL.
// --
// NOTE: We're not explicitly passing-through httpHost as this will be passed
// implicitly passes by the browser (and we'll pull it out of the CGI scope on the
// target page).
secureLink = [
"./view.cfm",
"?resource=#encodeForUrl( resource )#", // Input.
"&expiresAt=#encodeForUrl( expiresAt )#", // Input.
"&signature=#encodeForUrl( signature )#" // Signature.
];
</cfscript>
<cfoutput>
<p>
Secure Link:
<a href="#secureLink.toList( '' )#" target="_blank">View resource</a>
- valid for <strong>20-seconds</strong>!
</p>
</cfoutput>
If we run this ColdFusion page, we end up with the following URL (I'm adding the line-breaks and truncating the signature for readability):
http://127.0.0.1:62625/signed-url/view.cfm
?resource=private-image.jpg
&expiresAt=1705749300833
&signature=kMvwkQqbmDU....k20QKeUVSSJ0Y
As you can see, the signature
is a base64url-encoded value that is being tacked-on to the end of the URL. Our target page, view.cfm
, must then take this signature, aggregate the inputs
, and test that the URL has not been tampered with:
<cfscript>
// Expected inputs in the secure URL.
param name="url.resource" type="string";
param name="url.expiresAt" type="numeric";
// Signature ensuring the URL has not been tampered with.
param name="url.signature" type="string";
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// We need to use the same URL signer so that we generate the same signatures.
secretKey = binaryDecode( fileRead( "./secret.key" ).trim(), "base64" );
urlSigner = new UrlSigner( secretKey );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// When testing the signature, we have to pass in the same inputs that we used in
// order to generate the signature on the calling page.
urlSigner.testSignature(
{
httpHost: cgi.http_host,
resource: url.resource,
expiresAt: url.expiresAt
},
url.signature
);
// Once the signature has been verified, meaning the URL has NOT BEEN TAMPERED WITH,
// we can further validate facets of the URL. Let's make sure the URL has not expired.
if ( getTickCount() > url.expiresAt ) {
echo( "Expired link!" );
abort;
}
echo( "Access Granted: #encodeForHtml( url.resource )#" );
</cfscript>
As you can see, this page takes the same URL components (server host, resource
, and expiresAt
) as well as the user-provided signature and passes them into the testSingature()
method. Internally, that method does nothing more than call generateSignature()
and assert that the provided url.signature
value matches the internally re-generated signature.
Once this page asserts that the signatures match, it then goes onto consume the expiresAt
value from a business logic standpoint. And, rejects any request that is older than 20-seconds.
I don't actually have a resource to serve-up to the user; but, at this point, the ColdFusion application can be confident that the incoming request has not been tampered with (at least, the parts of the URL that we included in the signature generation); and, that it is safe to serve-up the requested resource.
A URL signature is a relatively simple but effective way to create secure URLs. I first encountered this concept when generating pre-signed URLs for AWS S3 Object access. But, I've since come to use it frequently for email-based URLs. For example, I've created a passwordless (magic link) login for Dig Deep Fitness, I've generated pre-signed URLs for S3, and I embedded email tracking pixels all using signed URLs.
In the end, just remember that every data-point that you want to secure must be included in the signature generation. Otherwise, said data-point can be altered by the user without invalidating the signature.
Want to use code from this post? Check out the license.
Reader Comments
Hi Ben
Really interesting post. I might use this sometime?
Maybe for links that come from my validate e-mail address e-mails?
This actually uses a similar mechanism to the JWT paradigm. If the JWT is tampered with, the user is immediately logged out of his session, on the client.
I spent a few weeks writing a JWE library, in Coldfusion.
It uses the Jose Nimbus cryptographic library:
https://www.forgebox.io/view/JWTSignEncrypt
I even wrote the required Java classes for this project. Very basic, mind you 🤩
@Charles,
Yes, exactly, this has a lot of cross-over with JWT (JSON Web Token, for anyone else reading this). The JWT has a signature that verifies that the rest of the JWT payload has not been tampered-with; exactly how, in this post, we're using the signature to make sure that the rest of the URL has not been tampered with.
When I deal with JWTs, I usually use jwt.io for some testing. And, on that page, they demonstrate that the signature is generated in basically the same way:
HmacSha256( "#header#.#payload#", secret )
And, if you compare that to what I'm doing in my post, its (I'm simplifying a bit here for the comment):
HmacSha256( buildMessage(), secret )
Of course, the main difference here is that when someone uses a JWT, they have to provide the JWT with the request (either as a cookie or an explicit HTTP parameter). Whereas with the URL signing, they only need to click on a the link, and all the required parts are in the URL.
I'm not saying one is better than the other; only that they are very similar, but different.
As a quick follow-up, I was having a discussion with some people about "Unsubscribe" links at the footer of an email. And, it occurred to me that this is a great demonstrate of where a signed-URL can be very helpful. I put together a small fast-follow demo:
www.bennadel.com/blog/4586-powering-email-unsubscribe-links-using-signed-urls-in-coldfusion.htm
Hi Ben
Thanks for the heads up on JWT.
I learnt the other day that JWT's should never send sensitive data, like passwords, because the payload is only encoded, not encrypted.
This was partly why I built a JWE library.
Anyway, I was then thinking, if the whole point of a JWT is not to contain sensitive data, then maybe the idea of creating one with an encrypted payload, goes somewhat against the JWT paradigm?
Now, am I right in thinking that the whole point of a JWT, is to add a User UUID into the payload? If someone tries to modify the payload value, then the JWT will not authenticate. As oppose to just sending a User UUID as an endpoint request param, which could be tampered with, without the server being able to assess, it's authenticity.
@Charles,
So, my knowledge about JWTs is not very deep. But, I do believe that they have something in the protocol to allow for encryption. But, that may have been something they added later on - so, to your point, most people avoid adding sensitive data in the payload.
As far as adding a User Identifer into the payload, I think that is the majority use-case. The idea is that you're making a "claim" about who someone / something is; and, to make sure that someone can't mess with that claim and / or impersonate another person.
But, I'm just speaking high-level here - I haven't done too much with JWTs.
Thanks very much for that confirmation. Its always good to understand correct methodology.
I suddenly realized the other day that I was sending User UUIDs, in the endpoint request param and also in the JWT.
And I was thinking, something doesn't feel right about this.
Why have the same identifier sent, using two different mechanisms? I then had a Eureka moment and ended up removing all the identifiers in the URL.
Its only taken me 20 years to understand this fact! 😀
@Charles,
Ha ha, this is the story of the engineer 🤩 I'm right there with you - leaning and re-learning stuff every day.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →