0

I'm working on rewriting a Chrome extension to manifest v3 (damn you, Google monopoly!)

It's targeted towards a certain website, adding a bunch of cool features. This means I have to inject script that can access the page context. The website uses PageSpeed Insights (presumably an older version because I doubt they use eval nowadays), which fetches stringified code and evaluates it as such:

<script src="https://cdn.tanktrouble.com/RELEASE-2021-07-06-01/js/red_infiltration.js+messages.js.pagespeed.jc.vNQfCE2Wzx.js"></script>
<!-- ^ Contains the variables `mod_pagespeed_nEcHBi$9_H = 'function foo(){...}'` and `mod_pagespeed_gxZ81E5yX8 = 'function bar(){...}'` and the scripts below evaluate them -->
<script>eval(mod_pagespeed_nEcHBi$9_H);</script>
<script>eval(mod_pagespeed_gxZ81E5yX8);</script>

Now, I've come up with a method where I override the native eval function, take the string that it being evaluated, I generate a hash and compare it with some stored hashes. If they match, I stop the script from being evaluated and inject mine instead. Works good in theory, and acts as a failsafe if the site updates any code.

The problem lies in the fact that a good 50% of the time, the evaluation happens before I override eval, and it seems to relate to cache. If I do a hard reset (Ctrl+Shift+R) it works most of the time and injects my scripts instead as intended. However, a normal reload/site load, and it won't work.

manifest.json

    ...
    "content_scripts": [{
            "run_at": "document_start",
            "js": ["js/content.js"],
            "matches": [ "*://*.tanktrouble.com/*" ]
        }
    ], ...

content.js (content script)

class GameLoader {
    constructor() {
        // Preload scripts, why not?
        this.hasherScript = Object.assign(document.createElement('script'), {
            'src': chrome.runtime.getURL('js/hasher.js'),
            'type': 'module' // So we can import some utils and data later on
        });
        // These custom elements are a hacky method to get my chrome runtime URL into the site with element datasets.
        this.extensionURL = document.createElement('tanktroubleaddons-url');
        this.extensionURL.dataset.url = chrome.runtime.getURL('');
    }

    observe() {
        return new Promise(resolve => {
            const observer = new MutationObserver((mutations, observer) => {
                for (const mutation of mutations) {
                    for (const node of mutation.addedNodes) {
                        if (node.tagName === 'HEAD') {
                            node.insertBefore(this.extensionURL, node.firstChild); // extensionURL is used in hasherScript.js.
                            node.insertBefore(this.hasherScript, node.firstChild); // Inject the hash module

                            resolve(), observer.disconnect();
                        }
                    }
                }
            })
            .observe(document.documentElement, { childList: true, subtree: true });
        });
    }
}

const loader = new GameLoader();
loader.observe();

hasher.js (this.hasherScript)

import ScriptHashes from '/config/ScriptHashes.js'; // The script hashes previously mentioned. The format is { '7pqp95akl2s': 'game/gamemanager.js' ... }
import Hasher from '/js/utils/HashAlgorithm.js'; // Quick hash algorithm

/**
Here we implement the aforementioned hacky method for the extension URL.
 */
const nodeData = document.querySelector('tanktroubleaddons-url'),
extensionURL = nodeData.dataset.url;

window.t_url = function(url) {
    return extensionURL + url;
}

// Change the native eval function and generate a hash of the script being evaluated.
const proxied = eval;
const hashLength = ScriptHashes.length;
window.eval = function(code) {
    if (typeof code === 'string') {
        const codeHash = Hasher(code),
        match = ScriptHashes.hashes[codeHash]; // Check the newly generated hash against my list of hashes. If match, it should return a path to use be used in script fetching.

        if (match) {
            // Synchronous script fetching with jQuery. When done, return null so original script won't be evaluated.
            $.getScript(t_url('js/injects/' + match), () => {
                return null;
            });
        }
    }
    return proxied.apply(this, arguments);
}

Weird behaviour

I have noticed weird behaviour when changing hasherScript to a regular script rather than a module. If I exclude the type paramter and paste the imports directly into hasher.js, things load and work perfectly. Scripts get evaluated every time et cetera. It makes me wonder if it's a synchronous/asynchronous issue. I haven't been able to find anything on this, unfortunately.

Thanks in advance! :)

Commander
  • 13
  • 1
  • 5
  • It has to do with your DOM state not being loaded completely and order of operation is still taking lead with the site. This can happen often enough. I'd try delaying the start of the injection or awaiting page-load 100%. – BGPHiJACK Jan 03 '22 at 13:35
  • Generally what I do however for ease and complete domination of the domain is on pre-load delete all the HTML/CSS/JS and insert my own so it stays static and up to my injection. – BGPHiJACK Jan 03 '22 at 13:36
  • @BGPHiJACK excuse my ignorance, but how would I go about this? I'm not a fan of clearing site contents, as I'm not overriding everything and it'd be a whole mess of its own. I've tried doing window.onload and document DOMContentLoaded to loader.observe(), and I've also tried wrapping the node.insertBefore's inside the MutationObserver with onload and DOMContentLoaded – Commander Jan 03 '22 at 13:48
  • That's perfect, all the right tools. I've given a small example that would be a good staple for you. If you need to, you can privately scope your functions by wrapping them. (function () {/*code*/})(); – BGPHiJACK Jan 03 '22 at 14:14
  • MV3 is inherently unreliable when overriding [page context](/a/9517879) code at document_start because it supports only web_accessible_resources script, which is loaded asynchronously, so it'll be late randomly. You'll have to use MV2 until `world` parameter is implemented in https://crbug.com/1054624 or https://crbug.com/1207006. – wOxxOm Jan 03 '22 at 14:18
  • @wOxxOm I really wasn't hoping that was the case, damn shame it is. It explains why my MV2 extension, which utilizes basically the same method, but instead injects the eval override via script innerHTML, works. I hope to see the `world` paramter implemented before January 2023 or things are gonna suck for a lot of people Do you know any reason why it's only when loading my hasher script as a module, that this issue occurs? – Commander Jan 03 '22 at 14:28
  • When using `src` it's inherently random so I guess the module thing is a coincidence. – wOxxOm Jan 03 '22 at 14:30

1 Answers1

0

I'd try waiting for the load state of DOM, if that's not enough and there's scripts rendering the page further you can wait for a specific element/attribute before full insertion but this may not be enough so added CheckState.

When you insert it the way you do it's mostly done before DOM has fully loaded so perhaps your issue.

/*
The DOM state should be loaded before injecting code, however this depends on the site. Calling this makes sure that all HTML/JavaScript/CSS is loaded. 
*/
window.onload = async() => {
  console.log("HTML/JavaScript/CSS are considered to be loaded now");
  console.log("Page may be running scripts, wait for finish!");
  CheckState();
  // Above is optional or may be needed. 
};
let fullyloaded;

function CheckState() {
  console.log("checksite");
  fullyloaded = setTimeout(() => {
    /*
    Sometimes the site is loaded 100% but nothing is on the page, possibly loading the rest of the site through script. In this case you can run a setTimeout
    */
    if (document.querySelector('.SelectionThatTellsUsPageIsLoadedProperly')) {
      InjectCode();
    } else {
      CheckState();
    }

  }, 10000);
}

function InjectCode() {
  console.log("We're loaded, let's insert the code now no issues");
}
BGPHiJACK
  • 1,277
  • 1
  • 8
  • 16
  • Just an example but used often with Script Injection. You will need to determine if you need to load the script before, during or after load. If it's before, the route is a bit different; would always inject after load for safest results (ETags/etc could exist). This script would be inserted before load but ran when loaded if that makes sense. – BGPHiJACK Jan 03 '22 at 14:11
  • Thanks for the answer! Though, I'm afraid you might've misunderstood. I have to inject and run my module script before any site scripts load. If I needed to run my script when everything has loaded, I would set the manifest to use document_idle instead of document_start. I'm prepending my module before any other scripts and expect it to run as such that it affects the scripts site after it. – Commander Jan 03 '22 at 14:21
  • Yup correct, so this could be used for both actually. Cause you are inserting on document_start the script would load initially and optionally if any code-snippet had to wait it could be inserted within the blocks above. So anything you want initially loading as well wouldn't make it inside the onload function but outside of it. :) – BGPHiJACK Jan 03 '22 at 14:24
  • Ah, like that! That makes a lot more sense. Thanks! – Commander Jan 03 '22 at 14:32