Considering A Secure Encoding Technique Inspired By JWT In ColdFusion
Earlier this week, I looked at rebuilding my Incident Commander triage application in ColdFusion. The initial implementation uses a 64-byte alpha-numeric URL-based token to gate access to an incident. The goal of this token is to keep the application secure and prevent brute-force attacks without requiring the user to authenticate via any other mechanism. Essentially, I want to keep the barrier to entry for the application as low as possible in order to remove as much friction as I can from what is otherwise likely to be a very stressful situation (the current incident or outage).
The Drawbacks Of URL-Based Security
I believe that my current implementation is secure. However, I also conceded that there are draw-backs to using the URL as the sole security mechanism. It means that a user's browser history encodes implicit access into the Back button. It also means that every ingress, firewall, and load-balancer that processes the request will record the URL, thereby encoding implicit access into the request logs.
Exploring A Cookie-Based Security Mechanism
There is no "one way" to do security—all security practices are a calculated set of trade-offs: a healthy tension between the ease-of-use and the degree of assurance. The more secure an application becomes, the more hurdles the user—and the developer—have to contend with.
For people who are more security minded, I'd like to provide an additional means of securing an incident triage session. But, I still want to keep things as simple as possible, both for the users and for the development of the application. Which means, I'd still love to keep session management disabled.
Aside: even as I write this, I'm not sure how strongly I feel about keeping session management disabled. I think that so much of the emotional baggage that I have when it comes to session management stems from the
Application.cfc
-based management; that is, enabledthis.sessionManagement
in the ColdFusion framework settings.This framework-specific way of dealing with sessions has long been demonized because it consumes memory space; and, for an application that is public-facing, bots and spiders can quickly spawn hundreds-of-thousands of erroneous sessions that do nothing but eat away at the available RAM of the server.
But, this is only one of the ways in which session management can be implemented. It's probably a topic that I need to spend more time considering.
One approach that I'm looking into is allowing an optional password to be associated with an incident. Of course, I don't want the user to have to enter the password on every single page request. So, once the password is entered correctly the first time, I need to persist the knowledge that the user is authorized to view the current incident.
If my Incident Commander application had a sense of session management, I would just add the given incident to the server-controlled list of authorization settings. But, since I don't have session management (at this time), I need to move that authorization state to the client in the form of a Cookie.
That is, when the user correctly enters the password for a given incident, I will store a cookie that indicates as much. This way, on subsequent requests to view the incident, I can examine the cookie, assert authorization, and bypass the password entry form.
Typically, a session cookie contains session tokens that can be looked-up on the server. But again, I don't necessarily want to have any session state on the server. Which means that in order to keep the cookie-based session secure, I have to encode security into the cookie value itself.
In other words, the secure access cookie has to contain both information about which incidents have been authorized (via password entry) and a means of ensuring that the cookie value hasn't been altered by a malicious 3rd-party.
JSON Web Token (JWT) "Lite"
Other than writing an old implementation of the ColdFusion JSON Web Token (JWT) encoder, I haven't actually use JWTs all that much. But, this seems like a perfect scenario: I have "claims" that I want to make about the current user; and, I need a way to prevent those claims from being tampered with.
Only, when I went back to look at the JWT specification, it's way more generic than I need it to be. All the "subject" and "issuer" and "audience" and "algorithm" jazz, etc, just doesn't serve any purpose in my particular context. I'm not communicating across systems; and, there's no specific "subject" other than whichever client sends me the cookie.
Aside: ColdFusion 2023 has built-in support for JSON Web Token generation and verification. But, I'm still on ColdFusion 2021. And, if I'm being super honest, if I were on ACF 2023, I'd probably have just used their JWT functionality rather than worry about how overly generic it is as a specification.
As such, I set out to create a JWT-inspired encoder with less flexibility. Really all I need is a way to set the expiration such that every N-hours, the password will need to re-entered.
Plus, building things is a great way to learn more about different areas of web application development.
The basic structure and algorithm of my encoder very much line up with the JWT encoder. The token that is generated consists of two dot-delimited segments:
Base64Url( payload )
+.
Base64Url( HMAC( payload, key, algorithm )
The HMAC stands for "Hashed Message Authentication Code" and is the part of this encoding algorithm that ensures that the payload
hasn't been altered. ColdFusion introduced an hmac()
function in version 10, which makes implementing this encoder significantly easier.
While the JWT specification allows for a wide variety of HMAC algorithms, I'm only allowing for SHA256
, SHA384
, and SHA512
; and, I'm defaulting to SHA256
for all operations to keep the API simple.
Note: unlike the JWT specification, which encodes the hashing algorithm into the JWT itself (via the
alg
property), I'm not doing that. Not only does reading the algorithm out of the token open a security risk, it's also superfluous within a single system. Instead, both my encoding and decoding workflows require the server-side code to pass-in the algorithm choice.
The payload
can be any serializable value. And, for my purposes will probably be an array of structs that each contain:
id
- the ID of the authorized incident.expiresAt
- the epoch milliseconds through which the authorization is valid.
My encoder doesn't have a "header" or any in-built notion of expiration since the content of the token can be built-up over time. As such, it's not the token itself that expires so much as it is the individual authorizations within the token payload.
An Encoding / Decoding Example
To demonstrate the encoding and decoding workflow, let's look at a single-template example. In the following ColdFusion code, I'm going to create a token for an array the contains authorization for two different password-protected incidents. Then, I'm going to decode the given token and dump-out the inputs and outputs of the workflow:
<cfscript>
payloadEncoder = new PayloadEncoder();
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// The valuable (BUT NOT ENCRYPTED) data to encode securely. Example: an array of
// authorizations for different incidents with expiration dates.
payload = [
{
id: 94,
expiresAt: now().add( "d", 1 ).getTime() // Good for 24-hours.
},
{
id: 103,
expiresAt: now().add( "d", 1 ).getTime() // Good for 24-hours.
}
];
// The secret key constraints depend on the algorithm being used. The encoder provides
// a method for generating secure keys based on the given algorithm (using the default
// algorithm if none is specified).
secretKey = binaryEncode( payloadEncoder.generateKeyForAlgorithm(), "base64" );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
token = payloadEncoder.encode(
payload = payload,
key = secretKey
);
// What we securely encoded:
writeDump(
label = "Payload",
var = payload
);
// The resultant secure token:
writeOutput( "<p>" & token & "</p>" );
// The decoding of the secure token:
writeDump(
label = "Envelope",
var = payloadEncoder.decode(
token = token,
key = secretKey
)
);
</cfscript>
The PayloadEncoder.cfc
has a utility method for generating secure algorithm-specific secret keys, generateKeyForAlgorithm()
. For the sake of this demo, I'm generating a new key on every request. In reality, this key would need to be cached somewhere.
When we run this ColdFusion code, we get the following output:
As you can see, the input struct (our payload to encode) matches the output struct (the decoded payload extracted from the secure token).
The Implementation Details
Here is the ColdFusion code from my PayloadEncoder.cfc
. Right now, it's hard-coded to instantiate two other components for Base64Url encoding and secure token generation. In reality, those components would be provided through dependency-injection / Inversion of Control.
component
output = false
hint = "I provide methods for securely encoding and decoding a payload. The payload
is not encrypted (its contents can be viewed by anyone), but the contents are signed
in order to prevent tampering."
{
/**
* I initialize the payload encoder.
*/
public void function init() {
variables.base64UrlEncoder = new Base64UrlEncoder();
variables.secureRandom = new SecureRandom();
// Each successive algorithm produces a more secure authentication code. But, this
// comes at the cost of a longer output, more processing overhead, and calls for a
// longer secret key (see the generateKeyForAlgorithm() method). For simple web
// application needs, Sha256 is widely recommended as it strikes a good balance
// between security and speed. Hence it is the default.
variables.supportedAlgorithms = [
"HmacSHA256",
"HmacSHA384",
"HmacSHA512"
];
variables.defaultAlgorithm = supportedAlgorithms.first();
}
// ---
// PUBLIC METHODS.
// ---
/**
* I decode the given token into the original payload. The key is assumed to be a
* Base64-encoded value.
*/
public any function decode(
required string token,
required string key,
string algorithm = defaultAlgorithm
) {
key = testKey( key );
algorithm = testAlgorithm( algorithm );
var segments = parseToken( token );
// Before we parse and deserialize any of the data segments, let's ensure that the
// token has not been tampered with. To do this, we're going to regenerate the
// signature and then make sure that it matches the one provided by the token.
var expectedSignature = buildSignatureSegment( segments.payload, key, algorithm );
if ( compare( segments.signature, expectedSignature ) ) {
throw(
type = "PayloadEncoder.Token.SignatureMismatch",
message = "The token signature does not match the expected signature."
);
}
// At this point, we know that the token signature has been validated, which means
// that all segments should be in a known good state. As such, I'm NOT going to
// wrap this in any error handling. Any error that occurs during the parsing of
// the segments will be a bug and (can be caught and logged at a higher level).
return parsePayloadSegment( segments.payload );
}
/**
* I encode the given payload into a secure token. The payload can be any serializable
* value. The key is assumed to be a Base64-encoded value.
*/
public string function encode(
required any payload,
required string key,
string algorithm = defaultAlgorithm
) {
key = testKey( key );
algorithm = testAlgorithm( algorithm );
var payloadSegment = buildPayloadSegment( payload );
var signatureSegment = buildSignatureSegment( payloadSegment, key, algorithm );
return "#payloadSegment#.#signatureSegment#";
}
/**
* I generate a secure, random key of a length recommended for the given algorithm.
*/
public binary function generateKeyForAlgorithm( string algorithm = defaultAlgorithm ) {
switch ( testAlgorithm( algorithm ) ) {
// https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.hmacsha256.-ctor?view=net-9.0
case "HmacSHA256":
return secureRandom.getBytes( 64 );
break;
// https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.hmacsha384.-ctor?view=net-9.0
case "HmacSHA384":
// https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.hmacsha512.-ctor?view=net-9.0
case "HmacSHA512":
return secureRandom.getBytes( 128 );
break;
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I serialize the given payload into a segment.
*/
private string function buildPayloadSegment( required any payload ) {
return base64UrlEncoder.encodeFromString( serializeJson( payload ) );
}
/**
* I build the signature to prevent tampering with the given segments.
*/
private string function buildSignatureSegment(
required string payloadSegment,
required binary key,
required string algorithm
) {
return base64UrlEncoder.encodeFromHex( hmac( payloadSegment, key, algorithm ) );
}
/**
* I parse the given payload segment (it can result in any value).
*/
private any function parsePayloadSegment( required string payloadSegment ) {
return deserializeJson( base64UrlEncoder.decodeToString( payloadSegment ) );
}
/**
* I parse the given token into segments (which remain serialized and encoded).
*/
private struct function parseToken( required string token ) {
var parts = token.listToArray( "." );
if (
( parts.len() != 2 ) ||
! parts[ 1 ].len() ||
! parts[ 2 ].len()
) {
throw(
type = "PayloadEncoder.Token.Invalid",
message = "The token doesn't have 2 dot-delimited segments."
);
}
return {
payload: parts[ 1 ],
signature: parts[ 2 ]
};
}
/**
* I validate and return a normalized algorithm name.
*/
private string function testAlgorithm( required string algorithm ) {
for ( var element in supportedAlgorithms ) {
// Note: we're returning the one in the internal array in order to ensure
// proper key-casing for when we pass the name into the hmac() function - I'm
// not sure if it even matters, but I like to keep it consistently cased.
if ( element == algorithm ) {
return element;
}
}
throw(
type = "PayloadEncoder.Algorithm.Invalid",
message = "The algorithm is not currently supported.",
detail = "[#algorithm#] is not one of [#supportedAlgorithms.toList( ', ' )#]."
);
}
/**
* I validate and return a normalized key (which is converted into a binary value for
* consumption in the hmac() function).
*/
private binary function testKey( required string key ) {
if ( ! key.len() ) {
throw( type = "PayloadEncoder.Key.Empty" );
}
try {
return binaryDecode( key, "base64" );
} catch ( any error ) {
throw(
type = "PayloadEncoder.Key.Invalid",
message = "The key cannot be decoded as a Base64 value."
);
}
}
}
And, while not really relevant to this discussion, for the sake of completeness, here's the ColdFusion code for my Base64UrlEncoder.cfc
:
component
output = false
hint = "I provide methods for encoding and decoding values as Base64Url."
{
/**
* I decode the given Base64Url input into a binary value.
*/
public binary function decode( required string input ) {
var base64Input = input
.replace( "-", "+", "all" )
.replace( "_", "/", "all" )
;
// When we generate the URL-safe value, we strip out the padding characters at the
// end. When we decode the input, we then need to put the padding characters back
// in. I don't think this is strictly required in all context; but, is adheres to
// the Base64 specification on length.
var paddingLength = ( 4 - ( base64Input.len() % 4 ) );
var padding = repeatString( "=", paddingLength );
return binaryDecode( ( base64Input & padding ), "base64" );
}
/**
* I decode the given Base64Url input into a string value with the given encoding.
*/
public string function decodeToString(
required string input,
string encoding = "utf-8"
) {
return charsetEncode( decode( input ), encoding );
}
/**
* I encode the given binary input into a Base64Url value.
*/
public string function encode( required binary input ) {
return binaryEncode( input, "base64" )
.replace( "+", "-", "all" )
.replace( "/", "_", "all" )
.replace( "=", "", "all" )
;
}
/**
* I encode the given string input with given encoding into a Base64Url value.
*/
public string function encodeFromString(
required string input,
string encoding = "utf-8"
) {
return encode( charsetDecode( input, encoding ) );
}
/**
* I encode the given HEX input into a Base64Url value.
*/
public string function encodeFromHex( required string input ) {
return encode( binaryDecode( input, "hex" ) );
}
}
... and for my SecureRandom.cfc
, which uses randRange()
with the SHA1PRNG
algorithm to generate a random binary value.
component
output = false
hint = "I provide methods for securely generating random data."
{
/**
* I return a random byte array of the given length.
*/
public binary function getBytes( required numeric length ) {
var bytes = [];
for ( var i = 1 ; i <= length ; i++ ) {
bytes.append( randRange( -128, 127, "sha1prng" ) );
}
return javaCast( "byte[]", bytes );
}
}
Dealing with security is always a double-edged sword. On the one hand, I'm nervous that I'm going to get something wrong and create an insecure solution. But, on the other hand, I'm never going to become comfortable with this area of programming if I keep trying to avoid it. As such, I think there's a tremendous amount of value in thinking about this kind of code; and, about the trade-offs that I'm consciously making in my implementation.
Want to use code from this post? Check out the license.
Reader Comments
So, I couldn't find all the code(mostly because I didn't write it), but we have an in-house cache and all the servers involved in that cache (usually a user server and a scheduled server) talk to each other to say "hey, I've updated this database record so clear any CFC's that represent it". This happens very frequently. Those specific Coldbox handler hits, through various means (onRequestStart/End, Interceptors, onException, etc.) basically kill that session and set the timeout of the session to almost nothing. This keeps us from having bajillions of sessions going all because of this cache expiration notification.
@Will,
The session stuff is always tricky. But, I think a lot of my emotional baggage also comes from back in the day before I had ever learned about storing session info in a database (which is much more scalable than storing it in RAM on a single server). Plus, with something like Redis, you can easily and very efficiently share sessions across a large array of "stateless servers". I mean, sure, I only have one server here, but it's always something that I have in the back of my head.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →