Imrpove no-xhr-if scriptlet

Related issue:
https://github.com/uBlockOrigin/uBlock-issues/issues/2773

The `randomize` paramater introduced in https://github.com/gorhill/uBlock/commit/418087de9c
is now named `directive`, and beside the `true` value which is meant
to respond with a random 10-character string, it can now take the
following value:

  war:[web_accessible_resource name]

In order to mock the XHR response with a web accessible resource. For
example:

  piquark6046.github.io##+js(no-xhr-if, adsbygoogle.js, war:googlesyndication_adsbygoogle.js)

Will cause the XHR performed by the webpage to resolve to the content
of `/web_accessible_resources/googlesyndication_adsbygoogle.js`.

Should the resource not exist, the empty string will be returned.
This commit is contained in:
Raymond Hill 2023-08-14 10:03:50 -04:00
parent c92cdd5818
commit bf591d93fb
No known key found for this signature in database
GPG key ID: 25E1490B761470C2
7 changed files with 149 additions and 85 deletions

View file

@ -1935,21 +1935,55 @@ builtinScriptlets.push({
}); });
function noXhrIf( function noXhrIf(
propsToMatch = '', propsToMatch = '',
randomize = '' directive = ''
) { ) {
if ( typeof propsToMatch !== 'string' ) { return; } if ( typeof propsToMatch !== 'string' ) { return; }
const xhrInstances = new WeakMap(); const xhrInstances = new WeakMap();
const propNeedles = parsePropertiesToMatch(propsToMatch, 'url'); const propNeedles = parsePropertiesToMatch(propsToMatch, 'url');
const log = propNeedles.size === 0 ? console.log.bind(console) : undefined; const log = propNeedles.size === 0 ? console.log.bind(console) : undefined;
const warOrigin = scriptletGlobals.get('warOrigin');
const generateRandomString = len => {
let s = '';
do { s += Math.random().toString(36).slice(2); }
while ( s.length < 10 );
return s.slice(0, len);
};
const generateContent = async directive => {
if ( directive === 'true' ) {
return generateRandomString(10);
}
if ( directive.startsWith('war:') ) {
if ( warOrigin === undefined ) { return ''; }
const warName = directive.slice(4);
const fullpath = [ warOrigin, '/', warName ];
const warSecret = scriptletGlobals.get('warSecret') || '';
if ( warSecret !== '' ) {
fullpath.push('?secret=', warSecret);
}
return new Promise(resolve => {
const warXHR = new XMLHttpRequest();
warXHR.responseType = 'text';
warXHR.onloadend = ev => {
resolve(ev.target.responseText || '');
};
warXHR.open('GET', fullpath.join(''));
warXHR.send();
});
}
return '';
};
self.XMLHttpRequest = class extends self.XMLHttpRequest { self.XMLHttpRequest = class extends self.XMLHttpRequest {
open(method, url, ...args) { open(method, url, ...args) {
if ( log !== undefined ) { if ( log !== undefined ) {
log(`uBO: xhr.open(${method}, ${url}, ${args.join(', ')})`); log(`uBO: xhr.open(${method}, ${url}, ${args.join(', ')})`);
} else { return super.open(method, url, ...args);
const haystack = { method, url }; }
if ( matchObjectProperties(propNeedles, haystack) ) { if ( warOrigin !== undefined && url.startsWith(warOrigin) ) {
xhrInstances.set(this, haystack); return super.open(method, url, ...args);
} }
const haystack = { method, url };
if ( matchObjectProperties(propNeedles, haystack) ) {
xhrInstances.set(this, haystack);
} }
return super.open(method, url, ...args); return super.open(method, url, ...args);
} }
@ -1958,50 +1992,66 @@ function noXhrIf(
if ( haystack === undefined ) { if ( haystack === undefined ) {
return super.send(...args); return super.send(...args);
} }
Object.defineProperties(this, { let promise = Promise.resolve({
readyState: { value: 4 }, xhr: this,
responseURL: { value: haystack.url }, directive,
status: { value: 200 }, props: {
statusText: { value: 'OK' }, readyState: { value: 4 },
response: { value: '' },
responseText: { value: '' },
responseXML: { value: null },
responseURL: { value: haystack.url },
status: { value: 200 },
statusText: { value: 'OK' },
},
}); });
let response = '';
let responseText = '';
let responseXML = null;
switch ( this.responseType ) { switch ( this.responseType ) {
case 'arraybuffer': case 'arraybuffer':
response = new ArrayBuffer(0); promise = promise.then(details => {
details.props.response.value = new ArrayBuffer(0);
return details;
});
break; break;
case 'blob': case 'blob':
response = new Blob([]); promise = promise.then(details => {
details.props.response.value = new Blob([]);
return details;
});
break; break;
case 'document': { case 'document': {
const parser = new DOMParser(); promise = promise.then(details => {
const doc = parser.parseFromString('', 'text/html'); const parser = new DOMParser();
response = doc; const doc = parser.parseFromString('', 'text/html');
responseXML = doc; details.props.response.value = doc;
details.props.responseXML.value = doc;
return details;
});
break; break;
} }
case 'json': case 'json':
response = {}; promise = promise.then(details => {
responseText = '{}'; details.props.response.value = {};
details.props.responseText.value = '{}';
return details;
});
break; break;
default: default:
if ( randomize !== 'true' ) { break; } if ( directive === '' ) { break; }
do { promise = promise.then(details => {
response += Math.random().toString(36).slice(-2); return generateContent(details.directive).then(text => {
} while ( response.length < 10 ); details.props.response.value = text;
response = response.slice(-10); details.props.responseText.value = text;
responseText = response; return details;
});
});
break; break;
} }
Object.defineProperties(this, { promise.then(details => {
response: { value: response }, Object.defineProperties(details.xhr, details.props);
responseText: { value: responseText }, details.xhr.dispatchEvent(new Event('readystatechange'));
responseXML: { value: responseXML }, details.xhr.dispatchEvent(new Event('load'));
details.xhr.dispatchEvent(new Event('loadend'));
}); });
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('load'));
this.dispatchEvent(new Event('loadend'));
} }
}; };
} }

View file

@ -1167,24 +1167,32 @@ vAPI.messaging = {
// https://github.com/uBlockOrigin/uBlock-issues/issues/550 // https://github.com/uBlockOrigin/uBlock-issues/issues/550
// Support using a new secret for every network request. // Support using a new secret for every network request.
vAPI.warSecret = (( ) => { {
const generateSecret = ( ) => { // Generate a 6-character alphanumeric string, thus one random value out
return Math.floor(Math.random() * 982451653 + 982451653).toString(36); // of 36^6 = over 2x10^9 values.
}; const generateSecret = ( ) =>
(Math.floor(Math.random() * 2176782336) + 2176782336).toString(36).slice(1);
const root = vAPI.getURL('/'); const root = vAPI.getURL('/');
const secrets = []; const reSecret = /\?secret=(\w+)/;
let lastSecretTime = 0; const shortSecrets = [];
let lastShortSecretTime = 0;
const guard = function(details) { // Long secrets are meant to be used multiple times, but for at most a few
const url = details.url; // minutes. The realm is one value out of 36^18 = over 10^28 values.
const pos = secrets.findIndex(secret => const longSecrets = [ '', '' ];
url.lastIndexOf(`?secret=${secret}`) !== -1 let lastLongSecretTimeSlice = 0;
);
const guard = details => {
const match = reSecret.exec(details.url);
if ( match === null ) { return; }
const secret = match[1];
if ( longSecrets.includes(secret) ) { return; }
const pos = shortSecrets.indexOf(secret);
if ( pos === -1 ) { if ( pos === -1 ) {
return { cancel: true }; return { cancel: true };
} }
secrets.splice(pos, 1); shortSecrets.splice(pos, 1);
}; };
browser.webRequest.onBeforeRequest.addListener( browser.webRequest.onBeforeRequest.addListener(
@ -1195,20 +1203,31 @@ vAPI.warSecret = (( ) => {
[ 'blocking' ] [ 'blocking' ]
); );
return ( ) => { vAPI.warSecret = {
if ( secrets.length !== 0 ) { short: ( ) => {
if ( (Date.now() - lastSecretTime) > 5000 ) { if ( shortSecrets.length !== 0 ) {
secrets.splice(0); if ( (Date.now() - lastShortSecretTime) > 5000 ) {
} else if ( secrets.length > 256 ) { shortSecrets.splice(0);
secrets.splice(0, secrets.length - 192); } else if ( shortSecrets.length > 256 ) {
shortSecrets.splice(0, shortSecrets.length - 192);
}
} }
} lastShortSecretTime = Date.now();
lastSecretTime = Date.now(); const secret = generateSecret();
const secret = generateSecret(); shortSecrets.push(secret);
secrets.push(secret); return secret;
return secret; },
long: ( ) => {
const timeSlice = Date.now() >>> 19; // Changes every ~9 minutes
if ( timeSlice !== lastLongSecretTimeSlice ) {
longSecrets[1] = longSecrets[0];
longSecrets[0] = `${generateSecret()}${generateSecret()}${generateSecret()}`;
lastLongSecretTimeSlice = timeSlice;
}
return longSecrets[0];
},
}; };
})(); }
/******************************************************************************/ /******************************************************************************/

View file

@ -984,7 +984,7 @@ const onMessage = function(request, sender, callback) {
zap: µb.epickerArgs.zap, zap: µb.epickerArgs.zap,
eprom: µb.epickerArgs.eprom, eprom: µb.epickerArgs.eprom,
pickerURL: vAPI.getURL( pickerURL: vAPI.getURL(
`/web_accessible_resources/epicker-ui.html?secret=${vAPI.warSecret()}` `/web_accessible_resources/epicker-ui.html?secret=${vAPI.warSecret.short()}`
), ),
}); });
µb.epickerArgs.target = ''; µb.epickerArgs.target = '';

View file

@ -152,7 +152,7 @@ const NetFilteringResultCache = class {
entry.redirectURL.startsWith(this.extensionOriginURL) entry.redirectURL.startsWith(this.extensionOriginURL)
) { ) {
const redirectURL = new URL(entry.redirectURL); const redirectURL = new URL(entry.redirectURL);
redirectURL.searchParams.set('secret', vAPI.warSecret()); redirectURL.searchParams.set('secret', vAPI.warSecret.short());
entry.redirectURL = redirectURL.href; entry.redirectURL = redirectURL.href;
} }
return entry; return entry;

View file

@ -66,12 +66,12 @@ const removeTopCommentBlock = text => {
return text.replace(/^\/\*[\S\s]+?\n\*\/\s*/, ''); return text.replace(/^\/\*[\S\s]+?\n\*\/\s*/, '');
}; };
// vAPI.warSecret() is optional, it could be absent in some environments, // vAPI.warSecret is optional, it could be absent in some environments,
// i.e. nodejs for example. Probably the best approach is to have the // i.e. nodejs for example. Probably the best approach is to have the
// "web_accessible_resources secret" added outside by the client of this // "web_accessible_resources secret" added outside by the client of this
// module, but for now I just want to remove an obstacle to modularization. // module, but for now I just want to remove an obstacle to modularization.
const warSecret = typeof vAPI === 'object' && vAPI !== null const warSecret = typeof vAPI === 'object' && vAPI !== null
? vAPI.warSecret ? vAPI.warSecret.short
: ( ) => ''; : ( ) => '';
const RESOURCES_SELFIE_VERSION = 7; const RESOURCES_SELFIE_VERSION = 7;
@ -153,15 +153,7 @@ class RedirectEntry {
static fromDetails(details) { static fromDetails(details) {
const r = new RedirectEntry(); const r = new RedirectEntry();
r.mime = details.mime; Object.assign(r, details);
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;
r.world = details.world || 'MAIN';
if ( Array.isArray(details.dependencies) ) {
r.dependencies.push(...details.dependencies);
}
return r; return r;
} }
} }
@ -331,17 +323,17 @@ class RedirectEngine {
const fetches = [ const fetches = [
import('/assets/resources/scriptlets.js').then(module => { import('/assets/resources/scriptlets.js').then(module => {
for ( const scriptlet of module.builtinScriptlets ) { for ( const scriptlet of module.builtinScriptlets ) {
const { name, aliases, fn } = scriptlet; const details = {};
const entry = RedirectEntry.fromDetails({ details.mime = mimeFromName(scriptlet.name);
mime: mimeFromName(name), details.data = scriptlet.fn.toString();
data: fn.toString(), for ( const [ k, v ] of Object.entries(scriptlet) ) {
dependencies: scriptlet.dependencies, if ( k === 'fn' ) { continue; }
requiresTrust: scriptlet.requiresTrust === true, details[k] = v;
world: scriptlet.world || 'MAIN', }
}); const entry = RedirectEntry.fromDetails(details);
this.resources.set(name, entry); this.resources.set(details.name, entry);
if ( Array.isArray(aliases) === false ) { continue; } if ( Array.isArray(details.aliases) === false ) { continue; }
for ( const alias of aliases ) { for ( const alias of details.aliases ) {
this.aliases.set(alias, name); this.aliases.set(alias, name);
} }
} }

View file

@ -383,7 +383,10 @@ scriptletFilteringEngine.retrieve = function(request) {
return { filters: cacheDetails.filters }; return { filters: cacheDetails.filters };
} }
const scriptletGlobals = []; const scriptletGlobals = [
[ 'warOrigin', vAPI.getURL('/web_accessible_resources') ],
[ 'warSecret', vAPI.warSecret.long() ],
];
if ( isDevBuild === undefined ) { if ( isDevBuild === undefined ) {
isDevBuild = vAPI.webextFlavor.soup.has('devbuild'); isDevBuild = vAPI.webextFlavor.soup.has('devbuild');

View file

@ -1135,7 +1135,7 @@ self.addEventListener('hiddenSettingsChanged', ( ) => {
const fetcher = (path, options = undefined) => { const fetcher = (path, options = undefined) => {
if ( path.startsWith('/web_accessible_resources/') ) { if ( path.startsWith('/web_accessible_resources/') ) {
path += `?secret=${vAPI.warSecret()}`; path += `?secret=${vAPI.warSecret.short()}`;
return io.fetch(path, options); return io.fetch(path, options);
} }
return io.fetchText(path); return io.fetchText(path);