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(
propsToMatch = '',
randomize = ''
directive = ''
) {
if ( typeof propsToMatch !== 'string' ) { return; }
const xhrInstances = new WeakMap();
const propNeedles = parsePropertiesToMatch(propsToMatch, 'url');
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 {
open(method, url, ...args) {
if ( log !== undefined ) {
log(`uBO: xhr.open(${method}, ${url}, ${args.join(', ')})`);
} else {
const haystack = { method, url };
if ( matchObjectProperties(propNeedles, haystack) ) {
xhrInstances.set(this, haystack);
}
return super.open(method, url, ...args);
}
if ( warOrigin !== undefined && url.startsWith(warOrigin) ) {
return super.open(method, url, ...args);
}
const haystack = { method, url };
if ( matchObjectProperties(propNeedles, haystack) ) {
xhrInstances.set(this, haystack);
}
return super.open(method, url, ...args);
}
@ -1958,50 +1992,66 @@ function noXhrIf(
if ( haystack === undefined ) {
return super.send(...args);
}
Object.defineProperties(this, {
readyState: { value: 4 },
responseURL: { value: haystack.url },
status: { value: 200 },
statusText: { value: 'OK' },
let promise = Promise.resolve({
xhr: this,
directive,
props: {
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 ) {
case 'arraybuffer':
response = new ArrayBuffer(0);
promise = promise.then(details => {
details.props.response.value = new ArrayBuffer(0);
return details;
});
break;
case 'blob':
response = new Blob([]);
promise = promise.then(details => {
details.props.response.value = new Blob([]);
return details;
});
break;
case 'document': {
const parser = new DOMParser();
const doc = parser.parseFromString('', 'text/html');
response = doc;
responseXML = doc;
promise = promise.then(details => {
const parser = new DOMParser();
const doc = parser.parseFromString('', 'text/html');
details.props.response.value = doc;
details.props.responseXML.value = doc;
return details;
});
break;
}
case 'json':
response = {};
responseText = '{}';
promise = promise.then(details => {
details.props.response.value = {};
details.props.responseText.value = '{}';
return details;
});
break;
default:
if ( randomize !== 'true' ) { break; }
do {
response += Math.random().toString(36).slice(-2);
} while ( response.length < 10 );
response = response.slice(-10);
responseText = response;
if ( directive === '' ) { break; }
promise = promise.then(details => {
return generateContent(details.directive).then(text => {
details.props.response.value = text;
details.props.responseText.value = text;
return details;
});
});
break;
}
Object.defineProperties(this, {
response: { value: response },
responseText: { value: responseText },
responseXML: { value: responseXML },
promise.then(details => {
Object.defineProperties(details.xhr, details.props);
details.xhr.dispatchEvent(new Event('readystatechange'));
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
// Support using a new secret for every network request.
vAPI.warSecret = (( ) => {
const generateSecret = ( ) => {
return Math.floor(Math.random() * 982451653 + 982451653).toString(36);
};
{
// Generate a 6-character alphanumeric string, thus one random value out
// of 36^6 = over 2x10^9 values.
const generateSecret = ( ) =>
(Math.floor(Math.random() * 2176782336) + 2176782336).toString(36).slice(1);
const root = vAPI.getURL('/');
const secrets = [];
let lastSecretTime = 0;
const reSecret = /\?secret=(\w+)/;
const shortSecrets = [];
let lastShortSecretTime = 0;
const guard = function(details) {
const url = details.url;
const pos = secrets.findIndex(secret =>
url.lastIndexOf(`?secret=${secret}`) !== -1
);
// Long secrets are meant to be used multiple times, but for at most a few
// minutes. The realm is one value out of 36^18 = over 10^28 values.
const longSecrets = [ '', '' ];
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 ) {
return { cancel: true };
}
secrets.splice(pos, 1);
shortSecrets.splice(pos, 1);
};
browser.webRequest.onBeforeRequest.addListener(
@ -1195,20 +1203,31 @@ vAPI.warSecret = (( ) => {
[ 'blocking' ]
);
return ( ) => {
if ( secrets.length !== 0 ) {
if ( (Date.now() - lastSecretTime) > 5000 ) {
secrets.splice(0);
} else if ( secrets.length > 256 ) {
secrets.splice(0, secrets.length - 192);
vAPI.warSecret = {
short: ( ) => {
if ( shortSecrets.length !== 0 ) {
if ( (Date.now() - lastShortSecretTime) > 5000 ) {
shortSecrets.splice(0);
} else if ( shortSecrets.length > 256 ) {
shortSecrets.splice(0, shortSecrets.length - 192);
}
}
}
lastSecretTime = Date.now();
const secret = generateSecret();
secrets.push(secret);
return secret;
lastShortSecretTime = Date.now();
const secret = generateSecret();
shortSecrets.push(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,
eprom: µb.epickerArgs.eprom,
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 = '';

View file

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

View file

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

View file

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

View file

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