The problem#
I was writing a user script where I wanted to be able to
intercept the fetch()
calls the web page made so my script
could use the contents. I found a suggestion of
simply reassigning window.fetch
to my own function that internally
called the real window.fetch
while also doing whatever else I wanted.
While it worked fine under Tampermonkey on Chromium,
under Greasemonkey on Firefox, the script would just
silently fail with no indication of why the code wasn't running.
(The script I was writing was this one for fixing the formatting on the Folklife 2024 schedule to reformat the schedule to display as a grid. I plan to write a devlog post on it in the future, but just writing about the most pernicious issue in this post.)
The solution#
The problem was that Firefox's security model special-cases
function calls between web pages and extensions (and user scripts
running inside Greasemonkey count as part of an extension for this
purpose). And, furthermore, Promise
s can't pass the boundary, so you
need to carefully define functions such that the
implicit Promise
created by declaring the function async
lives on
the right side of the boundary.
The following code combines all of that together to intercept fetch()
on both Firefox and Chromium such that a userscript function intercept
is called with the text of the response to every fetch()
:
const intercept = responseText => {
// use responseText somehow ...
};
const w = window.wrappedJSObject;
if (w) {
exportFunction(intercept, window,
{ defineAs: "extIntercept" });
w.eval("window.origFetch = window.fetch");
w.eval(`window.fetch = ${async (...args) => {
let [resource, config] = args;
const response = await window.origFetch(resource,config);
window.extIntercept(await response.clone().text())
return response;
}}`);
} else {
const { fetch: origFetch } = window;
window.fetch = async (...args) => {
let [resource, config] = args;
const response = await origFetch(resource, config);
intercept(await response.clone().text());
return response;
};
}
The details#
Monkeypatching fetch()
#
The first version was to use this solution, which
is pretty much just the else
branch of the above code. The idea is
that we save the original fetch()
function to a separate variable and
implement a function that calls that function, but before returning the
response, reads it and sends a copy of the response text to our own
function.
It works in Chromium, but in Firefox, nothing happens: the function never gets called and there's no visible error message.
Trying to figure out whether the modification to window.fetch
was even
happening, I looked at the value of window.fetch
in the Javascript
console, and seemed to act like it hadn't been patched. I added an
alert()
call to it so it would be obvious if it were actually getting
called to confirm that window.fetch
really was still the original
unmodified function.
Firefox extension script security#
I copy and pasted the code for the monkey patch into the console and it
did what I expected, so I had determined something was different about
scripts running inside Greasemonkey. That gave me enough of a hint to
find this StackOverflow answer explaining that
I needed to use wrappedJSObject
and exportFunction
and pointing to
the docs on them.
Setting window.wrappedJSObject.fetch
#
My first attempt was to just replace my usages of window
with
window.wrappedJSObject
. If I assign to window.wrappedJSObject.fetch
instead of window.fetch
, then at least there's an error in the
console:
》 window.fetch
🡨 Restricted { }
and if I actually try to invoke the function, there's an exception:
》 window.fetch()
⚠ ▶ Uncaught Error: Permission denied to access object
<anonymous> debugger eval code:1
That "Restricted { }
" is due to Firefox not allowing setting
functions on window
from extensions directly.
Setting functions on window
indirectly#
Firefox provides an API exportFunction()
to
give a web page access to call a function defined in an extension (or
user script):
const ourFetch = async (...args) => { ... };
exportFunction(ourFetch, window, { defineAs: "fetch" })
Now our function is actually running (the alert()
box shows up),
but that gives us a different error:
》 window.fetch()
🡨 ▶ Restricted { }
⚠ TypeError: undefined is not a valid URL.
The TypeError
is because I didn't provide an argument, but it also
demonstrates that the real fetch()
appears to be getting called and
getting to the point that it's verifying its arguments.
We're getting "Restricted { }
" again, but note that it's not quite
the same: before it was the value of window.fetch
, but now it's the
return value of window.fetch()
. Which points us at the problem: as
this StackOverflow answer explains, it's not
the function that's "restricted", it's the Promise
that
it's returning. Specifically, the restriction is on the Promise
implicitly constructed by using the async
keyword to declare
the function: it's a user-script object, so the web page isn't allowed
to interact with it.
eval
the function definition#
The solution that StackOverflow answer suggests is
to use wrappedJSObject.eval
to create the function in the web page's
context. Then we aren't passing an extension function to the web page,
it's just always a web page function:
const interceptAsync = async response => { ... }
exportFunction(interceptAsync, window, { defineAs: "extAsync" })
const { fetch: origFetch } = window;
window.wrappedJSObject.eval(`window.fetch = ${async (...args) => {
let [resource, config] = args;
const response = await origFetch(resource, config);
await window.extAsync(response.clone())
return response;
}}`);
Catch the error? The function doesn't have access to the user script
scope, so it can't read origFetch
:
》 window.fetch()
🡨 ▼ Promise { <state>: "rejected", <reason>: ReferenceError }
<state>: "rejected"
▶ <reason>: ReferenceError: origFetch is not defined
▶ <prototype>: Promise.prototype { … }
⚠ ▶ Uncaught (in promise) ReferenceError: origFetch is not defined
fetch eval:5
But if we change that to also happen inside an eval()
(and update the
call to window.origFetch
):
window.wrappedJSObject.eval("window.origFetch = window.fetch");
Then we get a new error:
》 window.fetch()
🡨 ▼ Promise { <state>: "rejected", <reason>: Error }
<state>: "rejected"
▶ <reason>: Error: Permission denied to access property "then"
▶ <prototype>: Promise.prototype { … }
⚠ ▶ Uncaught (in promise) Error: Permission denied to access property "then"
fetch eval:7
async* debugger eval code:1
Once again, window.extAsync
is a user script Promise
that we're
trying to use inside the web page's code. One workaround I found by
accident is that the problem only occurs because we try to inspect
the Promise
, constructing it works just fine. Which means that if
we don't care about making sure it completes before returning from
fetch()
, we can just not await
it and there's no more errors as it's
apparently fine for the user script to await
on a web page Promise
.
Eliminating the final Promise
#
Generally throwing Promise
s into the ether and hoping they complete
is a bad idea, so we would rather figure out how to make the await
work. Since we can only await
Promise
s constructed on the web page
side, the fix is to remove the async
keyword from the user script side
function and use await
on the argument. This unforunately means we
need to decide which reponse
method to call and await
on in fetch
,
which is why the code at the top passes await response.clone().text()
to extIntercept()
.
Comments
Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.
There are no comments yet.