5

Summary: I need to find a way to accomplish with programmatic injection the same exact behaviour as using content_scripts > matches with "all_frames": true on a manifest. Why? because it is the only way I've found of injecting iframe's content in an extension page without having Cross-Origin errors.


I'm moving to optional_permissions on a Chrome extension and I'm on a dead end.

What I want:

Move this behaviour to optional_permissions in order to be able to add more hosts in the future. With the current code, by adding one new host on content_scripts > matches the extension is disabled by Chrome.

For the move, I removed content_scripts in the manifest and I added "optional_permissions": ["*://*/"],. Then, I successfully implemented a dialog asking new permissions to the user with chrome.permissions.request.

As I said before, the problem is how to inject the iframe's content in an extension page.

What I've tried:

  1. chrome.declarativeContent.RequestContentScript (mentioned here) with allFrames: true. I can only see the script running if I enter the URL directly, nothing happens when that URL is set in an iframe.
  2. chrome.tabs.onUpdated: url is undefined for an extension page. Also, the iframe url is not detected.
  3. Call chrome.tabs.executeScript with allFrames: true as soon as I load the first iframe. By doing this I get an exception Cannot access contents of the page. Extension manifest must request permission to access the respective host. and the "respective host" is chrome-extension://, which is not a valid host if you want to add it to the permissions.

I'm lost. I couldn't find a way to simulate the same behaviour as content_scripts > matches with programmatic injection.

Note: using webNavigation API is not an option since the extension is live and it has thousands of users. Because of this, I can not use the frameId property for executeScript. Thus, my only option with executeScript was to inject all frames but the chrome-extension host issue do not let me continue.


Update: I was able to accomplish what I wanted but only on an HTTP host. I used chrome.tabs.executeScript (option 3).

The question remains on how to make this work on an extension page.

IvanRF
  • 7,115
  • 5
  • 47
  • 71
  • You can't inject content scripts into pages that are within your extension, which is the error you are getting. You already have, or can have, JavaScript running in the page in the background context with elevated permissions, you don't need, and can't use, a content script. Also, `allFrames:true` in `tabs.executeScript()` means something very different than `"all_frames": true` in a *manifest.json* `content_scripts` entry. – Makyen Jun 08 '17 at 02:52
  • You can modify the default CSP and add youtube to script-src and frame-src. – wOxxOm Jun 08 '17 at 07:12
  • @wOxxOm I just tried that but I get the same `chrome-extension` host issue. If I inject the extension page content in a http domain I could bypass that error. Is there any way of using `declarativeContent` API to inject code inside dynamic `iframe`s? I wrote youtube in the example, but I have several domain permissions. – IvanRF Jun 08 '17 at 16:19
  • Thank you for the additional code. It would also be helpful to have code for the things you already tried which did not work. – Makyen Jun 08 '17 at 19:33
  • BTW: While IIFE's are often good practice, they are not as needed in extensions where the context/scope in which the code exists is completely controlled by you. Nothing gets to be in your background, content script, or extension popup other than the code you have written. There's nothing wrong with using them. Obviously, that's unless they preclude doing something you are wanting to do (e.g. provide access to data between scopes in the background context). – Makyen Jun 08 '17 at 20:02
  • @Makyen I added the test extensions in the "What I've tried" section for `declarativeContent` and `executeScript`. Regarding IIFE's, I was able to access some background functions from the console in the extension page so that's why I decided to use them. – IvanRF Jun 09 '17 at 00:46

1 Answers1

5

You cannot run content scripts in any extension page, including your own.

If you want to run code in a subframe of your extension page, then you have to use frameId. There are two ways to do this, with and without webNavigation.

I've put all code snippets in this answer together (with some buttons to invoke the individual code snippets) and shared it at https://robwu.nl/s/optional_permissions-script-subframe.zip
To try it out, download and extract the zip file, load the extension at chrome://extensions and click on the extension button to open the test page.

Request optional permissions

Since the goal is to programmatically run scripts with optional permissions, you need to request the permission. My example will use example.com. If you want to use the webNavigation API too, include its permission in the permission request too.

chrome.permissions.request({
    // permissions: ['webNavigation'], // uncomment if you want this.
    origins: ['*://*.example.com/*'],
}, function(granted) {
    alert('Permission was ' + (granted ? '' : 'not ') + 'granted!');
});

Inject script in subframe

Once you have a tab ID and frameId, injecting scripts in a specific frame is easy. Because of the tabId requirement, this method can only work for frames in tabs, not for frames in your browserAction/pageAction popup or background page!

To demonstrate that code execution succeeds, my examples below will call the next injectInFrame function once the tabId and frameId is known.

function injectInFrame(tabId, frameId) {
    chrome.tabs.executeScript(tabId, {
        frameId,
        code: 'document.body.textContent = "The document content replaced with content at " + new Date().toLocaleString();',
    });
}

If you want to run code not just in the specific frame, but all sub frames of that frame, just add allFrames: true to the chrome.tabs.executeScript call.

Option 1: Use webNavigation to find frameId

Use chrome.tabs.getCurrent to find the ID of the tab where the script runs (or chrome.tabs.query with {active:true,currentWindow:true} if you want to know the current tabId from another script (e.g. background script).

After that, use chrome.webNavigation.getAllFrames to query all frames in the tab. The primary way of identifying a frame is by the URL of the page, so you have a problem if the framed page redirects elsewhere, or if there are multiple frames with the same URL. Here is an example:

// Assuming that you already have a frame in your document,
// i.e. <iframe src="https://example.com"></iframe>
chrome.tabs.getCurrent(function(tab) {
    chrome.webNavigation.getAllFrames({
        tabId: tab.id,
    }, function(frames) {
        for (var frame of frames) {
            if (frame.url === 'https://example.com/') {
                injectInFrame(tab.id, frame.frameId);
                break;
            }
        }
    });
});

Option 2: Use helper page in the frame to find frameId

The option with webNavigation looks simple but has two main disadvantages:

  • It requires the webNavigation permission (causing the "Read your browsing history" permission warning)
  • The identification of the frame can fail if there are multiple frames with the same URL.

An alternative is to first open an extension page that sends an extension message, and find the frameId (and tab ID) in the metadata that is made available in the second parameter of the chrome.runtime.onMessage listener. This code is more complicated than the other option, but it is more reliable and does not require any additional permissions.

framehelper.html

<script src="framehelper.js"></script>

framehelper.js

var parentOrigin = location.ancestorOrigins[location.ancestorOrigins.length - 1];
if (parentOrigin === location.origin) {
    // Only send a message if the frame was opened by ourselves.
    chrome.runtime.sendMessage(location.hash.slice(1));
}

Code to be run in your extension page:

chrome.runtime.onMessage.addListener(frameMessageListener);
var randomMessage = 'Random message: ' + Math.random();

var f = document.createElement('iframe');
f.src = chrome.runtime.getURL('framehelper.html') + '#' + randomMessage;
document.body.appendChild(f);

function frameMessageListener(msg, sender) {
    if (msg !== randomMessage) return;
    var tabId = sender.tab.id;
    var frameId = sender.frameId;

    chrome.runtime.onMessage.removeListener(frameMessageListener);
    // Note: This will cause the script to be run on the first load.
    // If the frame redirects elsewhere, then the injection can seemingly fail.
    f.addEventListener('load', function onload() {
        f.removeEventListener('load', onload);
        injectInFrame(tabId, frameId);
    });
    f.src = 'https://example.com';
}
Rob W
  • 341,306
  • 83
  • 791
  • 678
  • 1
    Excellent! I knew you were my only hope :) Thanks for taking the time! [I uploaded a working example here on GitHub](https://github.com/IvanRF/chrome-extension-tests/tree/master/optional_permissions-script-subframe). – IvanRF Jun 10 '17 at 16:25
  • In the manifest you don't use `"persistent": false` on `"background"`, does it change anything to use it? – IvanRF Jun 10 '17 at 16:25
  • @IvanRF It doesn't matter either way. In the demo example I just use the background page to register a click handler for the button that opens the test page. You can even remove the background page and the demo will still work. – Rob W Jun 10 '17 at 16:26
  • Oh, you're right! I missed the `frameId` property on the `sender` parameter. That was the trick! :) – IvanRF Jun 10 '17 at 16:28
  • do you know if `"run_at": "document_end"` is exactly the same as `iframe.onload`? I mean, is the code injected at the same time? – IvanRF Jun 10 '17 at 17:32
  • @IvanRF There is no guarantee for that. – Rob W Jun 10 '17 at 17:34
  • I asked in case you crossed the implementation of `document_end` in Chromium, thanks! – IvanRF Jun 10 '17 at 17:35
  • Unfortunately this solution doesn't work on Chrome 48 & 49. I saw your comment [here](https://stackoverflow.com/a/36373707/1718678) saying `frameId` was introduced for `chrome.tabs.executeScript` on Chrome 51. I continued your example and the issue happens [here](https://github.com/IvanRF/chrome-extension-tests/blob/master/optional_permissions-script-subframe/extensionpage.js#L100). You mentioned [here](https://bugs.chromium.org/p/chromium/issues/detail?id=63979#c16) to use `allFrames: true` but I'm back to the `Cannot access contents of the page..` because of the host being `chrome-extension` – IvanRF Jul 15 '17 at 17:51
  • @IvanRF Why do you care about such an old version of Chrome? There are numerous published security issues, so it is irresponsible to continue using a version of Chrome that was released over a year ago. – Rob W Jul 15 '17 at 23:17
  • I wish I wouldn't have to care, but a 5% of my users have Chrome 49.0.2623.112 (Windows XP). Do you know any way of injecting a frame with a `frameId` in that old version? or my only option is to open an `http` host an inject there with `allFrames: true`? – IvanRF Jul 16 '17 at 00:49
  • @IvanRF That sounds like the best fallback option. – Rob W Jul 16 '17 at 08:35
  • [Here](https://github.com/IvanRF/chrome-extension-tests/blob/master/optional_permissions-script-subframe/extensionpage.js#L116) I added a function to test `frameId` support on different Chrome versions. I think calling the actual method is the better way of testing support. What do you think about this function `supportsFrameId()`? feel free to modify it if you know a better way. In my case, I need to know about the `frameId` support before opening a page in order to use the `http` host fallback, that's why I wrote this function. – IvanRF Jul 16 '17 at 23:40
  • @IvanRF Looks good to me. Make sure that you cache the boolean result since the result won't change. – Rob W Jul 17 '17 at 00:06
  • Regarding `iframe.onload` on `frameMessageListener`, I'm having an issue in which a 3rd site sometimes takes almost 20s to reach the `onload` (delaying the inject too much). Some call inside the iframe is throwing a `ERR_CONNECTION_RESET` and the iframe is only injected after that error is thrown. – IvanRF Mar 23 '18 at 01:47
  • I think I could simulate a `runAt: "document_start"` injection by endlessly calling `injectInFrame` right after `f.src = 'http...'`. The first injections will fail since the iframe has an extension URL. Then, when an inject succeed I know the external iframe is loading. Finally, on the injected code I can use `DOMContentLoaded`. Do you know a better way to accomplish this? – IvanRF Mar 23 '18 at 01:47