Injecting JavaScript Variable Before Content Script

Injecting javascript variable before content script

This is going to be very tricky.

Let's look at your requirements.

Inject.js will need to have access to this variable and run it's code BEFORE any scripts on the page run.

That's not how your code currently works. Your inject.js is executed at document_end - which happens after the whole DOM tree is parsed, which means after all page scripts have run (barring asynchronous parts and async script loading).

Chrome has a solution to that - you can set your execution to document_start. Then your code will truly run before everything else, while DOM is still not parsed (so document is essentially empty). With what your code does, it should not create problems (it only relies on document.documentElement, which will exist).

Problem is, all your code has to be synchronous to still enjoy "runs before everything else" property. Chrome will pause DOM parsing as long as the synchronous part of your code runs, but then all bets are off as it merrily continues to parse (and run code from) the document.

This, for example, disqualifies chrome.storage and Messaging as access to that is necessarily asynchronous.

I need to inject a dynamic variable [on a page load]

Meaning that you cannot store this in advance in some synchronously-available storage (e.g. in localStorage or cookies of the website), which would be problematic anyway considering you don't know domains in advance.

Note, for your code in particular, this may not be that much of a factor; your "dynamic" value is in fact fixed per domain. You still don't know in advance which domain will be visited, but you can at least guarantee that on a second load it will be there.

Using my background script background.js, I need to inject a dynamic variable as a content script before injecting another file [that still needs to run before everything else on the page]

That's the tricky part. In fact, as stated, it's simply impossible. You're trying to catch, from the background, the exact moment between the navigation being committed, so that Chrome switched the page to the new domain, and the execution of your document_start script.

There is no detectable gap there, and no way to tell Chrome to wait. It's a race condition you have no hopes to resolve.

You're trying to use webNavigation.onBeforeNavigate - before even the navigation is committed. So your injectScript probably goes to the previous page even, rendering it useless. If you try some other event, e.g . onCommitted, there's still no telling as to when exactly injectScript will be treated. Likely after your script.

So, how to work around all this?

Fortunately, there is some synchronous storage that's available to the content script that you can push some information to right before the earliest of scripts executes.

Cookies.

However, using the chrome.cookies API won't help. You need to actively inject the cookie value into the request on webRequest.onHeadersReceived.

You have to have the value ready synchronously to process it with a blocking handler to onHeadersReceived, but then you can simply add one Set-Cookie header and have it immediately available in document.cookies in your inject.js.

  • background.js

    function addSeedCookie(details) {
    seed = SomethingSynchronous();
    details.responseHeaders.push({
    name: "Set-Cookie",
    value: `seed_goes_here=${seed};`
    });
    return {
    responseHeaders: details.responseHeaders
    };
    }

    chrome.webRequest.onHeadersReceived.addListener(
    addSeedCookie, {urls: ["<all_urls>"]}, [
    "blocking",
    "responseHeaders",
    // Chrome 72+ requires 'extraHeaders' to handle Set-Cookie header
    chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
    ].filter(Boolean)
    );
  • inject.js

    function getCookie(cookie) { // https://stackoverflow.com/a/19971550/934239
    return document.cookie.split(';').reduce(function(prev, c) {
    var arr = c.split('=');
    return (arr[0].trim() === cookie) ? arr[1] : prev;
    }, undefined);
    }

    var seed = getCookie("seed_goes_here");

If an asynchronous function is needed to produce the data, prepare the data before the request is sent in onBeforeRequest event, then use it in onHeadersReceived listener.

const preparedSeed = {};
chrome.webRequest.onBeforeRequest.addListener(
details => {
chrome.storage.local.get('seed', data => {
preparedSeed[details.requestId] = data.seed;
});
}, {
urls: ['<all_urls>'],
types: ['main_frame', 'sub_frame'],
});

Note: the above code is untested and only serves to illustrate the idea.

Passing a variable before injecting a content script

To pass a variable from the popup to the dynamically inserted content script, see Pass a parameter to a content script injected using chrome.tabs.executeScript().

After getting a variable in the content script, there are plenty of ways to get the variable to the script in the page.

E.g. by setting attributes on the script tag, and accessing this <script> tag using document.currentScript. Note: document.currentScript only refers to the script tag right after inserting the tag in the document. If you want to refer to the original script tag later (e.g. within a timer or an event handler), you have to save a reference to the script tag in a local variable.

Content script:

var s = document.createElement('script');
s.dataset.variable = 'some string variable';
s.dataset.not_a_string = JSON.stringify({some: 'object'});
s.src = chrome.runtime.getURL('pageSearch.js');
s.onload = function() {
this.remove();
};
(document.head||document.documentElement).appendChild(s);

pageSearch.js:

(function() {
var variable = document.currentScript.dataset.variable;
var not_a_string = JSON.parse(document.currentScript.dataset.not_a_string);
// TODO: Use variable or not_a_string.
})();

Access variables and functions defined in page context using a content script

Underlying cause:

Content scripts are executed in an "isolated world" environment.

Solution:

Inject the code into the page using DOM - that code will be able to access functions/variables of the page context ("main world") or expose functions/variables to the page context (in your case it's the state() method).

  • Note in case communication with the page script is needed:

    Use DOM CustomEvent handler. Examples: one, two, and three.

  • Note in case chrome API is needed in the page script:

    Since chrome.* APIs can't be used in the page script, you have to use them in the content script and send the results to the page script via DOM messaging (see the note above).

Safety warning:

A page may redefine or augment/hook a built-in prototype so your exposed code may fail if the page did it in an incompatible fashion. If you want to make sure your exposed code runs in a safe environment then you should either a) declare your content script with "run_at": "document_start" and use Methods 2-3 not 1, or b) extract the original native built-ins via an empty iframe, example. Note that with document_start you may need to use DOMContentLoaded event inside the exposed code to wait for DOM.

Table of contents

  • Method 1: Inject another file - ManifestV3 compatible
  • Method 2: Inject embedded code - MV2
  • Method 2b: Using a function - MV2
  • Method 3: Using an inline event - ManifestV3 compatible
  • Method 4: Using executeScript's world - ManifestV3 only
  • Method 5: Using world in manifest.json - ManifestV3 only, Chrome 111+
  • Dynamic values in the injected code

Method 1: Inject another file (ManifestV3/MV2)

Particularly good when you have lots of code. Put the code in a file within your extension, say script.js. Then load it in your content script like this:

var s = document.createElement('script');
s.src = chrome.runtime.getURL('script.js');
s.onload = function() {
this.remove();
};
(document.head || document.documentElement).appendChild(s);

The js file must be exposed in web_accessible_resources:

  • manifest.json example for ManifestV2

    "web_accessible_resources": ["script.js"],
  • manifest.json example for ManifestV3

    "web_accessible_resources": [{
    "resources": ["script.js"],
    "matches": ["<all_urls>"]
    }]

If not, the following error will appear in the console:

Denying load of chrome-extension://[EXTENSIONID]/script.js. Resources must be listed in the web_accessible_resources manifest key in order to be loaded by pages outside the extension.

Method 2: Inject embedded code (MV2)

This method is useful when you want to quickly run a small piece of code. (See also: How to disable facebook hotkeys with Chrome extension?).

var actualCode = `// Code here.
// If you want to use a variable, use $ and curly braces.
// For example, to use a fixed random number:
var someFixedRandomValue = ${ Math.random() };
// NOTE: Do not insert unsafe variables in this way, see below
// at "Dynamic values in the injected code"
`;

var script = document.createElement('script');
script.textContent = actualCode;
(document.head||document.documentElement).appendChild(script);
script.remove();

Note: template literals are only supported in Chrome 41 and above. If you want the extension to work in Chrome 40-, use:

var actualCode = ['/* Code here. Example: */' + 'alert(0);',
'// Beware! This array have to be joined',
'// using a newline. Otherwise, missing semicolons',
'// or single-line comments (//) will mess up your',
'// code ----->'].join('\n');

Method 2b: Using a function (MV2)

For a big chunk of code, quoting the string is not feasible. Instead of using an array, a function can be used, and stringified:

var actualCode = '(' + function() {
// All code is executed in a local scope.
// For example, the following does NOT overwrite the global `alert` method
var alert = null;
// To overwrite a global variable, prefix `window`:
window.alert = null;
} + ')();';
var script = document.createElement('script');
script.textContent = actualCode;
(document.head||document.documentElement).appendChild(script);
script.remove();

This method works, because the + operator on strings and a function converts all objects to a string. If you intend on using the code more than once, it's wise to create a function to avoid code repetition. An implementation might look like:

function injectScript(func) {
var actualCode = '(' + func + ')();'
...
}
injectScript(function() {
alert("Injected script");
});

Note: Since the function is serialized, the original scope, and all bound properties are lost!

var scriptToInject = function() {
console.log(typeof scriptToInject);
};
injectScript(scriptToInject);
// Console output: "undefined"

Method 3: Using an inline event (ManifestV3/MV2)

Sometimes, you want to run some code immediately, e.g. to run some code before the <head> element is created. This can be done by inserting a <script> tag with textContent (see method 2/2b).

An alternative, but not recommended is to use inline events. It is not recommended because if the page defines a Content Security policy that forbids inline scripts, then inline event listeners are blocked. Inline scripts injected by the extension, on the other hand, still run.
If you still want to use inline events, this is how:

var actualCode = '// Some code example \n' + 
'console.log(document.documentElement.outerHTML);';

document.documentElement.setAttribute('onreset', actualCode);
document.documentElement.dispatchEvent(new CustomEvent('reset'));
document.documentElement.removeAttribute('onreset');

Note: This method assumes that there are no other global event listeners that handle the reset event. If there is, you can also pick one of the other global events. Just open the JavaScript console (F12), type document.documentElement.on, and pick on of the available events.

Method 4: Using chrome.scripting API world (ManifestV3 only)

  • Chrome 95 or newer, chrome.scripting.executeScript with world: 'MAIN'
  • Chrome 102 or newer, chrome.scripting.registerContentScripts with world: 'MAIN', also allows runAt: 'document_start' to guarantee early execution of the page script.

Unlike the other methods, this one is for the background script or the popup script, not for the content script. See the documentation and examples.

Method 5: Using world in manifest.json (ManifestV3 only)

In Chrome 111 or newer you can add "world": "MAIN" to content_scripts declaration in manifest.json to override the default value which is ISOLATED. The scripts run in the listed order.

  "content_scripts": [{
"js": ["content.js"],
"matches": ["<all_urls>"],
"run_at": "document_start"
}, {
"world": "MAIN",
"js": ["page.js"],
"matches": ["<all_urls>"],
"run_at": "document_start"
}],

Dynamic values in the injected code (MV2)

Occasionally, you need to pass an arbitrary variable to the injected function. For example:

var GREETING = "Hi, I'm ";
var NAME = "Rob";
var scriptToInject = function() {
alert(GREETING + NAME);
};

To inject this code, you need to pass the variables as arguments to the anonymous function. Be sure to implement it correctly! The following will not work:

var scriptToInject = function (GREETING, NAME) { ... };
var actualCode = '(' + scriptToInject + ')(' + GREETING + ',' + NAME + ')';
// The previous will work for numbers and booleans, but not strings.
// To see why, have a look at the resulting string:
var actualCode = "(function(GREETING, NAME) {...})(Hi, I'm ,Rob)";
// ^^^^^^^^ ^^^ No string literals!

The solution is to use JSON.stringify before passing the argument. Example:

var actualCode = '(' + function(greeting, name) { ...
} + ')(' + JSON.stringify(GREETING) + ',' + JSON.stringify(NAME) + ')';

If you have many variables, it's worthwhile to use JSON.stringify once, to improve readability, as follows:

...
} + ')(' + JSON.stringify([arg1, arg2, arg3, arg4]).slice(1, -1) + ')';

Dynamic values in the injected code (ManifestV3)

  • Method 1 can set the URL of the script element in the content script:

    s.src = chrome.runtime.getURL('script.js?') + new URLSearchParams({foo: 1});

    Then script.js can read it:

    const params = new URLSearchParams(document.currentScript.src.split('?')[1]);
    console.log(params.get('foo'));
  • Method 4 executeScript has args parameter, registerContentScripts currently doesn't (hopefully it'll be added in the future).

Access global js variables from js injected by a chrome extension

Since content scripts run in an "isolated world" the JS variables of the page cannot be directly accessed from an extension, you need to run code in page's main world.

WARNING! DOM element cannot be extracted as an element so just send its innerHTML or another attribute. Only JSON-compatible data types can be extracted (string, number, boolean, null, and arrays/objects of these types), no circular references.

1. ManifestV3 in modern Chrome 95 or newer

This is the entire code in your extension popup/background script:

async function getPageVar(name, tabId) {
const [{result}] = await chrome.scripting.executeScript({
func: name => window[name],
args: [name],
target: {
tabId: tabId ??
(await chrome.tabs.query({active: true, currentWindow: true}))[0].id
},
world: 'MAIN',
});
return result;
}

Usage:

(async () => {
const v = await getPageVar('foo');
console.log(v);
})();

See also how to open correct devtools console.

2. ManifestV3 in old Chrome and ManifestV2

We'll extract the variable and send it into the content script via DOM messaging. Then the content script can relay the message to the extension script in iframe or popup/background pages.

  • ManifestV3 for Chrome 94 or older needs two separate files

    content script:

    const evtToPage = chrome.runtime.id;
    const evtFromPage = chrome.runtime.id + '-response';

    chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
    if (msg === 'getConfig') {
    // DOM messaging is synchronous so we don't need `return true` in onMessage
    addEventListener(evtFromPage, e => {
    sendResponse(JSON.parse(e.detail));
    }, {once: true});
    dispatchEvent(new Event(evtToPage));
    }
    });

    // Run the script in page context and pass event names
    const script = document.createElement('script');
    script.src = chrome.runtime.getURL('page-context.js');
    script.dataset.args = JSON.stringify({evtToPage, evtFromPage});
    document.documentElement.appendChild(script);

    page-context.js should be exposed in manifest.json's web_accessible_resources, example.

    // This script runs in page context and registers a listener.
    // Note that the page may override/hook things like addEventListener...
    (() => {
    const el = document.currentScript;
    const {evtToPage, evtFromPage} = JSON.parse(el.dataset.args);
    el.remove();
    addEventListener(evtToPage, () => {
    dispatchEvent(new CustomEvent(evtFromPage, {
    // stringifying strips nontranferable things like functions or DOM elements
    detail: JSON.stringify(window.config),
    }));
    });
    })();
  • ManifestV2 content script:

    const evtToPage = chrome.runtime.id;
    const evtFromPage = chrome.runtime.id + '-response';

    // this creates a script element with the function's code and passes event names
    const script = document.createElement('script');
    script.textContent = `(${inPageContext})("${evtToPage}", "${evtFromPage}")`;
    document.documentElement.appendChild(script);
    script.remove();

    // this function runs in page context and registers a listener
    function inPageContext(listenTo, respondWith) {
    addEventListener(listenTo, () => {
    dispatchEvent(new CustomEvent(respondWith, {
    detail: window.config,
    }));
    });
    }

    chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
    if (msg === 'getConfig') {
    // DOM messaging is synchronous so we don't need `return true` in onMessage
    addEventListener(evtFromPage, e => sendResponse(e.detail), {once: true});
    dispatchEvent(new Event(evtToPage));
    }
    });
  • usage example for extension iframe script in the same tab:

    function handler() {
    chrome.tabs.getCurrent(tab => {
    chrome.tabs.sendMessage(tab.id, 'getConfig', config => {
    console.log(config);
    // do something with config
    });
    });
    }
  • usage example for popup script or background script:

    function handler() {
    chrome.tabs.query({active: true, currentWindow: true}, tabs => {
    chrome.tabs.sendMessage(tabs[0].id, 'getConfig', config => {
    console.log(config);
    // do something with config
    });
    });
    }

So, basically:

  1. the iframe script gets its own tab id (or the popup/background script gets the active tab id) and sends a message to the content script
  2. the content script sends a DOM message to a previously inserted page script
  3. the page script listens to that DOM message and sends another DOM message back to the content script
  4. the content script sends it in a response back to the extension script.

Is there a way to run a content script after the page is loaded but before scripts are executed?

As answered by CertainPerformance in the comments, you can use a MutationObserver to observe changes to the DOM as shown here: https://stackoverflow.com/a/59518023

Pass a parameter to a content script injected using chrome.tabs.executeScript()

There's not such a thing as "pass a parameter to a file".

What you can do is to either insert a content script before executing the file, or sending a message after inserting the file. I will show an example for these distinct methods below.

Set parameters before execution of the JS file

If you want to define some variables before inserting the file, just nest chrome.tabs.executeScript calls:

chrome.tabs.executeScript(tab.id, {
code: 'var config = 1;'
}, function() {
chrome.tabs.executeScript(tab.id, {file: 'content.js'});
});

If your variable is not as simple, then I recommend to use JSON.stringify to turn an object in a string:

var config = {somebigobject: 'complicated value'};
chrome.tabs.executeScript(tab.id, {
code: 'var config = ' + JSON.stringify(config)
}, function() {
chrome.tabs.executeScript(tab.id, {file: 'content.js'});
});

With the previous method, the variables can be used in content.js in the following way:

// content.js
alert('Example:' + config);

Set parameters after execution of the JS file

The previous method can be used to set parameters after the JS file. Instead of defining variables directly in the global scope, you can use the message passing API to pass parameters:

chrome.tabs.executeScript(tab.id, {file: 'content.js'}, function() {
chrome.tabs.sendMessage(tab.id, 'whatever value; String, object, whatever');
});

In the content script (content.js), you can listen for these messages using the chrome.runtime.onMessage event, and handle the message:

chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
// Handle message.
// In this example, message === 'whatever value; String, object, whatever'
});


Related Topics



Leave a reply



Submit