Can a site invoke a browser extension?
C

1

34

I am a newbie to the browser extension development and I understand the concept of browser extensions altering the page and injecting codes into it.

Is there a way this direction can be turned around? I write an extension that provides a set of APIs, and web sites that want to use my extension can detect its presence and if it is present, the website can call my API methods like var extension = Extenion(foo, bar). Is this possible in Chrome, Firefox and Safari?

Example:

  1. Google created a new extension called BeautifierExtension. It has a set of APIs as JS objects.

  2. User goes to reddit.com. Reddit.com detects BeautifierExtension and invoke the API by calling beautifer = Beautifier();

See #2 - normally it's the extension that detects the matching sites and alter the pages. What I am interested to know is whether #2 is possible.

Cranky answered 10/5, 2012 at 3:4 Comment(0)
C
81

Since Chrome introduced externally_connectable, this is quite easy to do in Chrome. First, specify the allowed domain in your manifest.json file:

"externally_connectable": {
  "matches": ["*://*.example.com/*"]
}

Use chrome.runtime.sendMessage to send a message from the page:

chrome.runtime.sendMessage(editorExtensionId, {openUrlInEditor: url},
  function(response) {
    // ...
  });

Finally, listen in your background page with chrome.runtime.onMessageExternal:

chrome.runtime.onMessageExternal.addListener(
  function(request, sender, sendResponse) {
    // verify `sender.url`, read `request` object, reply with `sednResponse(...)`...
  });

If you don't have access to externally_connectable support, the original answer follows:

I'll answer from a Chrome-centric perspective, although the principles described here (webpage script injections, long-running background scripts, message passing) are applicable to virtually all browser extension frameworks.

From a high level, what you want to do is inject a content script into every web page, which adds an API, accessible to the web page. When the site calls the API, the API triggers the content script to do something, like sending messages to the background page and/or send a result back to the content script, via asynchronous callback.

The main difficulty here is that content scripts which are "injected" into a web page cannot directly alter the JavaScript execution environment of a page. They share the DOM, so events and changes to DOM structure are shared between the content script and the web page, but functions and variables are not shared. Examples:

  • DOM manipulation: If a content script adds a <div> element to a page, that will work as expected. Both content script and page will see the new <div>.

  • Events: If a content script sets up an event listener, e.g., for clicks on an element, the listener will successfully fire when the event occurs. If the page sets up a listener for custom events fired from the content script, they will be successfully received when the content script fires those events.

  • Functions: If the content script defines a new global function foo() (as you might try when setting up a new API). The page cannot see or execute foo, because foo exists only in the content script's execution environment, not in the page's environment.

So, how can you set up a proper API? The answer comes in many steps:

  1. At a low-level, make your API event-based. The web page fires custom DOM events with dispatchEvent, and the content scripts listens for them with addEventListener, taking action when they are received. Here's a simple event-based storage API which a web page can use to have the extension to store data for it:

    content_script.js (in your extension):

    // an object used to store things passed in from the API
    internalStorage = {};
    
    // listen for myStoreEvent fired from the page with key/value pair data
    document.addEventListener('myStoreEvent', function(event) {
        var dataFromPage = event.detail;
        internalStorage[dataFromPage.key] = dataFromPage.value
    });
    

    Non-extension web page, using your event-based API:

    function sendDataToExtension(key, value) {
        var dataObj = {"key":key, "value":value};
        var storeEvent = new CustomEvent('myStoreEvent', {"detail":dataObj});
        document.dispatchEvent(storeEvent);
    }
    sendDataToExtension("hello", "world");
    

    As you can see, the ordinary web page is firing events that the content script can see and react to, because they share the DOM. The events have data attached, added in the CustomEvent constructor. My example here is pitifully simple -- you can obviously do much more in your content script once it has the data from the page (most likely pass it to the background page for further processing).

  2. However, this is only half the battle. In my example above, the ordinary web page had to create sendDataToExtension itself. Creating and firing custom events is quite verbose (my code takes up 3 lines and is relatively brief). You don't want to force a site to write arcane event-firing code just to use your API. The solution is a bit of a nasty hack: append a <script> tag to your shared DOM which adds the event-firing code to the main page's execution environment.

    Inside content_script.js:

    // inject a script from the extension's files
    // into the execution environment of the main page
    var s = document.createElement('script');
    s.src = chrome.extension.getURL("myapi.js");
    document.documentElement.appendChild(s);
    

    Any functions that are defined in myapi.js will become accessible to the main page. (If you are using "manifest_version":2, you'll need to include myapi.js in your manifest's list of web_accessible_resources).

    myapi.js:

    function sendDataToExtension(key, value) {
        var dataObj = {"key":key, "value":value};
        var storeEvent = new CustomEvent('myStoreEvent', {"detail":dataObj});
        document.dispatchEvent(storeEvent);
    }
    

    Now the plain web page can simply do:

    sendDataToExtension("hello", "world");
    
  3. There is one further wrinkle to our API process: the myapi.js script will not be available exactly at load time. Instead, it will be loaded some time after page-load time. Therefore, the plain web page needs to know when it can safely call your API. You can solve this by having myapi.js fire an "API ready" event, which your page listens for.

    myapi.js:

    function sendDataToExtension(key, value) {
        // as above
    }
    
    // since this script is running, myapi.js has loaded, so let the page know
    var customAPILoaded = new CustomEvent('customAPILoaded');
    document.dispatchEvent(customAPILoaded);
    

    Plain web page using API:

    document.addEventListener('customAPILoaded', function() {
        sendDataToExtension("hello", "world");
        // all API interaction goes in here, now that the API is loaded...
    });
    
  4. Another solution to the problem of script availability at load time is setting run_at property of content script in manifest to "document_start" like this:

    manifest.json:

        "content_scripts": [
          {
            "matches": ["https://example.com/*"],
            "js": [
              "myapi.js"
            ],
            "run_at": "document_start"
          }
        ],
    

    Excerpt from docs:

    In the case of "document_start", the files are injected after any files from css, but before any other DOM is constructed or any other script is run.

    For some contentscripts that could be more appropriate and of less effort than having "API loaded" event.

  5. In order to send results back to the page, you need to provide an asynchronous callback function. There is no way to synchronously return a result from your API, because event firing/listening is inherently asynchronous (i.e., your site-side API function terminates before the content script ever gets the event with the API request).

    myapi.js:

    function getDataFromExtension(key, callback) {
        var reqId = Math.random().toString(); // unique ID for this request
        var dataObj = {"key":key, "reqId":reqId};
        var fetchEvent = new CustomEvent('myFetchEvent', {"detail":dataObj});
        document.dispatchEvent(fetchEvent);
    
        // get ready for a reply from the content script
        document.addEventListener('fetchResponse', function respListener(event) {
            var data = event.detail;
    
            // check if this response is for this request
            if(data.reqId == reqId) {
                callback(data.value);
                document.removeEventListener('fetchResponse', respListener);
            }
        }
    }
    

    content_script.js (in your extension):

    // listen for myFetchEvent fired from the page with key
    // then fire a fetchResponse event with the reply
    document.addEventListener('myStoreEvent', function(event) {
        var dataFromPage = event.detail;
        var responseData = {"value":internalStorage[dataFromPage.key], "reqId":data.reqId};
        var fetchResponse = new CustomEvent('fetchResponse', {"detail":responseData});
        document.dispatchEvent(fetchResponse);
    });
    

    ordinary web page:

    document.addEventListener('customAPILoaded', function() {
        getDataFromExtension("hello", function(val) {
            alert("extension says " + val);
        });
    });
    

    The reqId is necessary in case you have multiple requests out at once, so that they don't read the wrong responses.

And I think that's everything! So, not for the faint of heart, and possibly not worth it, when you consider that other extensions can also bind listeners to your events to eavesdrop on how a page is using your API. I only know all this because I made made a proof-of-concept cryptography API for a school project (and subsequently learned the major security pitfalls associated with it).

In sum: A content script can listen for custom events from an ordinary web page, and the script can also inject a script file with functions that makes it easier for web pages to fire those events. The content script can pass messages to a background page, which then stores, transforms, or transmits data from the message.

Crispation answered 10/5, 2012 at 5:6 Comment(9)
Thanks for the detailed explanations! +1 for pointing out execution environment.Cranky
Consider updating your answer with the CustomEvent constructor. Its syntax looks much nicier than the deprecated document.createEvent method.Gametophore
Is there any way to check the page's security details/HSTS status before injecting the script?Musset
@RobW I finally got around to it. I've never CustomEvent constructor personally, but I think I've done it correctly here.Crispation
@Musset Probably not; if the page doesn't have a valid cert it won't load at all, unless the user expressly allows it. I expect Chrome does not expose a page-level or extension-level API for checking HTTP certs. As for HSTS, maybe you can see the HTTP headers using webRequest. Anyway, I think this is a fine topic for a brand new question.Crispation
This is awesome except there is a problem. Chrome extension content script is loaded after the webpage is loaded. That means the document.addListener call is made after the webpage fires document.dispatchEvent is there a way to get around this issue?Subliminal
@Subliminal I believe that's addressed in step 3 of the answer (unless I misunderstand your problem). The page must not send any events until the content script sends a customAPILoaded event or similar. To put it another way, the very first thing the web page should do is listen for an event from the content script and not send any event until that event happens.Crispation
nice, I used the old approach because it fit me better and was easier to understand (needed only one event), but one gotcha you have to look out is that you can't pass any functions in events, as this will set the event.detail to null, https://mcmap.net/q/451049/-customevent-detail-quot-tainted-quot (I wanted to have callbackDoneFn(), instead I opted with two way events)Terina
In case someone finds this in 2020: This approach doesn't seem to work anymore (in FF). FF doesn't allow to load JS from the extension into the page anymore. I get an "Security Error: Content at xxxxxxxxx.net may not load or link to moz-extension://46a55a72-0d87-4ca7-86e6-6d2bc890970e/myapi.js."Worldshaking

© 2022 - 2024 — McMap. All rights reserved.