Skip to main content

Bypassing Tight PCI Restrictions To Develop Tools In A Restricted Work Environment

This post is part of the Into The Past series. A list of posts written long after they happened as part of an attempt to look back at some earlier creations and thoughts I had along the way. This post was written April 2024.

The ultimate support agent tool #

At the time, this was one of the most useful things I had ever created it. This tool helped save a lot of time documenting almost all standard requests from customers and having quick access to copy-pasting default chat scripts or sending useful generic mails. The tool even comes with autocomplete covering basically 90% of all customer contacts!

Some parts of the tool has been stripped out on account that it included either business critical information or authentication flows that were used internally. However, most of the tool is still intact as it was originally developed.

The tool has been made available for easy access here: Norton Helper v7, but I highly recommend going the extended route of executing it the way it was originally intended :)

Setting the scene #

During my time in Athens, Greece, I was working as a customer support agent for the Norton antivirus by Symantec. The job was fairly simple if it wasn't for the horribly under-spec'ed work PCs and very heavy PCI restrictions. The production floor was closed off and inaccesible without key cards and no personal belongings permitted - not even a pen or paper.

I needed something to pass time between customer calls and I definitely needed some way to improve my working conditions. And so the first thoughts about Norton Helper tool was born.

All internet access was blocked except for a few select pages. The Norton webshop, Mozilla main domain and Google main domain (although no search results could be visited) as well as a few intranet pages used for training and what not. My life just got a lot easier once I noticed that some were build with Bootstrap 3.3, and I could copy out the whole layout as needed. I expect both Mozilla and Google were left available on account that the IT department continuously needed to reinstall and update browsers, and did not want to reconfigure firewalls each time.

Salesforce was the CRM used to keep track of customers previous tickets, ongoing cases and generally keeping notes on how to troubleshoot various problems. As such, this was one of the sites that were whitelisted. Salesforce was very memory extensive to work with. The working PCs were at the low end of the scale performance wise. Needless to say, working with Salesforce caused a lot of Chrome crashes.

Whenever Chrome crashes, any unsaved changes and drafts in Salesforce were of course lost. On busy days you wouldn't have time to save all notes between calls. Many details were lost, lots of headaches were born this way.

I figured I could build my own tool to assist with every day tasks. And most importantly, persist any unsaved changes even if a browser crashed once in a while.

Serving HTML and executing Javascript #

Installing any kind of IDE was of course out of the question. Good thing we always can have access to the trusty old notepad.exe. Fun fact, even saving files with an extension like .html or .js was prohibited. So I settled on plain .txt files. All logic written in the same file, top to bottom.

One cool effect of having access to the Mozilla domain was the fact that their developer network subdomain was also avalaible. Meaning full access to the Javascript docs which proved very helpful.

First challenge was to be able to execute any meaningful logic in the browser context. After weighing in options, I settled on this nifty little snippet. The code is meant to be pasted into your developer console from within any browser. Using the DOMParser, we will replace whatever HTML document is shown on the open browser window with a small 500x500 px area. This area has an event listener for the "drop" event. Once a file is dropped into this area, we will parse its content and render it as a new HTML page.

Drag'n'Drop Browser Execution #

https://gist.github.com/CavaleriDK/ae0288754eb678c86896b1fa8e9a8fee

function onInitialLoad(sourceFile) {
  const parser = new DOMParser();
  const initHTML = '<div id="your-files" style="background-color:#ff0000;width:500px;height:500px;"></div>';

  const newDoc = parser.parseFromString(initHTML, "text/html");
  document.replaceChild(newDoc.documentElement, document.documentElement);

  const target = document.getElementById("your-files"); 
  target.addEventListener("dragover", function(event) { 
    event.preventDefault(); 
  }, false); 
  target.addEventListener("drop", function(event) { 
    event.preventDefault(); 
    let i = 0, files = event.dataTransfer.files, len = files.length; 

    for (; i < len; i++) { 
			loadFile(files[i]);
    } 
  }, false);
};
onInitialLoad();

function loadFile(readThis) {
  const reader = new FileReader();
  const parser = new DOMParser(); 
  
  reader.onload = function(event) { 
    let contents = event.target.result; 
    const newDoc = parser.parseFromString(contents, "text/html");
    document.replaceChild(newDoc.documentElement, document.documentElement);
    enableScripts();
  }; 
  reader.onerror = function(event) { 
    console.error("File could not be read! Code " + event.target.error.code); 
  };

  reader.readAsText(readThis); 
};

function enableScripts() {
  const tmpScripts = document.getElementsByTagName('script'); 
  if (tmpScripts.length > 0) { 
    // push all of the document's script tags into an array 
    // (to prevent dom manipulation while iterating over dom nodes) 
    const scripts = []; 

    for (let i = 0; i < tmpScripts.length; i++) { 
      scripts.push(tmpScripts[i]); 
    }

    // iterate over all script tags and create a duplicate tags for each 
    for (let i = 0; i < scripts.length; i++) { 
      const s = document.createElement('script'); 
      s.innerHTML = scripts[i].innerHTML; 

      // add the new node to the page 
      scripts[i].parentNode.appendChild(s); 

      // remove the original (non-executing) node from the page 
      scripts[i].parentNode.removeChild(scripts[i]); 
    }
  }
};

Check out the enableScripts method. This is a hacky but quite cool way to make sure that the javascript content inside <script></script> tages are being executed. Loading them in using the parseFromString method on the DOMParser turned out to not evaluate the script content. However, appending them as new child nodes afterwards made sure they would execute immediately.

Try it out yourself - copy the code content above, open a new blank tab and paste into your browser console to strip away all of the about:blank content and replace it with a red square ready for input.

Stealing left and right #

Remember how one of the intranet sites we had access to was build on Bootstrap 3.3? This meant that I could copy in the entire minified CSS and JS at the top of my .txt file as part of the <head></head> object. Of course this meant the file increasing by several magnitudes in size, and it became quite unmanageable to scroll through it all. However, having a framework layout served in advance was well worth the payoff. Besides, I had all the time in the world to scroll up and down the huge text file while sitting and waiting for the next support call.

Another small feature from the same intranet page was an autocomplete functionality when you wanted to look up colleagues contact information from the same department. They had a lot of shenanigans going on to make that work, but after some very time consuming analysis of minified Javascript, I managed to track down and isolate this snippet below.

(function(){function e(b,e,f){if(!h)throw Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");if(f=f&&f.debug||!1){var a=document.querySelector("#input-textarea-caret-position-mirror-div");a&&a.parentNode.removeChild(a)}a=document.createElement("div");a.id="input-textarea-caret-position-mirror-div";document.body.appendChild(a);var c=a.style,d=window.getComputedStyle?window.getComputedStyle(b):b.currentStyle,k="INPUT"===b.nodeName;c.whiteSpace="pre-wrap";k||(c.wordWrap=
"break-word");c.position="absolute";f||(c.visibility="hidden");l.forEach(function(a){k&&"lineHeight"===a?c.lineHeight=d.height:c[a]=d[a]});m?b.scrollHeight>parseInt(d.height)&&(c.overflowY="scroll"):c.overflow="hidden";a.textContent=b.value.substring(0,e);k&&(a.textContent=a.textContent.replace(/\s/g,"\u00a0"));var g=document.createElement("span");g.textContent=b.value.substring(e)||".";a.appendChild(g);b={top:g.offsetTop+parseInt(d.borderTopWidth),left:g.offsetLeft+parseInt(d.borderLeftWidth),height:parseInt(d.lineHeight)};
f?g.style.backgroundColor="#aaa":document.body.removeChild(a);return b}var l="direction boxSizing width height overflowX overflowY borderTopWidth borderRightWidth borderBottomWidth borderLeftWidth borderStyle paddingTop paddingRight paddingBottom paddingLeft fontStyle fontVariant fontWeight fontStretch fontSize fontSizeAdjust lineHeight fontFamily textAlign textTransform textIndent textDecoration letterSpacing wordSpacing tabSize MozTabSize".split(" "),h="undefined"!==typeof window,m=h&&null!=window.mozInnerScreenX;
"undefined"!=typeof module&&"undefined"!=typeof module.exports?module.exports=e:h&&(window.getCaretCoordinates=e)})();

The snippet here exposes a method getCaretCoordinates which can be used to get the relative coordinates of the input caret to whatever DOM element you want to look up. This was key to positioning the autocomplete box right under your input. The rest of the autocomplete functionality is mostly powered by the following methods. Execution flow and event triggers can be seen from the full script.

function autoSuggestManager (ev) {
	if ( ev.code === "Slash" && ev.target.dataset.autoactive === "false" ) {
		let str = ev.target.value;
		let lines = str.substring(0, ev.target.selectionStart).split("\n");

		let caretLine = lines[lines.length-1];
		autoSuggest(caretLine);

		let caret = getCaretCoordinates(ev.target, ev.target.selectionStart);
		let rect = ev.target.getBoundingClientRect();

		autoCompleteWrapper.style.top = caret.height + caret.top + rect.top + "px";
		autoCompleteWrapper.style.left = caret.left + rect.left + "px";
		ev.target.dataset.autoactive = "true";

	} else if ( ev.code !== "ArrowDown" && ev.code !== "ArrowUp" && ev.target.dataset.autoactive === "true" ) {
		let str = ev.target.value;
		let lines = str.substring(0, ev.target.selectionStart).split("\n");

		let caretLine = lines[lines.length-1];
		
		if (caretLine === "") {
			resetAutoSuggest(ev);
		}else{
			autoSuggest(caretLine);
		}
	}
}

function autoComplete(val) {
	var suggest_return = [];

	for (i = 0; i < suggestions.length; i++) {
		if (val.toLowerCase() === suggestions[i].slice(0, val.length).toLowerCase()) {
			suggest_return.push(suggestions[i]);
		}
	}

	return suggest_return;
}
function autoSuggestPopulate (list) {
	autoCompleteWrapper.classList.remove('hide');

	// empty list
	while( autoCompleteList.hasChildNodes() ) {
		autoCompleteList.removeChild(autoCompleteList.firstChild);
	}

	for (let i = 0; i < list.length; i++) {
		let li = document.createElement("li");

		li.innerHTML = list[i];
		if(i === 0) {
			li.className = "active-choice";
		}
		autoCompleteList.appendChild(li);
	}
}
function autoSuggest (line) {
	let returns = autoComplete(line);
	autoSuggestPopulate(returns);
}

function navigateList (direction) {
	let currentSel = autoCompleteList.getElementsByClassName('active-choice');
	currentSel = currentSel[0];

	if ( direction == "down" && currentSel.nextElementSibling !== null ) {
		currentSel.classList.remove('active-choice');
		currentSel.nextElementSibling.classList.add('active-choice');

	}else if ( direction == "up" && currentSel.previousElementSibling !== null ) {
		currentSel.classList.remove('active-choice');
		currentSel.previousElementSibling.classList.add('active-choice');
	}
}

function resetAutoSuggest(ev) {
	// empty list
	while( autoCompleteList.hasChildNodes() ) {
		autoCompleteList.removeChild(autoCompleteList.firstChild);
	}

	autoCompleteWrapper.classList.add('hide');
	ev.target.dataset.autoactive = "false";
	
}

function selectAutoSuggestion (selection, event) {
	let str = event.target.value;
	let lines = str.substring(0, event.target.selectionStart).split("\n");
	let allLines = str.substring(0).split("\n");

	let caretLine = lines[lines.length-1];

	let caretLineIndex = allLines.lastIndexOf(caretLine);
	allLines[caretLineIndex] = selection.innerText;

	event.target.value = allLines.join('\n');

	event.target.setSelectionRange(event.target.value.lastIndexOf(selection.innerText)+selection.innerText.length, event.target.value.lastIndexOf(selection.innerText)+selection.innerText.length);

	resetAutoSuggest(event);
}

Persistent storage #

Aside from the benefits of being able to document cases faster and collect all snippets, chat scripts and e-mail templates in one place, my main driving force for building this tool was to avoid the pain that was losing several case documents due to browser crashes. So I needed a way to persist data. Cue localStorage.

Working with localStorage is fairly simple, and I added a few event listeners on every input element to make sure that we save all data whenever we remove focus from that element. As an example, we would save the notepad every time we finished typing something and clicked away.

let notepad = document.getElementById('notepad');

notepad.addEventListener('focusout', function(ev) {
	persistNotepad();
}, false);

...

function persistNotepad () {
	let notepad = document.getElementById('notepad');
	notepad.value = limitCharacters(notepad.value);

	window.localStorage.setItem("notepad", notepad.value);
}

// Default to 4.000 bytes(?) of characters to prevent localstorage filling up 
// and impossible load times on startup
function limitCharacters (str, maxLength = 4000) {
	if (str.length > maxLength) {
		return str.substr(str.length - maxLength);
	} else {
		return str;
	}
}

This however taught me a new lesson - The Storage API only works in secure contexts (HTTPS). But I can't host my web pages, let alone serve them with a SSL certificate of their own. So how can we work around this?

Error: Access to localStorage is denied

As it turned out, this was surprisingly simple to solve. Literally nothing prevents us from piggy back riding on another sites SSL certificate for our use case. We can open any secure web site, run our Drag'n'Drop script and replace it with our own web page. This will serve our page in a secure context. The only caveat is, we need to use the same domain every time to make sure we load the correct storage data. I went with Google.com as my piggy back to ride.

This was all fun and games until one faithful evening shift. I would normally clock out at 20:00 local time for the late shift, unless I had any ongoing communications on the chat. In that case, I would need to finish those first. This was one of those days where both the customer and I were exhausted, and solving their issue dragged out longer than it should have.

After closing off with the customer, I quickly jotted down my notes for the case into the tool along with a few earlier cases that were yet to be documented in Salesforce. I figured it was getting late, and I had full confidence in my Norton Helper that I could shut of the PC and return back tomorrow to document it in Salesforce.

Until I couldn't...

Even looking back now I can hardly recognize any patterns or reason as to why, but for whatever reason the cache and all browser data was cleared the next morning. Saved bookmarks, browsing history, the lot. As well as the data from my localStorage. I was left with equal parts of disbelief and frustration. So I had to come up with a better solution. So I introduced an option to export and import data after a shift.

Export and import functionality

Aptly named after the event, this allowed me to save all data in a JSON formatted .txt file and import the same data structure. That was the end of my troubles and I wouldn't see another crash causing overtime, lost documentation or the like. The feature name stuck around through several iterations of the tool as a reminder of the pains that drove me to build all of this.

Full page source and steps to run #

The full page source is far too heavy to paste here. However, you can grab a copy and save as Norton Helper v7.txt file from the gist link here. Click raw and then right click to save as text file.

As we learned in the section above, we need to piggy back ride on an existing SSL certificate in order for the site to fully function. You can go ahead and use this blog post, but for authenticity I would promote using https://google.com.

  1. Navigate to Google.com in a new window.
  2. Open the developer console in your browser.
  3. Paste the Drag'n'Drop Browser Execution script and hit Enter. Wait for the page to transform into a red square.
  4. Drag the Norton Helper v7.txt file with the helper tool page source into the red square.
  5. Be a productive support agent.