09 January 2010

Writing a Google Chrome extension: How to access the global context of the embedding page through a content script.

If you just want the code, skip all of this and get to business at the end of this entry.

Otherwise, here's the breakdown..

I've been writing a Google Chrome extension for the past week, and today I got to the point where I write a content script, if you don't know what that is, put simply: it's a javascript file that gets run against certain websites every time they're loaded (as in, Chrome's equivalent of a Greasemonkey script); however, there's a catch.

Due to the way Chrome handles scripting internally (via Google V8), the contexts of the javascript bits running in the original page and the extension's are completely separate; In plain English, this means that it is impossible to access a global variable that lives in the context of the webpage from the extension, and the same goes for the other way around;

Consider a page with the following code:

<html>
<head>
<script>
var foo = true;
function bar() {
return "foo is: " + foo;
}
</script>
</head>
<body>
</body>
</html>
If you create a content script that runs against this page and tries to access the values of 'foo' or 'bar', the attempt will result in 'undefined', the variables simply do not exist in the context you're trying to access them from..

That isolation technique is understandable from a security point of view, but sometimes you have to do just that; for example, in situations such as mine:
The page I was writing the content script for had a complex AJAX system in place; which maintains, validates, and updates a security token in the background by communicating with the server, and since I didn't want to disrupt the mechanism already in place by disabling that system and implementing my own; The simplest (and cleanest) solution that popped in my mind was to read the global variable containing said token directly in a timely fashion.

This is when I started searching around for a possible work-around for this problem, I had read something about indirect page-extension communication while skimming through the Chrome extension API documentation before, and I finally found it.

Let me quote it here again:

Although the execution environments of content scripts and the pages that host them are isolated from each other, they share access to the page's DOM. If the page wishes to communicate with the content script (or with the extension via the content script), it must do so through the shared DOM.

An example can be accomplished using custom DOM events and storing data in a known location.

That was followed by an example that shows how to setup a pseudo-communication channel initialized by both parties (the extension and the page). But wait, doesn't this assume (and require) that both parties participate? what if the page had no idea that my script is there? and isn't this the typical case?


Some older builds of Google Chrome apparently had a way to expose the global object of the target page to the content scripts (the contentWindow built-in global variable), but it got removed at some point.


Rules are made to be broken, and I decided that I'll be breaking this one today.


After tinkering around for a whole day I finally managed to do this, so I'll slowly walk you through my thought process: Since both contexts share the DOM, both can modify it, but it turns out that it belongs to the page, not the extension, and new elements added to the page by the extension are ultimately "owned" by the original page in the end.


Once I realized this, and wrote some test cases where the page manipulated new items that got inserted by the content script long after the content script had finished it's execution, I began to think of <script> tags.


What happens if you dynamically insert a <script> tag from the content script's context? which context would it end up in? Long story short: it ends up running in the original page's context, not the extension's; which was an awesome revelation at the time!

After a few tests I was positive that my injected scripts were running the way I wanted them, a few alert()'s showed me that they could access global variables from the original page just like as if they were parts of it, but getting those values back to the extension turned out to be another story.

At first I thought about doing it the way I quoted above, set up some custom event handlers and have a basic messaging system in place, where the extension would send in commands and recieve responses through the custom event handler; but being me, I just couldn't settle for that.

At first I had something as simple as this:


function injectScript(source)
{
var elem = document.createElement("script");
elem.type = "text/javascript";
elem.innerHTML = script;
return document.head.appendChild(elem);
}

But then I modified it a little bit to accept functions as a parameter for convenience, which it would convert into strings and have them injected and run like normal; it worked and all went well, but then again, I thought: what if I could pass parameters transparently to those functions? and I revised it again, and again, and again, and it eventually worked by converting the parameters to JSON, writing them in, then converting them back through a small bootstrapper that calls the wrapped function transparently.

Then I thought about return values; how can I get those back? at first I thought about the custom event handler strategy, but then I realized that it wouldn't work the way I intended for it in the first place, using event handlers means that you must provide a closure to recieve the value through, I wanted to this in a different (procedurally-transparent) way.

After expirementing a little more, I found out that appendChild() didn't return back to the caller until the injected script finished executing, effectively blocking execution of the caller for that duration, which I found useful for what I was going to do next.

(Since I'm writing a browser extension that will only work in Google Chrome, and thereafter don't have to care about browser cross-compatibility at all, I usually exploit this kind of thing to the brim, standard or not, if it works I'll use it; IE can go to hell kindly =P)

The example I mentioned earlier used a hidden <div> for communication, and I could've done the same, albeit JSON data tends to break HTML often, that, and the fact that I hate the clutter of it all, at first I thought about transferring the data by assigning a value to a custom field attached to the HTMLScriptElement, but that didn't work, the isolated contexts and all.

Then it hit me, JSON is inevitably valid ECMA/Javascript as far as v8 is concerned, so I had the script convert the return value to JSON then overwrite itself with it.

For this, we assign a random "id" attribute to the <script> block, I know, not strictly valid HTML, but it works and Chrome doesn't complain for the matter; As a final step I rewrote some of the argument parsing code so that it passes functions correctly (which v8's strict JSON implementation refuses to process), custom objects, and have it skip JSON conversion for numbers/booleans, pass on Date/RegExp objects too (which v8::JSON messes up badly).

I also thought about exceptions, and added a mechanism to catch and transparently re-throw them in the original context.

And here's the final result: (Licensed under the MIT license)


//////////////////////////////////////////////////////////////////////////////////////////////
// Copyright(C) 2010 Abdullah Ali, voodooattack@hotmail.com //
//////////////////////////////////////////////////////////////////////////////////////////////
// Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php //
//////////////////////////////////////////////////////////////////////////////////////////////

// Injects a script into the DOM, the new script gets executed in the original page's
// context instead of the active content-script context.
//
// Parameters:
// source: [string/function]
// (2..n): Function arguments if a function was passed as the first parameter.


function injectScript(source)
{

// Utilities
var isFunction = function (arg) {
return (Object.prototype.toString.call(arg) == "[object Function]");
};

var jsEscape = function (str) {
// Replaces quotes with numerical escape sequences to
// avoid single-quote-double-quote-hell, also helps by escaping HTML special chars.
if (!str || !str.length) return str;
// use \W in the square brackets if you have trouble with any values.
var r = /['"<>\/]/g, result = "", l = 0, c;
do{ c = r.exec(str);
result += (c ? (str.substring(l, r.lastIndex-1) + "\\x" +
c[0].charCodeAt(0).toString(16)) : (str.substring(l)));
} while (c && ((l = r.lastIndex) > 0))
return (result.length ? result : str);
};

var bFunction = isFunction(source);
var elem = document.createElement("script"); // create the new script element.
var script, ret, id = "";

if (bFunction)
{
// We're dealing with a function, prepare the arguments.
var args = [];

for (var i = 1; i < arguments.length; i++)
{
var raw = arguments[i];
var arg;

if (isFunction(raw)) // argument is a function.
arg = "eval(\"" + jsEscape("(" + raw.toString() + ")") + "\")";
else if (Object.prototype.toString.call(raw) == '[object Date]') // Date
arg = "(new Date(" + raw.getTime().toString() + "))";
else if (Object.prototype.toString.call(raw) == '[object RegExp]') // RegExp
arg = "(new RegExp(" + raw.toString() + "))";
else if (typeof raw === 'string' || typeof raw === 'object') // String or another object
arg = "JSON.parse(\"" + jsEscape(JSON.stringify(raw)) + "\")";
else
arg = raw.toString(); // Anything else number/boolean

args.push(arg); // push the new argument on the list
}

// generate a random id string for the script block
while (id.length < 16) id += String.fromCharCode(((!id.length || Math.random() > 0.5) ?
0x61 + Math.floor(Math.random() * 0x19) : 0x30 + Math.floor(Math.random() * 0x9 )));

// build the final script string, wrapping the original in a boot-strapper/proxy:
script = "(function(){var value={callResult: null, throwValue: false};try{value.callResult=(("+
source.toString()+")("+args.join()+"));}catch(e){value.throwValue=true;value.callResult=e;};"+
"document.getElementById('"+id+"').innerText=JSON.stringify(value);})();";

elem.id = id;
}
else // plain string, just copy it over.
{
script = source;
}

elem.type = "text/javascript";
elem.innerHTML = script;

// insert the element into the DOM (it starts to execute instantly)
document.head.appendChild(elem);

if (bFunction)
{
// get the return value from our function:
ret = JSON.parse(elem.innerText);

// remove the now-useless clutter.
elem.parentNode.removeChild(elem);

// make sure the garbage collector picks it instantly. (and hope it does)
delete (elem);

// see if our returned value was thrown or not
if (ret.throwValue)
throw (ret.callResult);
else
return (ret.callResult);
}
else // plain text insertion, return the new script element.
return (elem);
}

12 comments:

Tom Sherman said...

Holy crap this is awesome. I'm so glad I stumbled on this!

bobgubko said...

fuck! it's beautiful! thanks!

Laurent Caillette said...

That truly Grand Voodoo, man. You made my day. Chrome refused (with good reasons) to let my page load text resources on file: but extensions might do that.

shesek said...

Great post, really helped me out! The trick with appendChild() being synchronous is really neat, great job finding that out!

Just one note - the `delete` operator can only be used to delete object properties, not variables. Also, it doesn't really matter - the DOM element is already deleted by the previous removeChild statement, and the garbage collector would also delete the variable as soon as it gets out of scope.

Andrey said...

Thanks! Its help me a lot.

Iam Vela said...

Umm.. so I'm a newbie and I have loaded an external JS script I wrote. In there I have a function called myFunc();

If I type that in the console it works, but I cannot programmatically call it from my extension.

So I added the injectScript() function to my extension - in the background.js. Now I wish to call myFunc so I say
injectScript("myFunc()");
but that doesn't seem to work.

What am I doing wrong?

Unknown said...

Hello John,

You have to add injectScript to the content script, it won't work in background.html

Moreover, in your use case, you should not wrap the function in quotation marks, simply call it like so:

injectScript(myFunc);

Pass any arguments after the function object, it will be invoked in the page's context with any secondary parameters you provide.

If you invoke injectScript with the first parameter as a string, it will create a script element in the page's context with whatever you passed as the first parameter inside, in that case your script will be executed and it will return the appended script element instead.

BJ said...

Do you know if this script and general method will still work? I realise it's almost 3 years old now...

Would it work if I wanted to fire an event and then extract the generated HTML elements from a page I do not control using an external script (eg content-script of a chrome extension)?

Thanks

Lars Ole Avery Simonsen said...

This was just what I needed to make progress on my pet project. I went with the simple version customized for my needs. Thanks a bunch.

Unknown said...
This comment has been removed by the author.
Unknown said...
This comment has been removed by the author.
Nina said...

Thank you so much, this was brilliant.