WebExtensions porting: access to cross-origin document.styleSheets[...].cssRules

Hello. While porting my add-on to WebExtensions I’ve stumbled upon a severe issue: I need to access and modify document.styleSheets[...].cssRules including stylesheets loaded from foreign origins but I can’t (inline stylesheets work).

I used to access it from remoteRequire‘d script and it just worked. From WebExtensions’ content script it throws SecurityError just like from a regular webpage script.
I have "<all_urls>" in "permissions" manifest.json key but it has no effect. I’ve also tried adding individual origins to "permissions" to check if it will work — it doesn’t.

I’ve found a sort of workaround:

try {
    CSSStyleSheet_v.cssRules;
} catch (e) {
    if (e.name === 'SecurityError') {
        CSSStyleSheet_v.ownerNode.parentNode.removeChild(CSSStyleSheet_v.ownerNode);
        fetch(CSSStyleSheet_v.href).then(resp => resp.text()).then(css => {
            let style = document.createElement('style');
            style.innerText = css;
            document.head.appendChild(style);
        });
    }
}

However, with this approach there are still issues:

  • relative urls resolution in stylesheet will be broken
  • most likely (I haven’t checked yet) access to descendant stylesheets (loaded with @import) will not work
    • UPD: foreign @imports “taint” even same-origin stylesheets so they start throwing SecurityError too
  • It is very slow.

Note that this security restrictions makes no sense here: I can’t access stylesheet but I can fetch() it (because I’ve requested permissions for it)!

Any help will be appreciated!

UPD: bug reported https://bugzilla.mozilla.org/show_bug.cgi?id=1393022

This is how I understand the situation so far:

Content scripts are executed under a principle subsuming only the principle of the window they are attached to. The implications of that are quite well documented. Basically this means that you can only do stuff that the page can.
content scripts in previous extensions worked differently.

There are two things a content script can do that the page scripts can’t:

So what you are observing is by design and likely won’t ever change.

Now, for a way around it:
Instead on fetching the CSS (which should always work, but may cause an additional network request) and injecting it as a <style> (which should work unless the pages CSP disallows style-src 'unasfe-inline', but probably breaks the location of the style even if you append \n/*# sourceURL=${ originalUrl } */ to it, you could also try to set the crossorigin attribute on the link (you may have to clone the link to re-evaluate it).
If the server sets the correct Access-Control-* headers, you should then be able to access the corresponding document.styleSheets entry without restrictions.


Here is some more stuff I originally thought was relevant for the answer. It isn’t really relevant, but I think it is still interesting:

There is some additional some magic happening in content scripts. This is very poorly documented – or I haven’t found the documentation yet, but I don’t think there is any. That means everything that follows is what I analyzed from the resulting behavior and may be inaccurate:

  • Much of it starts with the fact that, in content scripts, this !== window. The very last section on the MDN page about content script hints at that by saying that eval() does something different than window.eval(). But that is it in terms of documentation. So far, what I have found is this:
    • Window.prototype === this.__proto__ >>> true which would imply that this is a different Window instance
    • but someone is trying to fool us: Object.getPrototypeOf(this) === this.__proto__ >>> false which means that someone overwrote the ES6 .__proto__ getter. I have no idea why that was done and think was is a very bad idea.
    • so, what is the [[Prototype]] of this? Well, Object.getPrototypeOf(this) >> Window → https://...
    • which gives the same output as window >> Window → https://... so surely those two are the same
    • nope: Object.getPrototypeOf(this) === window >>> false -.-
    • but setting properties on window makes them appear on this (but not vice versa) which generally means that window should be in the prototype chain of this
    • so this instanceof Object.assign(function() { }, { prototype: window, }) should return true. It doesn’t.
    • in conclusion, after about an hour of typing test in the JS console of a content script, I still don’t know what the precise relation between this and window in content scripts is. In some regards, this seems to inherit from window, but that is not actually the case. The [[Prototype]] of window seems to be some kind of wired Proxy with a number of actual properties that proxies other properties to the window. I am pretty sure this is not ES6 standards compliant.
  • Why did I mention all the above? Well, I wanted to check if fetch() (i.e. this.fetch) is the same as window.fetch. As function values, they are not, in fact, there are three instances of fetch (and many other build-in functions): this.fetch, window.fetch and Object.getPrototypeOf(this).fetch are all three different, as far as the === operator is concerned.

But since all three fetch functions seem to behave the way described in the first section, this doesn’t really matter.

It looks wrong for me. Expanded principal looks more suitable for content scripts (it seems the case for Addon SDK, however, I haven’t used content scripts in Addon SDK version — it was remoteRequire’d script which, I guess, has system principal).

If the server sets the correct Access-Control-* headers

Yep, that’s a problem. Only if server does. But server won’t because it knows nothing about my add-on. May be I can try to intercept HTTP requests and insert appropriate CORS headers. But it’s not clear what will happen with non-http(s) styles. And, by the way, it will introduce great security hole: any webpage literally would be able to GET any content which looks like CSS for my extension no matter which origin it has.

So, my point is that WebExtension’s "permissions" should affect not only fetch and XMLHTTPRequest but also document.styleSheets[…] and probably should result in avoiding tainting canvases but I don’t need it for my add-on, It just makes sense too.

Personally I agree that there should be at least an option to run the content scripts that way, possibly requiring admin reviews on AMO.
But at least at this point, this is the behavior desired by Mozilla, and I don’t think they will change their position on it.

1 Like

Is it, or was there just nobody to ask for a change?

In case of the latter, I’d suggest to open a Bugzilla issue.

I’ve asked and there is an issue — bug 1393022. But it is about add-ons, not about Developer Console. I guess when it is implemented, it will be possible to easily do the same for Developer Console.

1 Like

Thanks, this was exactly what I’m looking for.

I came here to say I encountered the same bug too. I find it odd that contentScripts could read iframe’s contents from different origins yet can’t read styleSheets from different origins.

1 Like

There’s actually a way on Firefox, but on on Chromium.
Just commented on the bug here.