From 41876336db48292de06707adfa5e97dab74297d2 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Sun, 21 May 2023 14:16:56 -0400 Subject: [PATCH] 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$/) --- assets/resources/scriptlets.js | 116 ++++++++++++++++++++++++++++----- src/js/redirect-engine.js | 48 +++++++------- src/js/scriptlet-filtering.js | 29 +++++---- src/js/storage.js | 29 ++++++--- 4 files changed, 165 insertions(+), 57 deletions(-) diff --git a/assets/resources/scriptlets.js b/assets/resources/scriptlets.js index 67eec324d..4ea602c88 100644 --- a/assets/resources/scriptlets.js +++ b/assets/resources/scriptlets.js @@ -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, + }); +} + +/******************************************************************************/ diff --git a/src/js/redirect-engine.js b/src/js/redirect-engine.js index dee269734..a2237817d 100644 --- a/src/js/redirect-engine.js +++ b/src/js/redirect-engine.js @@ -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; } diff --git a/src/js/scriptlet-filtering.js b/src/js/scriptlet-filtering.js index c9f023b23..3f9e76c18 100644 --- a/src/js/scriptlet-filtering.js +++ b/src/js/scriptlet-filtering.js @@ -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(); } diff --git a/src/js/storage.js b/src/js/storage.js index f227bb2d1..07075f76a 100644 --- a/src/js/storage.js +++ b/src/js/storage.js @@ -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), }) ); }