Skip to content

Commit

Permalink
Add trusted-source support for privileged scriptlets
Browse files Browse the repository at this point in the history
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$/)
  • Loading branch information
gorhill committed May 21, 2023
1 parent 2c7d91b commit 4187633
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 57 deletions.
116 changes: 101 additions & 15 deletions assets/resources/scriptlets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/******************************************************************************/
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -2122,6 +2121,9 @@ function callNothrow(
builtinScriptlets.push({
name: 'spoof-css.js',
fn: spoofCSS,
dependencies: [
'safe-self.fn',
],
});
function spoofCSS(
selector,
Expand All @@ -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;
Expand All @@ -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; }
Expand All @@ -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,
});
}

/******************************************************************************/
48 changes: 25 additions & 23 deletions src/js/redirect-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class RedirectEntry {
this.data = '';
this.warURL = undefined;
this.params = undefined;
this.requiresTrust = false;
this.dependencies = [];
}

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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() {
}

Expand Down Expand Up @@ -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; }
Expand All @@ -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 ) {
Expand All @@ -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}`,
Expand Down Expand Up @@ -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;
}
Expand Down
29 changes: 18 additions & 11 deletions src/js/scriptlet-filtering.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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(', ')})`;
};
Expand All @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -343,7 +350,7 @@ scriptletFilteringEngine.retrieve = function(request) {
};
}

if ( scriptletCache.resetTime < redirectEngine.modifyTime ) {
if ( scriptletCache.resetTime < reng.modifyTime ) {
scriptletCache.reset();
}

Expand Down
Loading

4 comments on commit 4187633

@peace2000
Copy link
Contributor

@peace2000 peace2000 commented on 4187633 May 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was testing this scriptlet on https://www.iltalehti.fi/ and I noticed that I cannot replace text Etusivu for some reason.

image

iltalehti.fi##+js(sed, span, Etusivu)

Can you reproduce this @gorhill ?

@uBlock-user
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works great for ghacks.net where we can now remove text node titled - "Advertisement" with ghacks.net##+js(sed, #text, /^Advertisement$/) 👍

@u-RraaLL
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@peace2000 the nodeName needs to be #text to target text, from what I understood.

But this brings me to a question: Would it be possible to limit the target of the scriptlet using css selectors to only a certain part of the webpage rather than its entirety? @gorhill

@gorhill
Copy link
Owner Author

@gorhill gorhill commented on 4187633 May 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scriptlet acts on mutation events, so the test on whether to target a specific part of the DOM would need to be done after a node is found to pass all conditions. This would add some more work, but yes, feasible. I would want to see a need for this before further changing the scriptlet, which I want to stabilize at this point. More parameters can be added later without compromising backward/forward compatibility.


Note that this wouldn't help efficiency if that is your concern, all mutation events are processed, to test whether a node is part of a specific portion of the DOM would add to the work done.

Please sign in to comment.