A Weird Imagination

Monkey patching async functions in user scripts

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, Promises 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 Promises 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 Promises 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.