Arkose on CloudFlare - Reference Architecture
Overview
This document describes the workflow and implementation with CloudFlare. The logic to verify the Arkose Labs token when handling a call to a protected endpoint would normally be handled by the web servers of Arkose Labs’ customers. When customers use CloudFlare to accelerate their traffic, the logic can be offloaded to the CDN. This not only helps streamline the integration of new customers or new endpoints but also shifts malicious traffic to the CDN layer, saving the customer’s web server bandwidth for legitimate traffic. When Arkose Bot Manager is integrated with CloudFlare, the CDN layer will take care of the following steps:
- If the
arkosesessiontoken
header is missing from the request to the protected endpoint, block the request and respond to the client Invalid orMissing Arkose Session Token
- Otherwise:
- Forward the request to Arkose Labs’ verify API
- Process the response from the Arkose Labs API and decide whether to block the request (
solved
=false) or forward it to the origin web server (solved
=true)
Workflow
Integration Steps
This integration consists of two parts:
- Add and configure Arkose client side javascript.
- Configure and deploy the verification Cloudflare worker.
Client-Side Setup
In order to use Arkose Bot Manager, some JavaScript additions needs to be made to the page you want to protect, such as your Login or Registration page. Documentation for the client-side additions can be found in the Arkose Labs standard integration guides:
The following code is an example of a basic login page that invokes the Arkose Labs detection and enforcement process on the submit button and passes the arkosesessiontoken
header as a header:
<html>
<head>
<!--
Include the Arkose Labs API in the <head> of your page. In the example below, remember to
replace <company> with your company's personalized Client API URL name, and replace <YOUR PUBLIC KEY> with the public key supplied to you by Arkose Labs.
e.g. <script src="//client-api.arkoselabs.com/v2/11111111-1111-1111-1111-111111111111/api.js" data-callback="setupEnforcement" async defer></script>
-->
<script src="//<company>-api.arkoselabs.com/v2/<YOUR_PUBLIC_KEY>/api.js" data-callback="setupEnforcement" async defer></script>
</head>
<body>
<!--
The trigger element can exist anywhere in your page and can be added to the DOM at any time.
-->
<h1>Test web site with Cloudflare integration</h1>
<br/>
<form method="post" id='loginForm' target="_self" action="./login.php">
<input id=username type="text">
<input id=password type="text">
<input id="arkoseID" name="arkoseID" type="hidden" value="">
<input type="submit" id="submit-id" onclick="return false;">
</form>
<!--
To configure the enforcement place a script tag just before the closing <body> tag and define the
callback as a global function.
-->
<script>
<!--
This global function will be invoked when the API is ready. Ensure the name is the same name
that is defined on the attribute `data-callback` in the script tag that loads the api for your
public key.
-->
function setupEnforcement(myEnforcement) {
myEnforcement.setConfig({
selector: '#submit-id',
onCompleted: function(response) {
console.log(response.token);
var xhttp = new XMLHttpRequest();
var jsondata = JSON.stringify({
username: document.getElementById("username").value,
password: document.getElementById("password").value
});
xhttp.open("POST", './login.php');
xhttp.setRequestHeader('arkosesessiontoken', response.token);
xhttp.send(jsondata);
document.getElementById("loginForm").reset();
}
});
}
</script>
</body>
</html>
Server-Side Setup
Please ensure you have a Cloudflare Account with Cloudflare Workers features enabled. For information on Cloudflare workers please visit: https://developers.cloudflare.com/workers/get-started/quickstarts/ )
Before deploying the Cloud worker javascript, please remember to make the follow changes:
- Update the
ARKOSE_API_SECRET
with your private key. - Update
pathname
to match the path name you intend to protect. - Update the Verify API
https://<company>-verify.arkoselabs.com/api/v4/verify/
with the API provided to you by Arkose Labs. Remember to replace<company>
with your company's personalized Client API URL name.
const ARKOSE_API_SECRET = globalThis.ARKOSE_API_SECRET;
// Modify config according to your use case
const CONFIG = {
triggers: [
{
route: {
method: "POST", // HTTP method of the matched request
pathname: "/signup", // pathname of the matched request
}
},
{
route: {
method: "GET",
pathname: "/",
}
},
],
timeout: 2000, // Arkose api timeout
scrubbedHeaders: ["cookie", "authorization"], // headers to filter out
denyResponse: function (request, data) {
return new Response(null, { status: 403, statusText: "denied" });
},
extractSessionToken: function (formData, headers) {
// here we get request token from the form data
if (formData) {
return formData.get("arkose_session_token");
}
// but if token is sent differently eg in headers you can replace this line with
// return headers.get("Arkose-Session-Token");
}
return user;
},
};
class InvalidsessionTokenError extends Error {
constructor(message) {
super(message);
// assign the error class name in your custom error (as a shortcut)
this.name = this.constructor.name;
// capturing the stack trace keeps the reference to your error class
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Return prefiltered request headers
* @param {Headers} requestHeaders
*/
function scrubHeaders(requestHeaders) {
const headersObject = Object.fromEntries(requestHeaders);
return Object.keys(headersObject).reduce((accumulator, headerKey) => {
const isScrubbed = CONFIG.scrubbedHeaders.includes(headerKey.toLowerCase());
return {
...accumulator,
[headerKey]: isScrubbed ? true : headersObject[headerKey],
};
}, {});
}
/**
* Return timeout promise on the base of the promise
* @param {number} ms
* @param {Promise} promise
*/
async function timeout(ms, promise) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error("Arkose Api Timeout"));
}, ms);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((reason) => {
clearTimeout(timer);
reject(reason);
});
});
}
/**
* Return the result of the POST /filter call to Verify API
* @param {Object} trigger
* @param {Request} request
*/
async function performRequest(trigger, request) {
const clonedRequest = await request.clone();
let formData;
try {
formData = await clonedRequest.formData();
} catch {}
const sessionToken = CONFIG.extractSessionToken(formData, request.headers);
if (!sessionToken) {
return {solved: false};
}
const requestBody = {
private_key: ARKOSE_API_SECRET,
session_token: sessionToken
};
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
};
try {
const response = await timeout(
CONFIG.timeout,
fetch(`https://<company>-verify.arkoselabs.com/api/v4/verify/`, requestOptions)
);
if (response.status === 201) {
return await response.json();
} else {
throw "arkose error";
}
} catch (err) {
throw err;
}
}
/**
* Return matched action or undefined
* @param {Request} request
*/
function findMatchingRoute(request) {
const requestUrl = new URL(request.url);
for (const trigger of CONFIG.triggers) {
if (
requestUrl.pathname === trigger.route.pathname &&
request.method === trigger.route.method
) {
return trigger;
}
}
}
/**
* Process the received request
* @param {Request} request
*/
async function handleRequest(request) {
try {
const trigger = findMatchingRoute(request);
if (!trigger) {
// returns the original fetch promise
return fetch(request);
}
const arkoseResponse = await performRequest(trigger, request);
if (arkoseResponse && arkoseResponse.solved == false) {
// defined what to do when deny happens
return CONFIG.denyResponse(request, arkoseResponse);
}
// returns the original fetch promise
return fetch(request);
} catch (err) {
if (err instanceof InvalidsessionTokenError) {
// Deny attempt. Likely a bad actor
return CONFIG.denyResponse(request, null);
} else {
// just pass the original fetch promise in case of any other error
return fetch(request);
}
}
}
addEventListener("fetch", (event) => {
if (!ARKOSE_API_SECRET) {
throw new Error("ARKOSE_API_SECRET not provided");
}
event.respondWith(handleRequest(event.request));
});
Updated about 1 year ago