Greasemonkey: isolates scripts' writes to window

Since the global namespace of javascript in the browser is accessible
there where issues with scripts clobbering things that the page expected
to be attributes of window pages clobbering things that userscripts
saved to `window`. The latter was occuring with OneeChan. OneeChan was
setting `window.$` to a function that took a css selector and the 4chan
catalog script was setting `window.$` to some object. Since OneeChan was
set to run at document-start this broke OneeChan, switching it to
document-end broke scripts on 4chan.

I used OneeChan and 4chan-X on 4chan as the test case for this and
TamperMonkey as a guide for what is the correct way to handle scoping. I
didn't manage to pick apart just how TamperMonkey does what it does (I
think it might just be the environment that Chrome provides extensions
actually) but I got close to the same behaviour.

TamperMonkey provides a `window` object that appears to be what the
global window looked like before the webpage modified it. The global
scope though does have the pages modifications accessible. If the script
assigns something to an attribute `window` it can see that attribute in
the global scope. This implementation differs from that one in that, to
the scipt, `window` and the global scope always look the same, and that
is the same as the global scope looks in the environment provided by
TamperMonkey.

I am using the ES6 `Proxy` feature to allow the `window` object to look
like the actual (unsafe) one while allowing writing to it that doesn't
clobber the unsafe one. I am then using the ES4 `with` function to make
attributes of that window (proxy) object visible in the scope chain.
There may be other ways to do this without using `with` by using nested
functions and setting `this` creatively. There are notes around
alleging `with` to be various states of uncool[1].

I also ran into an issue where a userscript calling
`window.addEventListener(...)` would fail with `TypeError: Illegal
Execution` which is apparently due to `this` not being set correctly. I
looked at the functions which threw that error and those that didn't and
am using whether they have a `prototype` attribute or not to tell
whether I need to bind them with `window` as `this`. I am not sure how
correct that is but it worked for all the cases I ran into.

[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/with
This commit is contained in:
Jimmy 2018-03-31 20:51:30 +13:00
parent 046c0a7ea2
commit ab50ad735b

View File

@ -145,9 +145,53 @@
}
};
/* TamperMonkey allows assinging to `window` to change the visible global
* scope, without breaking other scripts. Eg if the page has set
* window.$ for an object for some reason (hello 4chan)
* - typeof $ === 'object'
* - typeof window.$ == 'undefined'
* - window.$ = function() {}
* - typeof $ === 'function'
* - typeof window.$ == 'function'
* Just shadowing `window` won't work because if you try to use '$'
* from the global scope you will still get the pages one.
* Additionally the userscript expects `window` to actually look
* like a fully featured browser window object.
*
* So let's try to use a Proxy on window and the possibly deprecated
* `with` function to make that proxy shadow the global scope.
* unsafeWindow should still be the actual global page window.
*/
const unsafeWindow = window;
let myWindow = {};
var windowProxyHandler = {
get: function(obj, prop) {
if (prop in myWindow)
return myWindow[prop];
if (prop in obj) {
if (typeof obj[prop] === 'function' && typeof obj[prop].prototype == 'undefined')
// Getting TypeError: Illegal Execution when callers try to execute
// eg addEventListener from here because they were returned
// unbound
return obj[prop].bind(obj);
return obj[prop];
}
},
set: function(target, prop, val) {
return myWindow[prop] = val;
}
};
var myProxy = new Proxy(unsafeWindow, windowProxyHandler);
// ====== The actual user script source ====== //
with (myProxy) {
// can't assign window directly in with() scope because proxy doesn't
// allow assinging to things that a readonly on the target.
function blarg() { // why can't this be anonymous?
var window = myProxy;
{{ scriptSource }}
};
blarg();
};
// ====== End User Script ====== //
})();