CustomEvent.detail “tainted”?

2019-07-21 11:36发布

问题:

I'm developing a Chrome extension to add convenience to a website.
I have access to the page's DOM, but I also need to interact with the "first-party" JS on that page, which I cannot do from my extension.

I can inject arbitrary tags into the page (most notably also <script> tags), but since escaping strings like

{
    html: '<div onclick="doSomething(this, \'someName\')"></div>'
}

is a real pain, I'd like to keep the injected code at an absolute minimum.

I tried injecting event listeners into the page in order to fetch JS variables from the page, but ran into a problem.
It seems that if a CustomEvent is passed from an extension to a website or back, and if CustomEvent.detail contains certain types of objects (at least functions and errors) somewhere, the entire CustomEvent.detail will be purged, i.e. set to null.

Example

Script (extension.js):

(function()
{
    var script = document.createElement('script');
    script.innerHTML = [
"window.addEventListener('xyz', function(ev)",
"    {                                      ",
"        console.log('after dispatch:');    ",
"        console.log(ev.detail);            ",
"    });                                    ",
    ].join('\n');
    document.head.appendChild(script);
    // JSON-serializable data
    var e = new CustomEvent('xyz', { detail: { x: 42, name: 'Schroedinger' } });
    console.log('before dispatch:')
    console.log(e.detail);
    window.dispatchEvent(e);
    // non-JSON-serializable data
    var detail = { x: 42, name: 'Schroedinger' };
    detail.detail = detail; // Create circular reference
    e = new CustomEvent('xyz', { detail: detail });
    console.log('before dispatch:')
    console.log(e.detail);
    window.dispatchEvent(e);
    // data with function
    e = new CustomEvent('xyz', { detail: { x: 42, name: 'Schroedinger', func: function(){} } });
    console.log('before dispatch:');
    console.log(e.detail);
    window.dispatchEvent(e);
    // data with error object
    e = new CustomEvent('xyz', { detail: { x: 42, name: 'Schroedinger', err: new Error() } });
    console.log('before dispatch:');
    console.log(e.detail);
    window.dispatchEvent(e);
})();

Output (paragraphed for readability):

before dispatch:
Object {x: 42, name: "Schroedinger"}
after dispatch:
Object {x: 42, name: "Schroedinger"}

before dispatch:
Object {x: 42, name: "Schroedinger", detail: Object}
after dispatch:
Object {x: 42, name: "Schroedinger", detail: Object}

before dispatch:
Object {x: 42, name: "Schroedinger", func: function (){}}
after dispatch:
null

before dispatch:
Object {x: 42, name: "Schroedinger", err: Error at chrome-extension://...}
after dispatch:
null

I initially thought JSON-serializability was the issue, but circular references pass just fine in events, when they would break if JSON-serialized.
It feels like certain objects "taint" the event detail the same way non-crossorigin images taint canvases, except there's nothing in the console.

I was unable to find any documentation regarding this behaviour, and (as Paul S. suggested), there does not seem to be a "privilege" for that on the Chrome permissions list.

Tested in Chrome 40.0.2214.115m, 43.0.2357.124m and 48.0.2547.0-dev.

回答1:

What I found out

I initially thought this was a security feature, mostly because Firefox behaves that way.

In ran an equivalent test in Firefox by putting the event listener in a separate file that could be loaded via mozIJSSubScriptLoader:

test.js:

(function()
{
    window.addEventListener('xyz', function(ev)
    {
        console.log('after dispatch:');
        console.log(ev.detail);
    });
})();

firefox.js:

(function()
{
    var mozIJSSubScriptLoader = Components.classes["@mozilla.org/moz/jssubscript-loader;1"].getService(Components.interfaces.mozIJSSubScriptLoader);
    window.addEventListener('load', function load(event)
    {
        window.removeEventListener('load', load);
        window.gBrowser.addEventListener('DOMContentLoaded', function(event)
        {
            mozIJSSubScriptLoader.loadSubScript('chrome://my-extension/content/test.js', window.content, 'UTF-8');
            // JSON-serializable data
            var e = new CustomEvent('xyz', { detail: { x: 42, name: 'Schroedinger' } });
            console.log('before dispatch:')
            console.log(e.detail);
            window.content.dispatchEvent(e);
            // non-JSON-serializable data
            e = new CustomEvent('xyz', { detail: { x: 42, name: 'Schroedinger', func: function(){} } });
            console.log('before dispatch:');
            console.log(e.detail);
            window.content.dispatchEvent(e);
        });
    });
})();

Result:

(Note that the error occurs twice.)

So in Firefox it doesn't even matter what detail contains - as long as it comes from an extension, the page is not allowed to access it.
Looks like a security feature to me.

The reason I put the above in a quote is because this is somewhat different in Chrome!

After some deeper investigation it looks like although the extension and the page share the DOM tree, they exist in two different contexts.
I don't know whether this is actually a security feature or just a technical consequence, but this, of course, has the consequence that only clonable objects can be passed back and forth.

What puzzles me though is the fact that the operation silently fails, when, according to the HTML standard, §2.7.5 (structured clone), the entire operation should fail with an error:

↪ If input is another native object type (e.g. Error, Function)
↪ If input is a host object (e.g. a DOM node)
            Throw a DataCloneError exception and abort the overall structured clone algorithm.

Workaround

I ended up using a fairly easy (although not so pretty) workaround:
In Chrome, there's no equivalent to mozIJSSubScriptLoader, but you're allowed to append <script> tags to a page from within your extension (you're not allowed to do that in FF).
Together with chrome.extension.getURL, that can be used to run a JS file packaged with the extension in the context of the page:

(function()
{
    var script = document.createElement('script');
    script.src = chrome.extension.getURL('extension.js');
    document.head.appendChild(script);
})();

Of course that requires that

"web_accessible_resources": [ "extension.js" ]

is set in manifest.json, which isn't pretty, but shouldn't be an actual problem.

The drawback of this is, of course, that from within extension.js you no longer have access to any chrome API your extension has access to, but in my case I didn't need that. It wouldn't be too difficult to set up a proxy via CustomEvent for that though, as the biggest part of the Chrome API only requires and returns data that is clonable.