Add trusted-source support for privileged scriptlets

At the moment, the only filter lists deemed from a "trusted source"
are uBO-specific filter lists (i.e. "uBlock filters -- ..."), and
the user's own filters from "My filters".

A new scriptlet which can only be used by filter lists from trusted
sources has been introduced: `sed.js`.

The new `sed.js` scriptlet provides the ability to perform
text-level substitutions. Usage:

    example.org##+js(sed, nodeName, pattern, replacement, ...)

`nodeName`

The name of the node for which the text content must be substituted.
Valid node names can be found at:
https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeName

`pattern`

A string or regex to find in the text content of the node as the target of
substitution.

`replacement`

The replacement text. Can be omitted if the goal is to delete the text which
matches the pattern. Cannot be omitted if extra pairs of parameters have to be
used (see below).

Optionally, extra pairs of parameters to modify the behavior of the scriptlet:

`condition, pattern`

A string or regex which must be found in the text content of the node
in order for the substitution to occur.

`sedCount, n`

This will cause the scriptlet to stop after n instances of substitution. Since
a mutation oberver is used by the scriptlet, it's advised to stop it whenever
it becomes pointless. Default to zero, which means the scriptlet never stops.

`tryCount, n`

This will cause the scriptlet to stop after n instances of mutation observer
run (regardless of whether a substitution occurred). Default to zero, which
means the scriptlet never stops.

`log, 1`

This will cause the scriptlet to output information at the console, useful as
a debugging tool for filter authors. The logging ability is supported only
in the dev build of uBO.

Examples of usage:

    example.com##+js(sed, script, /devtoolsDetector\.launch\(\)\;/, , sedCount, 1)

    example.com##+js(sed, #text, /^Advertisement$/)
This commit is contained in:
Raymond Hill 2023-05-21 14:16:56 -04:00
parent 2c7d91ba57
commit 41876336db
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
4 changed files with 165 additions and 57 deletions

View File

@ -68,14 +68,13 @@ builtinScriptlets.push({
name: 'pattern-to-regex.fn',
fn: patternToRegex,
});
function patternToRegex(pattern) {
if ( pattern === '' ) {
return /^/;
function patternToRegex(pattern, flags = undefined) {
if ( pattern === '' ) { return /^/; }
const match = /^\/(.+)\/([gimsu]*)$/.exec(pattern);
if ( match !== null ) {
return new RegExp(match[1], match[2] || flags);
}
if ( pattern.startsWith('/') && pattern.endsWith('/') ) {
return new RegExp(pattern.slice(1, -1));
}
return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
}
/******************************************************************************/
@ -394,7 +393,7 @@ function abortOnStackTrace(
for ( let line of err.stack.split(/[\n\r]+/) ) {
if ( line.includes(exceptionToken) ) { continue; }
line = line.trim();
let match = safe.RegExp_exec.call(reLine, line);
const match = safe.RegExp_exec.call(reLine, line);
if ( match === null ) { continue; }
let url = match[2];
if ( url.startsWith('(') ) { url = url.slice(1); }
@ -2122,6 +2121,9 @@ function callNothrow(
builtinScriptlets.push({
name: 'spoof-css.js',
fn: spoofCSS,
dependencies: [
'safe-self.fn',
],
});
function spoofCSS(
selector,
@ -2137,19 +2139,30 @@ function spoofCSS(
if ( typeof args[i+1] !== 'string' ) { break; }
propToValueMap.set(toCamelCase(args[i+0]), args[i+1]);
}
const safe = safeSelf();
const canDebug = scriptletGlobals.has('canDebug');
const shouldDebug = canDebug && propToValueMap.get('debug') || 0;
const shouldLog = canDebug && propToValueMap.has('log') || 0;
const proxiedStyles = new WeakSet();
const spoofStyle = (prop, real) => {
const normalProp = toCamelCase(prop);
const shouldSpoof = propToValueMap.has(normalProp);
const value = shouldSpoof ? propToValueMap.get(normalProp) : real;
if ( shouldLog === 2 || shouldSpoof && shouldLog === 1 ) {
safe.uboLog(prop, value);
}
return value;
};
self.getComputedStyle = new Proxy(self.getComputedStyle, {
apply: function(target, thisArg, args) {
if ( propToValueMap.has('debug') ) { debugger; } // jshint ignore: line
if ( shouldDebug !== 0 ) { debugger; } // jshint ignore: line
const style = Reflect.apply(target, thisArg, args);
const targetElements = new WeakSet(document.querySelectorAll(selector));
if ( targetElements.has(args[0]) === false ) { return style; }
proxiedStyles.add(target);
const proxiedStyle = new Proxy(style, {
get(target, prop, receiver) {
const normalProp = toCamelCase(prop);
const value = propToValueMap.has(normalProp)
? propToValueMap.get(normalProp)
: Reflect.get(target, prop, receiver);
return value;
return spoofStyle(prop, Reflect.get(target, prop, receiver));
},
});
return proxiedStyle;
@ -2161,9 +2174,23 @@ function spoofCSS(
return Reflect.get(target, prop, receiver);
},
});
CSSStyleDeclaration.prototype.getPropertyValue = new Proxy(CSSStyleDeclaration.prototype.getPropertyValue, {
apply: function(target, thisArg, args) {
if ( shouldDebug !== 0 ) { debugger; } // jshint ignore: line
const value = Reflect.apply(target, thisArg, args);
if ( proxiedStyles.has(thisArg) === false ) { return value; }
return spoofStyle(args[0], value);
},
get(target, prop, receiver) {
if ( prop === 'toString' ) {
return target.toString.bind(target);
}
return Reflect.get(target, prop, receiver);
},
});
Element.prototype.getBoundingClientRect = new Proxy(Element.prototype.getBoundingClientRect, {
apply: function(target, thisArg, args) {
if ( propToValueMap.has('debug') ) { debugger; } // jshint ignore: line
if ( shouldDebug !== 0 ) { debugger; } // jshint ignore: line
const rect = Reflect.apply(target, thisArg, args);
const targetElements = new WeakSet(document.querySelectorAll(selector));
if ( targetElements.has(thisArg) === false ) { return rect; }
@ -2186,3 +2213,62 @@ function spoofCSS(
}
/******************************************************************************/
builtinScriptlets.push({
name: 'sed.js',
requiresTrust: true,
fn: sed,
dependencies: [
'pattern-to-regex.fn',
'safe-self.fn',
],
});
function sed(
nodeName = '',
pattern = '',
replacement = ''
) {
const reNodeName = patternToRegex(nodeName, 'i');
const rePattern = patternToRegex(pattern, 'gms');
const extraArgs = new Map(
Array.from(arguments).slice(3).reduce((out, v, i, a) => {
if ( (i & 1) === 0 ) { out.push([ a[i], a[i+1] || undefined ]); }
return out;
}, [])
);
const shouldLog = scriptletGlobals.has('canDebug') && extraArgs.get('log') || 0;
const reCondition = patternToRegex(extraArgs.get('condition') || '', 'gms');
let sedCount = extraArgs.has('sedCount') ? parseInt(extraArgs.get('sedCount')) : 0;
let tryCount = extraArgs.has('tryCount') ? parseInt(extraArgs.get('tryCount')) : 0;
const safe = safeSelf();
const handler = mutations => {
for ( const mutation of mutations ) {
for ( const node of mutation.addedNodes ) {
if ( reNodeName.test(node.nodeName) === false ) { continue; }
const before = node.textContent;
if ( safe.RegExp_test.call(rePattern, before) === false ) { continue; }
if ( safe.RegExp_test.call(reCondition, before) === false ) { continue; }
if ( shouldLog !== 0 ) { safe.uboLog('sed.js before:\n', before); }
const after = before.replace(rePattern, replacement);
if ( shouldLog !== 0 ) { safe.uboLog('sed.js after:\n', after); }
node.textContent = after;
if ( sedCount !== 0 && (sedCount -= 1) === 0 ) {
observer.disconnect();
if ( shouldLog !== 0 ) { safe.uboLog('sed.js: quitting'); }
return;
}
}
}
if ( tryCount !== 0 && (tryCount -= 1) === 0 ) {
observer.disconnect();
if ( shouldLog !== 0 ) { safe.uboLog('sed.js: quitting'); }
}
};
const observer = new MutationObserver(handler);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
/******************************************************************************/

View File

@ -86,6 +86,7 @@ class RedirectEntry {
this.data = '';
this.warURL = undefined;
this.params = undefined;
this.requiresTrust = false;
this.dependencies = [];
}
@ -149,21 +150,16 @@ class RedirectEntry {
return this.data;
}
static fromContent(mime, content, dependencies = []) {
static fromDetails(details) {
const r = new RedirectEntry();
r.mime = mime;
r.data = content;
r.dependencies.push(...dependencies);
return r;
}
static fromSelfie(selfie) {
const r = new RedirectEntry();
r.mime = selfie.mime;
r.data = selfie.data;
r.warURL = selfie.warURL;
r.params = selfie.params;
r.dependencies = selfie.dependencies || [];
r.mime = details.mime;
r.data = details.data;
r.requiresTrust = details.requiresTrust === true;
r.warURL = details.warURL !== undefined && details.warURL || undefined;
r.params = details.params !== undefined && details.params || undefined;
if ( Array.isArray(details.dependencies) ) {
r.dependencies.push(...details.dependencies);
}
return r;
}
}
@ -213,6 +209,11 @@ class RedirectEngine {
return this.resources.get(this.aliases.get(token) || token) !== undefined;
}
tokenRequiresTrust(token) {
const entry = this.resources.get(this.aliases.get(token) || token);
return entry && entry.requiresTrust === true || false;
}
async toSelfie() {
}
@ -288,10 +289,10 @@ class RedirectEngine {
// No more data, add the resource.
const name = this.aliases.get(fields[0]) || fields[0];
const mime = fields[1];
const content = orphanizeString(
const data = orphanizeString(
fields.slice(2).join(encoded ? '' : '\n')
);
this.resources.set(name, RedirectEntry.fromContent(mime, content));
this.resources.set(name, RedirectEntry.fromDetails({ mime, data }));
if ( Array.isArray(details) ) {
for ( const { prop, value } of details ) {
if ( prop !== 'alias' ) { continue; }
@ -314,11 +315,12 @@ class RedirectEngine {
import('/assets/resources/scriptlets.js').then(module => {
for ( const scriptlet of module.builtinScriptlets ) {
const { name, aliases, fn } = scriptlet;
const entry = RedirectEntry.fromContent(
mimeFromName(name),
fn.toString(),
scriptlet.dependencies,
);
const entry = RedirectEntry.fromDetails({
mime: mimeFromName(name),
data: fn.toString(),
dependencies: scriptlet.dependencies,
requiresTrust: scriptlet.requiresTrust === true,
});
this.resources.set(name, entry);
if ( Array.isArray(aliases) === false ) { continue; }
for ( const alias of aliases ) {
@ -331,7 +333,7 @@ class RedirectEngine {
const store = (name, data = undefined) => {
const details = redirectableResources.get(name);
const entry = RedirectEntry.fromSelfie({
const entry = RedirectEntry.fromDetails({
mime: mimeFromName(name),
data,
warURL: `/web_accessible_resources/${name}`,
@ -444,7 +446,7 @@ class RedirectEngine {
this.aliases = new Map(selfie.aliases);
this.resources = new Map();
for ( const [ token, entry ] of selfie.resources ) {
this.resources.set(token, RedirectEntry.fromSelfie(entry));
this.resources.set(token, RedirectEntry.fromDetails(entry));
}
return true;
}

View File

@ -24,7 +24,7 @@
/******************************************************************************/
import µb from './background.js';
import { redirectEngine } from './redirect-engine.js';
import { redirectEngine as reng } from './redirect-engine.js';
import { sessionFirewall } from './filtering-engines.js';
import { StaticExtFilteringHostnameDB } from './static-ext-filtering-db.js';
import * as sfp from './static-filtering-parser.js';
@ -119,7 +119,7 @@ const contentscriptCode = (( ) => {
// TODO: Probably should move this into StaticFilteringParser
// https://github.com/uBlockOrigin/uBlock-issues/issues/1031
// Normalize scriptlet name to its canonical, unaliased name.
const normalizeRawFilter = function(parser) {
const normalizeRawFilter = function(parser, sourceIsTrusted = false) {
const root = parser.getBranchFromType(sfp.NODE_TYPE_EXT_PATTERN_SCRIPTLET);
const walker = parser.getWalker(root);
const args = [];
@ -135,10 +135,14 @@ const normalizeRawFilter = function(parser) {
}
walker.dispose();
if ( args.length !== 0 ) {
const full = `${args[0]}.js`;
if ( redirectEngine.aliases.has(full) ) {
args[0] = redirectEngine.aliases.get(full).slice(0, -3);
let token = `${args[0]}.js`;
if ( reng.aliases.has(token) ) {
token = reng.aliases.get(token);
}
if ( sourceIsTrusted !== true && reng.tokenRequiresTrust(token) ) {
return;
}
args[0] = token.slice(0, -3);
}
return `+js(${args.join(', ')})`;
};
@ -155,19 +159,19 @@ const lookupScriptlet = function(rawToken, scriptletMap, dependencyMap) {
}
// TODO: The alias lookup can be removed once scriptlet resources
// with obsolete name are converted to their new name.
if ( redirectEngine.aliases.has(token) ) {
token = redirectEngine.aliases.get(token);
if ( reng.aliases.has(token) ) {
token = reng.aliases.get(token);
} else {
token = `${token}.js`;
}
const details = redirectEngine.contentFromName(token, 'text/javascript');
const details = reng.contentFromName(token, 'text/javascript');
if ( details === undefined ) { return; }
const content = patchScriptlet(details.js, args);
const dependencies = details.dependencies || [];
while ( dependencies.length !== 0 ) {
const token = dependencies.shift();
if ( dependencyMap.has(token) ) { continue; }
const details = redirectEngine.contentFromName(token, 'fn/javascript');
const details = reng.contentFromName(token, 'fn/javascript');
if ( details === undefined ) { continue; }
dependencyMap.set(token, details.js);
if ( Array.isArray(details.dependencies) === false ) { continue; }
@ -254,7 +258,10 @@ scriptletFilteringEngine.compile = function(parser, writer) {
// Only exception filters are allowed to be global.
const isException = parser.isException();
const normalized = normalizeRawFilter(parser);
const normalized = normalizeRawFilter(parser, writer.properties.get('isTrusted'));
// Can fail if there is a mismatch with trust requirement
if ( normalized === undefined ) { return; }
// Tokenless is meaningful only for exception filters.
if ( normalized === '+js()' && isException === false ) { return; }
@ -343,7 +350,7 @@ scriptletFilteringEngine.retrieve = function(request) {
};
}
if ( scriptletCache.resetTime < redirectEngine.modifyTime ) {
if ( scriptletCache.resetTime < reng.modifyTime ) {
scriptletCache.reset();
}

View File

@ -368,6 +368,14 @@ self.addEventListener('hiddenSettingsChanged', ( ) => {
/******************************************************************************/
µb.isTrustedList = function(assetKey) {
if ( assetKey.startsWith('ublock-') ) { return true; }
if ( assetKey === this.userFiltersPath ) { return true; }
return false;
};
/******************************************************************************/
µb.loadSelectedFilterLists = async function() {
const bin = await vAPI.storage.get('selectedFilterLists');
if ( bin instanceof Object && Array.isArray(bin.selectedFilterLists) ) {
@ -560,7 +568,8 @@ self.addEventListener('hiddenSettingsChanged', ( ) => {
await this.saveUserFilters(details.content.trim() + '\n' + filters);
const compiledFilters = this.compileFilters(filters, {
assetKey: this.userFiltersPath
assetKey: this.userFiltersPath,
isTrusted: true,
});
const snfe = staticNetFilteringEngine;
const cfe = cosmeticFilteringEngine;
@ -885,10 +894,10 @@ self.addEventListener('hiddenSettingsChanged', ( ) => {
if ( µb.inMemoryFilters.length !== 0 ) {
if ( µb.inMemoryFiltersCompiled === '' ) {
µb.inMemoryFiltersCompiled =
µb.compileFilters(
µb.inMemoryFilters.join('\n'),
{ assetKey: 'in-memory'}
);
µb.compileFilters(µb.inMemoryFilters.join('\n'), {
assetKey: 'in-memory',
isTrusted: true,
});
}
if ( µb.inMemoryFiltersCompiled !== '' ) {
toLoad.push(
@ -953,8 +962,10 @@ self.addEventListener('hiddenSettingsChanged', ( ) => {
return { assetKey, content: '' };
}
const compiledContent =
this.compileFilters(rawDetails.content, { assetKey });
const compiledContent = this.compileFilters(rawDetails.content, {
assetKey,
isTrusted: this.isTrustedList(assetKey),
});
io.put(compiledPath, compiledContent);
return { assetKey, content: compiledContent };
@ -1020,6 +1031,7 @@ self.addEventListener('hiddenSettingsChanged', ( ) => {
// client compilers.
if ( details.assetKey ) {
writer.properties.set('name', details.assetKey);
writer.properties.set('isTrusted', details.isTrusted === true);
}
const assetName = details.assetKey ? details.assetKey : '?';
const expertMode =
@ -1539,7 +1551,8 @@ self.addEventListener('hiddenSettingsChanged', ( ) => {
io.put(
'compiled/' + details.assetKey,
this.compileFilters(details.content, {
assetKey: details.assetKey
assetKey: details.assetKey,
isTrusted: this.isTrustedList(details.assetKey),
})
);
}