Campaign Design API > Validating campaign scripts on the client

Who is this article for?

  • Web Developers who build campaigns on highly secure pages that store sensitive information
  • Marketers working closely with Web Developers managing optimization campaigns on highly secure pages

Overview

Oracle Maxymiser optimization campaigns are delivered as a dynamically constructed response to the visitors' browser. This approach provides the following benefits:

  1. Testing has a minimal footprint on the website pages (static content is cached, while dynamic parts are delivered only on demand);
  2. Visitors only have access to the relevant content (the alternative scenario would make the whole testing configuration accessible to anyone with just basic JavaScript knowledge).

Challenge: Managing all the executable content in-house

Oracle Maxymiser service is distributed across different geo-locations and delivered via web accelerated channels. For those who would like to manage all the executable content on their end, there's an option to use on-page validation of the dynamic content to make sure it corresponds to the signed off version of the code.

This approach provides an additional layer of security on top of several security practices already implemented at Oracle Maxymiser. The solution is completely optional and not required, unless you have strict prescriptions over the content on your pages, where external scripts cannot be executed.

Solution: Validate Maxymiser response with a custom extension

A custom Campaign Design API tag extension is provided to help marketers and developers validate that the active content arriving to the visitor's browser is exactly the same as expected. This is done by an automatic cryptographic hashes verification of the arrived dynamic data.



In short, the process of verification implies two steps:

  1. Generate a list of cryptographic hashes

    Campaign content is programmatically retrieved by the PHP file provided in the bottom of the article (under the "Generate hashes programmatically" heading). This is done for every campaign that is intended to go live on your website pages with sensitive information.

    As a result, you get a hash table in the output from requesting the PHP file. Then this response should be placed on your webpage as a JavaScript file (e.g., signed-hashes.js). A sample hash table would look like the one below:

    window.signedHashes = { "Scripts": {}, "Campaigns": { "Demo": { "Scripts": { "Rendering": "3ddaa...", "V905": "f72a7..." }, "Elements": { "element1": { "variant1": ["5676c...", "016ba..."] } } } } }

  2. Add the hashes verification snippet into the Maxymiser tag

    (the snippet is available under the “beforeInit() handler” paragraph, this code can be extended if needed)

    This one is done once for the tag and can be configured to run only on specific URLs. It performs authentication of the arrived testing data, which is the only dynamic piece of code that comes from Maxymiser. Whenever Maxymiser campaigns are requested for a visitor, the browser would build the given content hashes on the client and compare those to the ones that are available on the page in the window.signedHashes table.

The solution requires generating a list of the hashes prior to every content publish. A good practice would be ensuring that the consolidated hashes tree does not include the outdated hashes.

As a result campaign development workflow with hash verification would look this way:

  1. Develop and QA a campaign
  2. Generate new content hashes
  3. Update the signed-off hashes file on your live website, so that it includes the new campaign
  4. Verify that all the campaign variants are recognized by the Maxymiser validation logic
  5. Publish the campaign

A PHP app that generates hashes for all the Sandbox scripts and campaigns is provided at the end of this article.

Getting started

First off you need to control the Oracle Maxymiser tag content. There are two options to ensure no one changes this without your permission:

  • You can set a Subresource Integrity HTML attribute (integrity="sha512-...")
  • You can host the tag on your end

Secondly, you need to self-host auxiliary libraries (such as "un.js", "qa.js", "debug.js" and "mmpackage.js"). Create a new directory on your server and reference it from the tag's baseContentUrl setting.

Finally, the code below must be applied to the beforeInit() handler of the Maxymiser tag. Once applied, all the dynamic responses coming from the Maxymiser service are either blocked or processed depending on the specified rules.

beforeInit:function( getParam, setParam, loader ){ // generate a hash, use an external custom function function getHash( target ) { return sha256( target ); } // compare scripts' hashes function validateScripts( signedScripts, responseScripts ) { var invalidScripts = []; responseScripts && responseScripts.forEach( function( script ) { if ( signedScripts[ script.Name ] !== getHash( script.Data ) ) { invalidScripts.push( script.Name ); } } ); return invalidScripts; } // compare campaigns' hashes function validateCampaigns( campaigns ) { var invalidCampaigns = {}; campaigns && campaigns.forEach( function( campaign ) { var isValid = true, name = campaign.Name; if ( !signedHashes.Campaigns[ name ] ) { invalidCampaigns[ name ] = ''; return; } var invalidCampaignScripts = validateScripts( signedHashes.Campaigns[ name ].Scripts, campaign.Scripts ); if ( invalidCampaignScripts.length ) { invalidCampaigns[ name ] = ''; } var invalidCampaignVariants = validateVariants( signedHashes.Campaigns[ name ].Elements, campaign.Elements ); if ( invalidCampaignVariants.length ) { invalidCampaigns[ name ] = ''; } } ); return Object.keys( invalidCampaigns ); } // compare variants' hashes function validateVariants( signedVariants, responseElements ) { var invalidVariants = {}; responseElements && responseElements.forEach( function( elementContent ) { var contents = elementContent.Data || []; contents.forEach( function( variantChunk, i ) { if ( typeof signedVariants[ elementContent.Name ] !== 'object' || typeof signedVariants[ elementContent.Name ][ elementContent.VariantName ] !== 'object' || signedVariants[ elementContent.Name ][ elementContent.VariantName ][ i ] !== getHash( variantChunk.Data ) ) { invalidVariants[ elementContent.Name ] = ''; } } ); } ); return Object.keys( invalidVariants ); } // validate responses function checkHashes( response, runResponse ) { var responseValid = true; var invalidScripts = validateScripts( signedHashes.Scripts, response.Scripts ); var invalidCampaigns = validateCampaigns( response.Campaigns ); if ( invalidScripts.length ) { responseValid = false; console.error( 'Invalid scripts: ' + invalidScripts.join( ', ' ) ); } if ( invalidCampaigns.length ) { responseValid = false; console.error( 'Invalid campaigns: ' + invalidCampaigns.join( ', ' ) ); } if ( responseValid ) { // execute scripts and render campaigns that arrived with the response runResponse(); } } loader.validateResponses( checkHashes ); },

You can copy and paste the code above and specify the pages where you want the validation to run. To do this just wrap the loader.validateResponses() call with the required conditions.

Any other condition can be added to fulfil your security goal.

Generate hashes programmatically

The extension above uses a hash table of the verified content. Here's a PHP app script that may be used to generate the hash table. As a result it prints a JSON tree that contains key-value pairs of every executable item from the specified UI site.

Note: This code can only be used for sites that have REST API enabled.

Source code:

<?php // specify your settings here class Config { // REST API hostnames static $AUTH_HOST = 'replace_with_REST_API_OAUTH_hostname'; static $HOST = 'replace_with_REST_API_hostname'; // UI user credentials static $USER = array('replace_with_UI_user_email', 'replace_with_UI_user_password'); // UI REST API client id and secrent static $CLIENT = array('replace_with_Client_ID', 'replace_with_Client_Secret'); // UI site static $UI_SITENAME = 'replace_with_UI_sitename'; } // store hash table and generate individual hashes class Hashes { public $table; function __construct() { $this->table = (object) array(); } public function generate($message) { return hash('sha512', $message); } } $hashes = new Hashes; // parse REST API responses class Response { // detect site id that corresponds the given site name private function get_site_id($sites) { foreach ($sites->items as $site) { if ($site->name === Config::$UI_SITENAME) { return $site->id; } } return ''; } private function generate_campaign_hashes(&$caller, $response_data) { global $hashes; $hash_tree_branch = &$hashes->table->Campaigns; $hash_tree_branch = (object) array(); foreach ($response_data->items as $item) { $hash_tree_branch->{$item->name} = (object) array(); $caller->params['campaign_id'] = $item->id; $caller->params['campaign_name'] = $item->name; $caller->get('campaign_scripts'); $caller->get('campaign_actions'); $caller->get('campaign_elements'); } } private function generate_campaign_element_hashes(&$caller, $response_data) { global $hashes; $hash_tree_branch = &$hashes->table->Campaigns->{$caller->params['campaign_name']}->Elements; $hash_tree_branch = (object) array(); foreach ($response_data->items as $item) { $element_name = strtolower($item->name); $hash_tree_branch->{$element_name} = (object) array(); $caller->params['element_id'] = $item->id; $caller->params['element_name'] = $element_name; $caller->get('campaign_variants'); } } private function generate_campaign_variant_hashes(&$caller, $response_data) { global $hashes; $hash_tree_branch = &$hashes->table->Campaigns->{$caller->params['campaign_name']}->Elements->{$caller->params['element_name']}; $hash_tree_branch = (object) array(); foreach($response_data->items as $item) { if (strlen($item->content)) { $variant_name = strtolower($item->name); $hash_tree_branch->{$variant_name} = array(); // split variant content the same way it's done for browsers $matches = preg_split("/<style[^>]*>|<\/style>|<script[^>]*>|<\/script>/sim", $item->content); // remove the first zero-length array item if (count($matches) > 1 && strlen($matches[0]) === 0) { array_shift($matches); } // combine the hashes for variant chunks into an array foreach ($matches as $value) { array_push($hash_tree_branch->{$variant_name}, $hashes->generate(str_replace("\r\n", "\n", $value))); } } } } // generate hashes for site scripts, campaign scripts and campaign action scripts function generate_script_hashes(&$hash_tree_branch, $response_data, $key, $field) { global $hashes; if (!isset($hash_tree_branch)) { $hash_tree_branch = (object) array(); } foreach ($response_data->items as $item) { if (isset($item->{$field})) { $hash_tree_branch->{$item->{$key}} = $hashes->generate(str_replace("\r\n", "\n", $item->{$field})); } } } // parse response body by REST API endpoint function __construct(&$caller, $endpoint, $response_body, $response_code) { global $hashes; if ($response_code !== 200) { exit($response_body); } $response_data = json_decode($response_body); switch ($endpoint) { case 'auth': $caller->params['auth_header'] = $response_data->token_type . ' ' . $response_data->access_token; break; case 'sites': $caller->params['site_id'] = $this->get_site_id($response_data); break; case 'scripts': $this->generate_script_hashes($hashes->table->Scripts, $response_data, 'name', 'content'); break; case 'campaigns': $this->generate_campaign_hashes($caller, $response_data); break; case 'campaign_scripts': $this->generate_script_hashes($hashes->table->Campaigns->{$caller->params['campaign_name']}->Scripts, $response_data, 'name', 'content'); break; case 'campaign_actions': $this->generate_script_hashes($hashes->table->Campaigns->{$caller->params['campaign_name']}->Scripts, $response_data, 'scriptName', 'scriptContent'); break; case 'campaign_elements': $this->generate_campaign_element_hashes($caller, $response_data); break; case 'campaign_variants': $this->generate_campaign_variant_hashes($caller, $response_data); break; } } } // perform REST API requests class Call { private $connection; public $params; // authenticate and get all the site executables function __construct() { header('Content-Type: application/json; charset=utf-8'); $this->connection = curl_init(); $this->params = array( 'auth_header' => '', 'site_id' => '', 'campaign_id' => '', 'campaign_name' => '', 'element_id' => '', 'element_name' => '' ); $this->auth(); $this->get('sites'); $this->get('scripts'); $this->get('campaigns'); } // generate a URL for every executable item by REST API endpoint private function url($endpoint) { switch ($endpoint) { case 'auth': return 'https://' . Config::$AUTH_HOST . '/oauth2/v1/tokens'; case 'sites': return 'https://' . Config::$HOST . '/v1/sites'; case 'scripts': return 'https://' . Config::$HOST . '/v1/sites/' . $this->params['site_id'] . '/sandbox/scripts'; case 'campaigns': return 'https://' . Config::$HOST . '/v1/sites/' . $this->params['site_id'] . '/sandbox/campaigns'; case 'campaign_scripts': return 'https://' . Config::$HOST . '/v1/sites/' . $this->params['site_id'] . '/sandbox/campaigns/' . $this->params['campaign_id'] . '/scripts'; break; case 'campaign_actions': return 'https://' . Config::$HOST . '/v1/sites/' . $this->params['site_id'] . '/sandbox/campaigns/' . $this->params['campaign_id'] . '/actions'; break; case 'campaign_elements': return 'https://' . Config::$HOST . '/v1/sites/' . $this->params['site_id'] . '/sandbox/campaigns/' . $this->params['campaign_id'] . '/elements'; break; case 'campaign_variants': return 'https://' . Config::$HOST . '/v1/sites/' . $this->params['site_id'] . '/sandbox/campaigns/' . $this->params['campaign_id'] . '/elements/' . $this->params['element_id'] . '/variants'; break; } } // send content request public function get($endpoint) { // sleep for 0.2 sec usleep(200000); curl_reset($this->connection); curl_setopt($this->connection, CURLOPT_HTTPHEADER, array( 'Authorization: ' . $this->params['auth_header'] )); curl_setopt($this->connection, CURLOPT_URL, $this->url($endpoint)); curl_setopt($this->connection, CURLOPT_RETURNTRANSFER, true); $response_body = curl_exec($this->connection); $response_code = curl_getinfo($this->connection, CURLINFO_HTTP_CODE); new Response($this, $endpoint, $response_body, $response_code); } // authenticate the given user public function auth() { curl_reset($this->connection); curl_setopt($this->connection, CURLOPT_CUSTOMREQUEST, 'POST'); curl_setopt($this->connection, CURLOPT_POSTFIELDS, http_build_query(array( grant_type => 'password', username => Config::$USER[0], password => Config::$USER[1] ))); curl_setopt($this->connection, CURLOPT_HTTPHEADER, array( 'Content-Type: application/x-www-form-urlencoded', 'Authorization: Basic ' . base64_encode(join(':',Config::$CLIENT)) )); curl_setopt($this->connection, CURLOPT_URL, $this->url('auth')); curl_setopt($this->connection, CURLOPT_RETURNTRANSFER, true); new Response($this, 'auth', curl_exec($this->connection), curl_getinfo($this->connection, CURLINFO_HTTP_CODE)); } } new Call; // output the hashes echo json_encode($hashes->table); ?>

More information and guidance on how to use the REST API can be obtained on these help pages: Authenticate with OAUTH 2.0 and Request content with REST API

Please set all the configuration parameters at the very top of the file before you use it.