Support injecting scriptlet in MAIN or ISOLATED world

This reflects the _world_ of the MV3 scripting API:
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld

MAIN: page's world
ISOLATED: extension's content script world

Some scriptlets are best executed in either world, so this
commit allows to pick in which world a scriptlet should execute
(default to MAIN).

For instance, the new sed.js scriptlet will now execute in
the ISOLATED world.
This commit is contained in:
Raymond Hill 2023-05-22 20:19:00 -04:00
parent 9f465f503a
commit 1a863a877d
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
5 changed files with 140 additions and 79 deletions

View File

@ -2218,6 +2218,7 @@ builtinScriptlets.push({
name: 'sed.js', name: 'sed.js',
requiresTrust: true, requiresTrust: true,
fn: sed, fn: sed,
world: 'ISOLATED',
dependencies: [ dependencies: [
'pattern-to-regex.fn', 'pattern-to-regex.fn',
'run-at.fn', 'run-at.fn',
@ -2229,7 +2230,6 @@ function sed(
pattern = '', pattern = '',
replacement = '' replacement = ''
) { ) {
if ( document.documentElement === null ) { return; }
const reNodeName = patternToRegex(nodeName, 'i'); const reNodeName = patternToRegex(nodeName, 'i');
const rePattern = patternToRegex(pattern, 'gms'); const rePattern = patternToRegex(pattern, 'gms');
const extraArgs = new Map( const extraArgs = new Map(
@ -2274,22 +2274,18 @@ function sed(
} }
}; };
const observer = new MutationObserver(handleMutations); const observer = new MutationObserver(handleMutations);
observer.observe(document.documentElement, { childList: true, subtree: true }); observer.observe(document, { childList: true, subtree: true });
{ if ( document.documentElement ) {
const treeWalker = document.createTreeWalker( const treeWalker = document.createTreeWalker(
document.documentElement, document.documentElement,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
); );
const currentScriptNode = document.currentScript;
const currentTextNode = currentScriptNode.firstChild;
let count = 0; let count = 0;
for (;;) { for (;;) {
const node = treeWalker.nextNode(); const node = treeWalker.nextNode();
count += 1; count += 1;
if ( node === null ) { break; } if ( node === null ) { break; }
if ( reNodeName.test(node.nodeName) === false ) { continue; } if ( reNodeName.test(node.nodeName) === false ) { continue; }
if ( node === currentScriptNode ) { continue; }
if ( node === currentTextNode ) { continue; }
if ( handleNode(node) ) { continue; } if ( handleNode(node) ) { continue; }
stop(); break; stop(); break;
} }

View File

@ -315,7 +315,7 @@ vAPI.scriptletsInjector = ((doc, details) => {
script = doc.createElement('script'); script = doc.createElement('script');
script.async = false; script.async = false;
script.src = url; script.src = url;
(doc.head || doc.documentElement).appendChild(script); doc.append(script);
self.uBO_scriptletsInjected = details.filters; self.uBO_scriptletsInjected = details.filters;
} catch (ex) { } catch (ex) {
} }

View File

@ -1324,9 +1324,9 @@ vAPI.DOMFilterer = class {
// https://github.com/gorhill/uBlock/blob/master/assets/ublock/resources.txt // https://github.com/gorhill/uBlock/blob/master/assets/ublock/resources.txt
if ( scriptletDetails && typeof self.uBO_scriptletsInjected !== 'string' ) { if ( scriptletDetails && typeof self.uBO_scriptletsInjected !== 'string' ) {
self.uBO_scriptletsInjected = scriptletDetails.filters; self.uBO_scriptletsInjected = scriptletDetails.filters;
if ( scriptletDetails.scriptlets ) { if ( scriptletDetails.mainWorld ) {
vAPI.injectScriptlet(document, scriptletDetails.scriptlets); vAPI.injectScriptlet(document, scriptletDetails.mainWorld);
vAPI.injectedScripts = scriptletDetails.scriptlets; vAPI.injectedScripts = scriptletDetails.mainWorld;
} }
} }

View File

@ -87,6 +87,7 @@ class RedirectEntry {
this.warURL = undefined; this.warURL = undefined;
this.params = undefined; this.params = undefined;
this.requiresTrust = false; this.requiresTrust = false;
this.world = 'MAIN';
this.dependencies = []; this.dependencies = [];
} }
@ -157,6 +158,7 @@ class RedirectEntry {
r.requiresTrust = details.requiresTrust === true; r.requiresTrust = details.requiresTrust === true;
r.warURL = details.warURL !== undefined && details.warURL || undefined; r.warURL = details.warURL !== undefined && details.warURL || undefined;
r.params = details.params !== undefined && details.params || undefined; r.params = details.params !== undefined && details.params || undefined;
r.world = details.world || 'MAIN';
if ( Array.isArray(details.dependencies) ) { if ( Array.isArray(details.dependencies) ) {
r.dependencies.push(...details.dependencies); r.dependencies.push(...details.dependencies);
} }
@ -227,6 +229,7 @@ class RedirectEngine {
if ( entry.mime.startsWith(mime) === false ) { return; } if ( entry.mime.startsWith(mime) === false ) { return; }
return { return {
js: entry.toContent(), js: entry.toContent(),
world: entry.world,
dependencies: entry.dependencies.slice(), dependencies: entry.dependencies.slice(),
}; };
} }
@ -320,6 +323,7 @@ class RedirectEngine {
data: fn.toString(), data: fn.toString(),
dependencies: scriptlet.dependencies, dependencies: scriptlet.dependencies,
requiresTrust: scriptlet.requiresTrust === true, requiresTrust: scriptlet.requiresTrust === true,
world: scriptlet.world || 'MAIN',
}); });
this.resources.set(name, entry); this.resources.set(name, entry);
if ( Array.isArray(aliases) === false ) { continue; } if ( Array.isArray(aliases) === false ) { continue; }

View File

@ -82,7 +82,7 @@ const scriptletFilteringEngine = {
// Consequently, the programmatic-injection code path is taken only with // Consequently, the programmatic-injection code path is taken only with
// Chromium-based browsers. // Chromium-based browsers.
const contentscriptCode = (( ) => { const mainWorldInjector = (( ) => {
const parts = [ const parts = [
'(', '(',
function(injector, details) { function(injector, details) {
@ -95,7 +95,7 @@ const contentscriptCode = (( ) => {
return; return;
} }
injector(doc, details); injector(doc, details);
if ( typeof self.uBO_scriptletsInjected === 'string' ) { return 0; } return 0;
}.toString(), }.toString(),
')(', ')(',
vAPI.scriptletsInjector, ', ', vAPI.scriptletsInjector, ', ',
@ -112,7 +112,39 @@ const contentscriptCode = (( ) => {
filters, filters,
}); });
return this.parts.join(''); return this.parts.join('');
} },
};
})();
const isolatedWorldInjector = (( ) => {
const parts = [
'(',
function(details) {
const doc = document;
if (
doc.location === null ||
details.hostname !== doc.location.hostname ||
self.uBO_isolatedScriptlets === 'done'
) {
return;
}
const isolatedScriptlets = function(){};
isolatedScriptlets();
self.uBO_isolatedScriptlets = 'done';
return 0;
}.toString(),
')(',
'json-slot',
');',
];
return {
parts,
jsonSlot: parts.indexOf('json-slot'),
scriptletSlot: parts.indexOf('scriptlet-slot'),
assemble: function(hostname, scriptlets) {
this.parts[this.jsonSlot] = JSON.stringify({ hostname });
return this.parts.join('').replace('function(){}', scriptlets);
},
}; };
})(); })();
@ -147,8 +179,8 @@ const normalizeRawFilter = function(parser, sourceIsTrusted = false) {
return `+js(${args.join(', ')})`; return `+js(${args.join(', ')})`;
}; };
const lookupScriptlet = function(rawToken, scriptletMap, dependencyMap) { const lookupScriptlet = function(rawToken, mainMap, isolatedMap) {
if ( scriptletMap.has(rawToken) ) { return; } if ( mainMap.has(rawToken) || isolatedMap.has(rawToken) ) { return; }
const pos = rawToken.indexOf(','); const pos = rawToken.indexOf(',');
let token, args = ''; let token, args = '';
if ( pos === -1 ) { if ( pos === -1 ) {
@ -157,8 +189,6 @@ const lookupScriptlet = function(rawToken, scriptletMap, dependencyMap) {
token = rawToken.slice(0, pos).trim(); token = rawToken.slice(0, pos).trim();
args = rawToken.slice(pos + 1).trim(); args = rawToken.slice(pos + 1).trim();
} }
// TODO: The alias lookup can be removed once scriptlet resources
// with obsolete name are converted to their new name.
if ( reng.aliases.has(token) ) { if ( reng.aliases.has(token) ) {
token = reng.aliases.get(token); token = reng.aliases.get(token);
} else { } else {
@ -166,18 +196,19 @@ const lookupScriptlet = function(rawToken, scriptletMap, dependencyMap) {
} }
const details = reng.contentFromName(token, 'text/javascript'); const details = reng.contentFromName(token, 'text/javascript');
if ( details === undefined ) { return; } if ( details === undefined ) { return; }
const targetWorldMap = details.world !== 'ISOLATED' ? mainMap : isolatedMap;
const content = patchScriptlet(details.js, args); const content = patchScriptlet(details.js, args);
const dependencies = details.dependencies || []; const dependencies = details.dependencies || [];
while ( dependencies.length !== 0 ) { while ( dependencies.length !== 0 ) {
const token = dependencies.shift(); const token = dependencies.shift();
if ( dependencyMap.has(token) ) { continue; } if ( targetWorldMap.has(token) ) { continue; }
const details = reng.contentFromName(token, 'fn/javascript'); const details = reng.contentFromName(token, 'fn/javascript');
if ( details === undefined ) { continue; } if ( details === undefined ) { continue; }
dependencyMap.set(token, details.js); targetWorldMap.set(token, details.js);
if ( Array.isArray(details.dependencies) === false ) { continue; } if ( Array.isArray(details.dependencies) === false ) { continue; }
dependencies.push(...details.dependencies); dependencies.push(...details.dependencies);
} }
scriptletMap.set(rawToken, [ targetWorldMap.set(rawToken, [
'try {', 'try {',
'// >>>> scriptlet start', '// >>>> scriptlet start',
content, content,
@ -314,24 +345,14 @@ scriptletFilteringEngine.fromCompiledContent = function(reader) {
const $scriptlets = new Set(); const $scriptlets = new Set();
const $exceptions = new Set(); const $exceptions = new Set();
const $scriptletMap = new Map(); const $mainWorldMap = new Map();
const $scriptletDependencyMap = new Map(); const $isolatedWorldMap = new Map();
scriptletFilteringEngine.retrieve = function(request) { scriptletFilteringEngine.retrieve = function(request) {
if ( scriptletDB.size === 0 ) { return; } if ( scriptletDB.size === 0 ) { return; }
const hostname = request.hostname; const hostname = request.hostname;
$scriptlets.clear();
$exceptions.clear();
scriptletDB.retrieve(hostname, [ $scriptlets, $exceptions ]);
const entity = request.entity !== ''
? `${hostname.slice(0, -request.domain.length)}${request.entity}`
: '*';
scriptletDB.retrieve(entity, [ $scriptlets, $exceptions ], 1);
if ( $scriptlets.size === 0 ) { return; }
// https://github.com/gorhill/uBlock/issues/2835 // https://github.com/gorhill/uBlock/issues/2835
// Do not inject scriptlets if the site is under an `allow` rule. // Do not inject scriptlets if the site is under an `allow` rule.
if ( if (
@ -341,22 +362,31 @@ scriptletFilteringEngine.retrieve = function(request) {
return; return;
} }
// Wholly disable scriptlet injection?
if ( $exceptions.has('') ) {
return {
filters: [
{ tabId: request.tabId, url: request.url, filter: '#@#+js()' }
]
};
}
if ( scriptletCache.resetTime < reng.modifyTime ) { if ( scriptletCache.resetTime < reng.modifyTime ) {
scriptletCache.reset(); scriptletCache.reset();
} }
let cacheDetails = scriptletCache.lookup(hostname); let cacheDetails = scriptletCache.lookup(hostname);
if ( cacheDetails === undefined ) { if ( cacheDetails === undefined ) {
const fullCode = []; $scriptlets.clear();
$exceptions.clear();
scriptletDB.retrieve(hostname, [ $scriptlets, $exceptions ]);
const entity = request.entity !== ''
? `${hostname.slice(0, -request.domain.length)}${request.entity}`
: '*';
scriptletDB.retrieve(entity, [ $scriptlets, $exceptions ], 1);
if ( $scriptlets.size === 0 ) { return; }
// Wholly disable scriptlet injection?
if ( $exceptions.has('') ) {
return {
filters: [
{ tabId: request.tabId, url: request.url, filter: '#@#+js()' }
]
};
}
for ( const token of $exceptions ) { for ( const token of $exceptions ) {
if ( $scriptlets.has(token) ) { if ( $scriptlets.has(token) ) {
$scriptlets.delete(token); $scriptlets.delete(token);
@ -365,27 +395,30 @@ scriptletFilteringEngine.retrieve = function(request) {
} }
} }
for ( const token of $scriptlets ) { for ( const token of $scriptlets ) {
lookupScriptlet(token, $scriptletMap, $scriptletDependencyMap); lookupScriptlet(token, $mainWorldMap, $isolatedWorldMap);
} }
for ( const token of $scriptlets ) { const mainWorldCode = [];
fullCode.push($scriptletMap.get(token)); for ( const js of $mainWorldMap.values() ) {
mainWorldCode.push(js);
} }
for ( const code of $scriptletDependencyMap.values() ) { const isolatedWorldCode = [];
fullCode.push(code); for ( const js of $isolatedWorldMap.values() ) {
isolatedWorldCode.push(js);
} }
cacheDetails = { cacheDetails = {
code: fullCode.join('\n\n'), mainWorld: mainWorldCode.join('\n\n'),
isolatedWorld: isolatedWorldCode.join('\n\n'),
filters: [ filters: [
...Array.from($scriptlets).map(s => `##+js(${s})`), ...Array.from($scriptlets).map(s => `##+js(${s})`),
...Array.from($exceptions).map(s => `#@#+js(${s})`), ...Array.from($exceptions).map(s => `#@#+js(${s})`),
].join('\n'), ].join('\n'),
}; };
scriptletCache.add(hostname, cacheDetails); scriptletCache.add(hostname, cacheDetails);
$scriptletMap.clear(); $mainWorldMap.clear();
$scriptletDependencyMap.clear(); $isolatedWorldMap.clear();
} }
if ( cacheDetails.code === '' ) { if ( cacheDetails.mainWorld === '' && cacheDetails.isolatedWorld === '' ) {
return { filters: cacheDetails.filters }; return { filters: cacheDetails.filters };
} }
@ -398,22 +431,37 @@ scriptletFilteringEngine.retrieve = function(request) {
scriptletGlobals.push([ 'canDebug', true ]); scriptletGlobals.push([ 'canDebug', true ]);
} }
const out = [ return {
'(function() {', mainWorld: cacheDetails.mainWorld === '' ? '' : [
'// >>>> start of private namespace', '(function() {',
'', '// >>>> start of private namespace',
µb.hiddenSettings.debugScriptlets ? 'debugger;' : ';', '',
'', µb.hiddenSettings.debugScriptlets ? 'debugger;' : ';',
// For use by scriptlets to share local data among themselves '',
`const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`, // For use by scriptlets to share local data among themselves
'', `const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`,
cacheDetails.code, '',
'', cacheDetails.mainWorld,
'// <<<< end of private namespace', '',
'})();', '// <<<< end of private namespace',
]; '})();',
].join('\n'),
return { scriptlets: out.join('\n'), filters: cacheDetails.filters }; isolatedWorld: cacheDetails.isolatedWorld === '' ? '' : [
'function() {',
'// >>>> start of private namespace',
'',
µb.hiddenSettings.debugScriptlets ? 'debugger;' : ';',
'',
// For use by scriptlets to share local data among themselves
`const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`,
'',
cacheDetails.isolatedWorld,
'',
'// <<<< end of private namespace',
'}',
].join('\n'),
filters: cacheDetails.filters,
};
}; };
scriptletFilteringEngine.injectNow = function(details) { scriptletFilteringEngine.injectNow = function(details) {
@ -430,18 +478,31 @@ scriptletFilteringEngine.injectNow = function(details) {
request.entity = entityFromDomain(request.domain); request.entity = entityFromDomain(request.domain);
const scriptletDetails = this.retrieve(request); const scriptletDetails = this.retrieve(request);
if ( scriptletDetails === undefined ) { return; } if ( scriptletDetails === undefined ) { return; }
const { scriptlets = '', filters } = scriptletDetails; const { mainWorld = '', isolatedWorld = '', filters } = scriptletDetails;
if ( scriptlets === '' ) { return scriptletDetails; } if ( mainWorld !== '' ) {
let code = contentscriptCode.assemble(request.hostname, scriptlets, filters); let code = mainWorldInjector.assemble(request.hostname, mainWorld, filters);
if ( µb.hiddenSettings.debugScriptletInjector ) { if ( µb.hiddenSettings.debugScriptletInjector ) {
code = 'debugger;\n' + code; code = 'debugger;\n' + code;
}
vAPI.tabs.executeScript(details.tabId, {
code,
frameId: details.frameId,
matchAboutBlank: true,
runAt: 'document_start',
});
}
if ( isolatedWorld !== '' ) {
let code = isolatedWorldInjector.assemble(request.hostname, isolatedWorld);
if ( µb.hiddenSettings.debugScriptletInjector ) {
code = 'debugger;\n' + code;
}
vAPI.tabs.executeScript(details.tabId, {
code,
frameId: details.frameId,
matchAboutBlank: true,
runAt: 'document_start',
});
} }
vAPI.tabs.executeScript(details.tabId, {
code,
frameId: details.frameId,
matchAboutBlank: true,
runAt: 'document_start',
});
return scriptletDetails; return scriptletDetails;
}; };