javascriptwebpackgoogle-chrome-extensioncontent-scriptwebpack-module-federation

Access remoteEntry from Module Federation in a content script


TL;DR - Can I access Module Federation remotes from within a content script of a chrome extension?

I'm currently developing a Chrome extension and faced the problem that can be represented by the following. Let's say I have 2 applications - extension and actionsLogger.
These applications are linked via Webpack Module Federation like that:
actionsLogger/webpack.js

{
  ...,

  plugins: [
    new ModuleFederationPlugin({
      name: 'actionsLogger',
      library: { type: 'var', name: 'actionsLogger' },
      filename: 'remoteEntry.js',
      exposes: {
        './logClick': './actions/logClick',
      }
    }),
  ],

  devServer: {
    port: 3000,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization'
    },
    static: {
      directory: path.resolve(__dirname, 'dist'),
    }
  },

  ...
}

extension/webpack.js

{
  ...,
  plugins: [
    new webpack.ProvidePlugin({
      browser: 'webextension-polyfill'
    }),
    new ModuleFederationPlugin({
      name: 'extension',
      remotes: {
        actionsLogger: 'actionsLogger@http://localhost:3000/remoteEntry.js',
      },
    }),
  ],
  ...
}

So, as you can see, actionsLogger is running on port 3000 and extension is referring to it via Module Federation. actionsLogger contains a simple function to get position of a cursor in case of a click event.

actionsLogger/actions/logClick.js

function logClick(event) {
  return { X: event.clientX, Y: event.clientY };
}

export default logClick;

Other application - extension, contains all the code for the chrome extension together with this particular script that imports logClick from actionsLogger/logClick and sends the position of a cursor to the background page whenever a click happens:

extension/tracker.js

import('actionsLogger/logClick').then((module) => {
  const logClick = module.default;
  
  document.addEventListener("click", (event) => { 
    const click = logClick(event);
    chrome.runtime.sendMessage(click);
  });
});

So manifest.json in extension looks like this:

extension/manifest.json

{
  ...
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["tracker.js"]
  }],
  ...
}

And here comes the problem. If I try to open some web page with the extension installed and running, I get the following error:

Uncaught (in promise) ScriptExternalLoadError: Loading script failed.
(missing: http://localhost:3000/remoteEntry.js)
while loading "./logClick" from webpack/container/reference/actionsLogger
    at webpack/container/reference/actionsLogger (remoteEntry.js":1:1)
    at __webpack_require__ (bootstrap:18:1)
    at handleFunction (remotes loading:33:1)
    at remotes loading:52:1
    at Array.forEach (<anonymous>)
    at __webpack_require__.f.remotes (remotes loading:15:1)
    at ensure chunk:6:1
    at Array.reduce (<anonymous>)
    at __webpack_require__.e (ensure chunk:5:1)
    at iframe.js:44:1

First of all, I thought that I misconfigured something in my Module Federation settings, but then I tried the following - added inject.js:

extension/inject.js

const script = document.createElement('script');
script.src = "chrome-extension://ddgdsaidlksalmcgmphhechlkdlfocmd/tracker.js";
document.body.appendChild(script);

Modified manifest.json in extension:

extension/manifest.json

{
  ...
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["inject.js"]
  }],
  "web_accessible_resources": [
    "tracker.js",
  ]
  ...
}

And now Module Federation works fine, but since extension/tracker.js imports import('actionsLogger/logClick') from a remote, some websites that have content security policy defined might block this request (e.g. IKEA). So this approach also won't always work.

And the initial problem with accessing MF module from content script probably happens because content scripts are running in their own isolated environment and module federation can't resolve there properly. But maybe there are some configuration flags/options or some other/better ways to make it work?

Would appreciate any advice.


Solution

  • Of course, minutes after I posted the question I came up with a working solution. So, basically, I decided to use the second approach where extension/tracker.js is injected via <script /> tag on a page by extension/inject.js and for the CSP issue I've added a utility that blocks a CSP header on a request for the page.

    manifest.json

    {
      "permissions": [
         ...,
        "webRequest",
        "webRequestBlocking",
        "browsingData",
        ...
      ]
    }
    

    extension/disableCSP.js

    function disableCSP() {
      chrome.browsingData.remove({}, { serviceWorkers: true }, function () {});
    
      const onHeaderFilter = { urls: ['*://*/*'], types: ['main_frame', 'sub_frame'] };
    
      browser.webRequest.onHeadersReceived.addListener(
        onHeadersReceived, onHeaderFilter, ['blocking', 'responseHeaders']
      );
    };
    
    function onHeadersReceived(details) {
      for (let i = 0; i < details.responseHeaders.length; i++) {
        if (details.responseHeaders[i].name.toLowerCase() === 'content-security-policy') {
          details.responseHeaders[i].value = '';
        }
      }
    
      return { responseHeaders: details.responseHeaders };
    };
    
    export default disableCSP;
    

    And then just call disableCSP() on the background script.

    I'm a bit unsure about security drawbacks of such an approach, so feel free to let me know if a potential high risk vulnerability is introduced by this solution.