Using Cloudflare Turnstile reCAPTCHA-Alternative In ColdFusion
A couple of weeks ago, I looked at using Google's reCAPTCHA to block spam in my Dig Deep Fitness website. In response to that post, Alex Skinner suggested that I look into Cloudflare Turnstile. I wasn't having any issues with reCAPTCHA. But, I do love Cloudflare as a company; so, I figured it'd be fun to take a look. Here's a quick demo of using Cloudflare Turnstile in order to block spam in ColdFusion.
Turnstile is, more or less, a drop-in replacement for reCAPTCHA. In fact, they even have a reCAPTCHA-compatibility mode. But, for the sake of the exploration, I just want to start from scratch.
As with reCAPTCHA, the Cloudflare Turnstile workflow includes both server-side and client-side aspects. On the client-side, a Cloudflare script makes a remote API call in order to provision a challenge token. This token is then submitted along with your form (the one that you're trying to protect). And, once the request is being processed on the ColdFusion server, we have to make a remote API call back to the Clouldflare server in order to verify the challenge token.
Unlike the reCAPTCHA verification workflow, which returns a score between 0
-1
, the Cloudflare Turnstile workflow simply returns a success
flag. This is a minor difference; but, it's once less thing that I have to think about as a developer, which is nice.
Let's start this exploration on the ColdFusion side. When communicating with the Cloudflare API, I am spreading the logic across two different concerns: dealing with the low-level HTTP mechanics; and, interpreting the Cloudflare response.
The low-level HTTP mechanics are handled by a ColdFusion component which is little more than a glorified wrapper around the CFHttp
tag. For this demo, I only need a single method, siteVerify()
, which submits the challenge token back to Cloudflare API for verification.
In this ColdFusion component, and subsequent components, I'll be using the CFProperty
tag to define accessor hooks. I'm using this technique, instead of constructor injection, for Inversion of Control (IoC).
component
accessors = true
output = false
hint = "I provide low-level HTTP access to the Cloudflare Turnstile API."
{
// Define properties for dependency-injection.
property name="apiKey";
property name="httpUtilities";
property name="timeoutInSeconds" default=5;
// ---
// PUBLIC METHODS.
// ---
/**
* I verify the given Cloudflare Turnstile challenge token.
*/
public struct function siteVerify(
required string token,
required string ipAddress,
numeric timeoutInSeconds = variables.timeoutInSeconds
) {
cfhttp(
result = "local.httpResponse",
method = "post",
url = "https://challenges.cloudflare.com/turnstile/v0/siteverify",
getAsBinary = "yes",
timeout = timeoutInSeconds
) {
cfhttpparam(
type = "formfield",
name = "secret",
value = apiKey
);
cfhttpparam(
type = "formfield",
name = "response",
value = token
);
cfhttpparam(
type = "formfield",
name = "remoteip",
value = ipAddress
);
}
var fileContent = httpUtilities.getFileContentAsString( httpResponse );
if ( httpUtilities.isFailureResponse( httpResponse ) ) {
throw(
type = "Turnstile.Gateway.ApiFailure",
message = "Cloudflare Turnstile API error.",
detail = "Returned with status code: #httpResponse.statusCode#",
extendedInfo = fileContent
);
}
try {
return( deserializeJson( fileContent ) );
} catch ( any error ) {
throw(
type = "Turnstile.Gateway.PayloadError",
message = "Cloudflare Turnstile API response consumption error.",
detail = "Returned with status code: #httpResponse.statusCode#",
extendedInfo = fileContent
);
}
}
}
As you can see, this ColdFusion component does nothing more than orchestrate the HTTP request to the Cloudflare API and attempt to deserialize the JSON response. According to the documentation, the minimal JSON response looks like this:
{
"success": true,
"error-codes": [],
"challenge_ts": "2022-10-06T00:07:23.274Z",
"hostname": "example.com"
}
But, it looks like additional data can be supplied in order to provide more context about the workflow being protected. I have not tried using the optional data yet.
Consuming the API response is then the responsibility of the TurnstileClient.cfc
. When it comes to executing commands against my ColdFusion applications, I tend to use an exception-driven control flow. That is, I assume that all parts of the "happy path" work; and, I allow each step to throw an error in order to halt processing.
To that end, my TurnstileClient.cfc
has two methods: testToken()
and verifyToken()
. The verifyToken()
method invokes the low-level HTTP request and returns the success flag. The testToken()
method then wraps the verifyToken()
method and throws an error if the challenge token gets rejected.
component
accessors = true
output = false
hint = "I provide high-level HTTP access to the Cloudflare Turnstile API."
{
// Define properties for dependency-injection.
property name="gateway";
// ---
// PUBLIC METHODS.
// ---
/**
* I test the given Cloudflare Turnstile challenge token provided by the client-side
* form. If the challenge passes successfully, this method exits. Otherwise, it throws
* an error.
*/
public void function testToken(
required string token,
required string ipAddress
) {
if ( ! verifyToken( argumentCollection = arguments ) ) {
throw(
type = "Turnstile.Client.VerificationFailure",
message = "Cloudflare Turnstile verification failure.",
detail = "Challenge did not pass, user might be a bot."
);
}
}
/**
* I verify the given Cloudflare Turnstile challenge token.
*/
public boolean function verifyToken(
required string token,
required string ipAddress
) {
// If no token has been provided by the Turnstile system, then we know that the
// user is attempting to bypass the security. There's no need to make the API
// call (note: it's possible that the user just submitted the form too quickly).
if ( ! token.len() ) {
throw(
type = "Turnstile.Client.InvalidToken",
message = "Cloudflare Turnstile token is empty."
);
}
var apiResponse = gateway.siteVerify( token, ipAddress );
return( apiResponse.success );
}
}
This TurnstileClient.cfc
is the ColdFusion component that we're going to consume in our form submission. And, the testToken()
method is the method we'll invoke during our form processing.
On the client-side, I'm using Cloudflare's implicit rendering technique. This is where I load the Turnstile script and have it automatically provision a challenge token and inject the token into the form as a hidden input. It does this by looking for an element with the CSS class name, cf-turnstile
. Once this element is found, Cloudflare renders the Turnstile widget as the first child (of this element); and then, appends a hidden input as a subsequent child that encodes the challenge token.
Aside: Cloudflare provides explicit rendering techniques if you want to have granular control over when the challenge token is provisioned and how it interacts with the form. But, I don't need that level of control at this time.
For the sake of simplicity, I'm going to instantiate a new instance of my ColdFusion components at the top of every page:
<cfscript>
// SECURITY CAUTION: You should not show the SECRET KEY to anyone. This is a set of
// throw-away keys (configured for "localhost") that I have created specifically for
// this demo.
config = {
siteKey: "0x4AAAAAAAPqhac0_y9Coyz-",
secretKey: "0x4AAAAAAAPqhVYA58LIIuKHIa4q4b6Fp2c"
};
// Using Inversion of Control (IoC) to create the gateway component and then provide
// it to the Cloudflare Turnstile client component as a dependency.
// --
// NOTE: Normally, I would cache this ColdFusion component in a persistent scope; but,
// for the sake of the demo and simplicity, I am re-creating it on every request.
turnstileGateway = new TurnstileGateway()
.setApiKey( config.secretKey )
.setHttpUtilities( new HttpUtilities() )
;
turnstileClient = new TurnstileClient()
.setGateway( turnstileGateway )
;
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
param name="form.submitted" type="boolean" default=false;
param name="form.username" type="string" default="";
param name="form.password" type="string" default="";
// This field is automatically populated by the client-side Cloudflare Turnstile
// challenge. Using the IMPLICIT rendering approach, Cloudflare's script will
// automatically generate a challenge token and populate a hidden input field.
param name="form[ 'cf-turnstile-response' ]" type="string" default="";
errorMessage = "";
if ( form.submitted ) {
try {
// NOTE: Each Cloudflare Turnstile challenge token is valid for 5-minutes and
// can only be verified once in order to prevent replay attacks.
turnstileClient.testToken(
token = form[ "cf-turnstile-response" ],
ipAddress = cgi.remote_addr
);
// TODO: Actual authentication logic for the application ...
location(
url = "./home.cfm",
addToken = false
);
} catch ( any error ) {
switch ( error.type ) {
case "Turnstile.Client.InvalidToken":
case "Turnstile.Client.VerificationFailure":
errorMessage = "Your login form has expired. Please try submitting your form again.";
break;
default:
errorMessage = "An unexpected error occurred."
break;
}
}
} // END: Submitted.
</cfscript>
<cfoutput>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<h1>
Cloudflare Turnstile Demo In ColdFusion
</h1>
<cfif errorMessage.len()>
<p>
<strong>Error:</strong> #encodeForHtml( errorMessage )#
</p>
</cfif>
<form method="post">
<input type="hidden" name="submitted" value="true" />
<p>
<strong>Username:</strong><br />
<input type="text" name="username" size="30" />
</p>
<p>
<strong>Password:</strong><br />
<input type="password" name="password" size="30" />
</p>
<p>
<button type="submit">
I'm totally a Human, Login!
</button>
</p>
<!--- Turnstile challenge widget is rendered as a child of this DIV. --->
<div
data-sitekey="#encodeForHtmlAttribute( config.siteKey )#"
class="cf-turnstile"
style="margin-top: 30px ;">
<!---
NOTE: This div must be inside the FORM as it will inject a hidden form
field as a child element (which must be submitted to the server).
--->
</div>
</form>
<!---
Load Cloudflare Turnstile script. Since I'm using the implicit rendering
technique, it will automatically look for the element with the class name
"cf-turnstile", render the widget there, and then inject the challenge token
as a hidden input field.
--->
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async defer>
</script>
</body>
</html>
</cfoutput>
As you can see, with the implicit rendering approach, I have no JavaScript. I simply include the Cloudflare script and serve-up an element with the class name, cf-turnstile
. Cloudflare takes care of the rest (from a client-side perspective). Then, on the ColdFusion server, I'm taking the provisioned challenge token and I'm testing it with my TurnstileClient.cfc
. And, if the test passes, I proceed with the form submission.
If I then open this ColdFusion page in the browser, we see the Cloudflare widget get rendered:
As you can see, our empty div with class name, cf-turnstile
, ends up containing both the challenge widget (as an iframe
) and the hidden input that contains the challenge token. And, since this is all being rendered inside the <form>
tag, the challenge token is submitted to the ColdFusion server along with the login credentials.
If you compare this blog post to the previous one (about Google reCAPTCHA), the code looks almost exactly the same in both demos. Which makes sense because the workflows are exactly the same; and, Cloudflare Turnstile was designed to be a replacement for CAPTCHA. The major difference (in my two demos) is that I broke the HTTP helper utilities out into their own ColdFusion component. Such a refactoring isn't necessary for the demo; but, it more closely mirrors what I would do in a production setting:
component
output = false
hint = "I provide common utility functions for consuming HTTP responses."
{
/**
* I return the given fileContent payload a UTF-8 encoded string. Even if we ask for a
* request to return the fileContent as a Binary value, the type is only guaranteed if
* the request comes back properly. If something goes terribly wrong (such as a
* "Connection Failure"), the fileContent will still be returned as a simple string.
* This method will normalize both response cases to a string.
*/
public string function getFileContentAsString( required struct httpResponse ) {
if ( isBinary( httpResponse.fileContent ) ) {
return( charsetEncode( httpResponse.fileContent, "utf-8" ) );
}
return( httpResponse.fileContent );
}
/**
* I determine if the given HTTP response has a failure / non-2xx status code.
*/
public boolean function isFailureResponse( required struct httpResponse ) {
return( ! isSuccessResponse( httpResponse ) );
}
/**
* I determine if the given HTTP response has a success / 2xx status code.
*/
public boolean function isSuccessResponse( required struct httpResponse ) {
return( httpResponse.statusCode.reFind( "2\d\d" ) );
}
}
I just deployed this change to Dig Deep Fitness this morning and so far everything seems to be running smoothly. In the last few hours, I haven't seen any spam form submissions get through!
Want to use code from this post? Check out the license.
Reader Comments
Nice - I have been using it for a while with CF, and it is working pretty well. I used to get a lot of spam on my contact form and now they are very few and far between. I wrote up a simple CFML implementation as well here a few years ago: https://www.petefreitag.com/blog/adding-cloudflare-turnstile/
@Pete,
Very nice! Great minds think alike 😆 It's nice not having to think about this stuff and just defer to people whose whole job it is to keep the internet safer and happier for everyone. The one thing that throws me off is that the token is actually generated super fast but the UI animation takes like a second for the green checkmark to show up (which makes me worry that I'm jumping the gun with my form submission). But, I'm assuming that "normal" people using the form won't be concerned with such things.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →