Restore ability to redirect xhr to image resources

The ability to redirect xmlhttprequest to binary
resources was lost when redirectable/injectable
resources became immutable in commit
152cea2dfe.

This commit restores the ability to redirect a
xmlhttprequest to a binary resource by making
it possible to derive a data: URI from the
content of binary resources such as images.

Addtionally a redirect to a data: URI can be
forced by prefixing the resource token with `%`.
This is a non-official feature at this point,
i.e. it could be removed at any time.
This commit is contained in:
Raymond Hill 2019-08-06 10:51:24 -04:00
parent a1b99954e1
commit 7ac7b027f4
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
2 changed files with 188 additions and 183 deletions

View File

@ -62,6 +62,90 @@ const fireNotification = function(topic, details) {
/******************************************************************************/
api.fetch = function(url, options = {}) {
return new Promise((resolve, reject) => {
// Start of executor
const timeoutAfter = µBlock.hiddenSettings.assetFetchTimeout * 1000 || 30000;
const xhr = new XMLHttpRequest();
let contentLoaded = 0;
let timeoutTimer;
const cleanup = function() {
xhr.removeEventListener('load', onLoadEvent);
xhr.removeEventListener('error', onErrorEvent);
xhr.removeEventListener('abort', onErrorEvent);
xhr.removeEventListener('progress', onProgressEvent);
if ( timeoutTimer !== undefined ) {
clearTimeout(timeoutTimer);
timeoutTimer = undefined;
}
};
// https://github.com/gorhill/uMatrix/issues/15
const onLoadEvent = function() {
cleanup();
// xhr for local files gives status 0, but actually succeeds
const details = {
url,
content: '',
statusCode: this.status || 200,
statusText: this.statusText || ''
};
if ( details.statusCode < 200 || details.statusCode >= 300 ) {
return reject(details);
}
details.content = this.response;
resolve(details);
};
const onErrorEvent = function() {
cleanup();
µBlock.logger.writeOne({
realm: 'message',
type: 'error',
text: errorCantConnectTo.replace('{{msg}}', url)
});
reject({ url, content: '' });
};
const onTimeout = function() {
xhr.abort();
};
// https://github.com/gorhill/uBlock/issues/2526
// - Timeout only when there is no progress.
const onProgressEvent = function(ev) {
if ( ev.loaded === contentLoaded ) { return; }
contentLoaded = ev.loaded;
if ( timeoutTimer !== undefined ) {
clearTimeout(timeoutTimer);
}
timeoutTimer = vAPI.setTimeout(onTimeout, timeoutAfter);
};
// Be ready for thrown exceptions:
// I am pretty sure it used to work, but now using a URL such as
// `file:///` on Chromium 40 results in an exception being thrown.
try {
xhr.open('get', url, true);
xhr.addEventListener('load', onLoadEvent);
xhr.addEventListener('error', onErrorEvent);
xhr.addEventListener('abort', onErrorEvent);
xhr.addEventListener('progress', onProgressEvent);
xhr.responseType = options.responseType || 'text';
xhr.send();
timeoutTimer = vAPI.setTimeout(onTimeout, timeoutAfter);
} catch (e) {
onErrorEvent.call(xhr);
}
// End of executor
});
};
/******************************************************************************/
api.fetchText = function(url, onLoad, onError) {
const isExternal = reIsExternalPath.test(url);
let actualUrl = isExternal ? url : vAPI.getURL(url);
@ -91,109 +175,36 @@ api.fetchText = function(url, onLoad, onError) {
onError = onLoad;
}
return new Promise(resolve => {
// Start of executor
const timeoutAfter = µBlock.hiddenSettings.assetFetchTimeout * 1000 || 30000;
const xhr = new XMLHttpRequest();
let contentLoaded = 0;
let timeoutTimer;
const cleanup = function() {
xhr.removeEventListener('load', onLoadEvent);
xhr.removeEventListener('error', onErrorEvent);
xhr.removeEventListener('abort', onErrorEvent);
xhr.removeEventListener('progress', onProgressEvent);
if ( timeoutTimer !== undefined ) {
clearTimeout(timeoutTimer);
timeoutTimer = undefined;
}
};
const onResolve = function(details) {
if ( onLoad instanceof Function ) {
return onLoad(details);
}
resolve(details);
return details;
};
const onReject = function(details) {
details.content = '';
if ( onError instanceof Function ) {
return onError(details);
}
resolve(details);
return details;
};
// https://github.com/gorhill/uMatrix/issues/15
const onLoadEvent = function() {
cleanup();
// xhr for local files gives status 0, but actually succeeds
const details = {
url,
content: '',
statusCode: this.status || 200,
statusText: this.statusText || ''
};
if ( details.statusCode < 200 || details.statusCode >= 300 ) {
return onReject(details);
}
return api.fetch(url).then(details => {
// consider an empty result to be an error
if ( stringIsNotEmpty(this.responseText) === false ) {
if ( stringIsNotEmpty(details.content) === false ) {
return onReject(details);
}
// we never download anything else than plain text: discard if response
// appears to be a HTML document: could happen when server serves
// some kind of error page I suppose
const text = this.responseText.trim();
const text = details.content.trim();
if ( text.startsWith('<') && text.endsWith('>') ) {
return onReject(details);
}
details.content = this.responseText;
onResolve(details);
};
const onErrorEvent = function() {
cleanup();
µBlock.logger.writeOne({
realm: 'message',
type: 'error',
text: errorCantConnectTo.replace('{{msg}}', actualUrl)
});
onReject({ url, content: '' });
};
const onTimeout = function() {
xhr.abort();
};
// https://github.com/gorhill/uBlock/issues/2526
// - Timeout only when there is no progress.
const onProgressEvent = function(ev) {
if ( ev.loaded === contentLoaded ) { return; }
contentLoaded = ev.loaded;
if ( timeoutTimer !== undefined ) {
clearTimeout(timeoutTimer);
}
timeoutTimer = vAPI.setTimeout(onTimeout, timeoutAfter);
};
// Be ready for thrown exceptions:
// I am pretty sure it used to work, but now using a URL such as
// `file:///` on Chromium 40 results in an exception being thrown.
try {
xhr.open('get', actualUrl, true);
xhr.addEventListener('load', onLoadEvent);
xhr.addEventListener('error', onErrorEvent);
xhr.addEventListener('abort', onErrorEvent);
xhr.addEventListener('progress', onProgressEvent);
xhr.responseType = 'text';
xhr.send();
timeoutTimer = vAPI.setTimeout(onTimeout, timeoutAfter);
} catch (e) {
onErrorEvent.call(xhr);
}
// End of executor
return onResolve(details);
}).catch(details => {
return onReject(details);
});
};

View File

@ -28,133 +28,130 @@
/******************************************************************************/
/******************************************************************************/
// The resources referenced below are found in ./web_accessible_resources/
//
// The content of the resources which declare a `data` property will be loaded
// in memory, and converted to a suitable internal format depending on the
// type of the loaded data. The `data` property allows for manual injection
// through `+js(...)`, or for redirection to a data: URI when a redirection
// to a web accessible resource is not desirable.
const redirectableResources = new Map([
[ '1x1.gif', {
alias: '1x1-transparent.gif',
inject: false
data: 'blob',
} ],
[ '2x2.png', {
alias: '2x2-transparent.png',
inject: false
data: 'blob',
} ],
[ '3x2.png', {
alias: '3x2-transparent.png',
inject: false
data: 'blob',
} ],
[ '32x32.png', {
alias: '32x32-transparent.png',
inject: false
data: 'blob',
} ],
[ 'addthis_widget.js', {
alias: 'addthis.com/addthis_widget.js',
inject: false
} ],
[ 'ampproject_v0.js', {
alias: 'ampproject.org/v0.js',
inject: false
} ],
[ 'chartbeat.js', {
alias: 'static.chartbeat.com/chartbeat.js',
inject: false
} ],
[ 'amazon_ads.js', {
alias: 'amazon-adsystem.com/aax2/amzn_ads.js',
inject: false
} ],
[ 'disqus_embed.js', {
alias: 'disqus.com/embed.js',
inject: false
} ],
[ 'disqus_forums_embed.js', {
alias: 'disqus.com/forums/*/embed.js',
inject: false
} ],
[ 'doubleclick_instream_ad_status.js', {
alias: 'doubleclick.net/instream/ad_status.js',
inject: false
} ],
[ 'google-analytics_analytics.js', {
alias: 'google-analytics.com/analytics.js',
inject: false
} ],
[ 'google-analytics_cx_api.js', {
alias: 'google-analytics.com/cx/api.js',
inject: false
} ],
[ 'google-analytics_ga.js', {
alias: 'google-analytics.com/ga.js',
inject: false
} ],
[ 'google-analytics_inpage_linkid.js', {
alias: 'google-analytics.com/inpage_linkid.js',
inject: false
} ],
[ 'googlesyndication_adsbygoogle.js', {
alias: 'googlesyndication.com/adsbygoogle.js',
inject: false
} ],
[ 'googletagmanager_gtm.js', {
alias: 'googletagmanager.com/gtm.js',
inject: false
} ],
[ 'googletagservices_gpt.js', {
alias: 'googletagservices.com/gpt.js',
inject: false
} ],
[ 'hd-main.js', {
inject: false
} ],
[ 'ligatus_angular-tag.js', {
alias: 'ligatus.com/*/angular-tag.js',
inject: false
} ],
[ 'monkeybroker.js', {
alias: 'd3pkae9owd2lcf.cloudfront.net/mb105.js',
inject: false
} ],
[ 'noeval.js', {
data: 'text',
} ],
[ 'noeval-silent.js', {
alias: 'silent-noeval.js',
data: 'text',
} ],
[ 'nobab.js', {
alias: 'bab-defuser.js',
data: 'text',
} ],
[ 'nofab.js', {
alias: 'fuckadblock.js-3.2.0',
data: 'text',
} ],
[ 'noop-0.1s.mp3', {
alias: 'noopmp3-0.1s',
inject: false
data: 'blob',
} ],
[ 'noop-1s.mp4', {
alias: 'noopmp4-1s',
inject: false
data: 'blob',
} ],
[ 'noop.html', {
alias: 'noopframe',
inject: false
} ],
[ 'noop.js', {
alias: 'noopjs',
data: 'text',
} ],
[ 'noop.txt', {
alias: 'nooptext',
data: 'text',
} ],
[ 'outbrain-widget.js', {
alias: 'widgets.outbrain.com/outbrain.js',
inject: false
} ],
[ 'popads.js', {
alias: 'popads.net.js',
data: 'text',
} ],
[ 'popads-dummy.js', {
data: 'text',
} ],
[ 'scorecardresearch_beacon.js', {
alias: 'scorecardresearch.com/beacon.js',
inject: false
} ],
[ 'window.open-defuser.js', {
data: 'text',
} ],
]);
@ -194,9 +191,10 @@ const RedirectEntry = function() {
// - https://stackoverflow.com/a/8056313
// - https://bugzilla.mozilla.org/show_bug.cgi?id=998076
RedirectEntry.prototype.toURL = function(fctxt) {
RedirectEntry.prototype.toURL = function(fctxt, asDataURI = false) {
if (
this.warURL !== undefined &&
asDataURI !== true &&
fctxt instanceof Object &&
fctxt.type !== 'xmlhttprequest'
) {
@ -204,11 +202,7 @@ RedirectEntry.prototype.toURL = function(fctxt) {
}
if ( this.data === undefined ) { return; }
if ( this.data.startsWith('data:') === false ) {
if ( this.mime.indexOf(';') === -1 ) {
this.data = 'data:' + this.mime + ';base64,' + btoa(this.data);
} else {
this.data = 'data:' + this.mime + ',' + this.data;
}
this.data = `data:${this.mime};base64,${btoa(this.data)}`;
}
return this.data;
};
@ -304,9 +298,10 @@ RedirectEngine.prototype.lookup = function(fctxt) {
if ( this.ruleSources.has(src) ) {
for ( let i = 0; i < n; i++ ) {
const entries = this.rules.get(`${src} ${desAll[i]} ${type}`);
if ( entries && this.lookupToken(entries, reqURL) ) {
return this.resourceNameRegister;
}
if ( entries === undefined ) { continue; }
const rule = this.lookupRule(entries, reqURL);
if ( rule === undefined ) { continue; }
return rule;
}
}
src = this.toBroaderHostname(src);
@ -314,16 +309,13 @@ RedirectEngine.prototype.lookup = function(fctxt) {
}
};
RedirectEngine.prototype.lookupToken = function(entries, reqURL) {
let j = entries.length;
while ( j-- ) {
let entry = entries[j];
RedirectEngine.prototype.lookupRule = function(entries, reqURL) {
for ( const entry of entries ) {
if ( entry.pat instanceof RegExp === false ) {
entry.pat = new RegExp(entry.pat, 'i');
}
if ( entry.pat.test(reqURL) ) {
this.resourceNameRegister = entry.tok;
return true;
return entry;
}
}
};
@ -331,24 +323,21 @@ RedirectEngine.prototype.lookupToken = function(entries, reqURL) {
/******************************************************************************/
RedirectEngine.prototype.toURL = function(fctxt) {
let token = this.lookup(fctxt);
if ( token === undefined ) { return; }
const rule = this.lookup(fctxt);
if ( rule === undefined ) { return; }
let token = this.resourceNameRegister = rule.tok;
const asDataURI = token.charCodeAt(0) === 0x25 /* '%' */;
if ( asDataURI ) {
token = token.slice(1);
}
const entry = this.resources.get(this.aliases.get(token) || token);
if ( entry !== undefined ) {
return entry.toURL(fctxt);
return entry.toURL(fctxt, asDataURI);
}
};
/******************************************************************************/
RedirectEngine.prototype.matches = function(context) {
const token = this.lookup(context);
return token !== undefined &&
this.resources.has(this.aliases.get(token) || token);
};
/******************************************************************************/
RedirectEngine.prototype.addRule = function(src, des, type, pattern, redirect) {
this.ruleSources.add(src);
this.ruleDestinations.add(des);
@ -551,18 +540,6 @@ RedirectEngine.prototype.fromSelfie = function(path) {
/******************************************************************************/
RedirectEngine.prototype.resourceURIFromName = function(name, mime) {
const entry = this.resources.get(this.aliases.get(name) || name);
if (
(entry !== undefined) &&
(mime === undefined || entry.mime.startsWith(mime))
) {
return entry.toURL();
}
};
/******************************************************************************/
RedirectEngine.prototype.resourceContentFromName = function(name, mime) {
const entry = this.resources.get(this.aliases.get(name) || name);
if ( entry === undefined ) { return; }
@ -674,57 +651,74 @@ const removeTopCommentBlock = function(text) {
RedirectEngine.prototype.loadBuiltinResources = function() {
this.resources = new Map();
this.aliases = new Map();
const fetches = [
µBlock.assets.fetchText('/assets/resources/scriptlets.js'),
];
// TODO: remove once usage of uBO 1.20.4 is widespread.
µBlock.assets.remove('ublock-resources');
for ( const [ name, details ] of redirectableResources ) {
if ( details.inject !== false ) {
fetches.push(
const fetches = [
µBlock.assets.fetchText(
`/web_accessible_resources/${name}${vAPI.warSecret()}`
)
);
continue;
}
const entry = RedirectEntry.fromSelfie({
mime: mimeFromName(name),
warURL: vAPI.getURL(`/web_accessible_resources/${name}`),
});
this.resources.set(name, entry);
if ( details.alias !== undefined ) {
this.aliases.set(details.alias, name);
}
}
return Promise.all(fetches).then(results => {
// Built-in redirectable resources
for ( let i = 1; i < results.length; i++ ) {
const result = results[i];
const match = /^\/web_accessible_resources\/([^?]+)/.exec(result.url);
if ( match === null ) { continue; }
const name = match[1];
const content = removeTopCommentBlock(result.content);
const details = redirectableResources.get(name);
const entry = RedirectEntry.fromSelfie({
mime: mimeFromName(name),
data: content,
warURL: vAPI.getURL(`/web_accessible_resources/${name}`),
});
this.resources.set(name, entry);
if ( details.alias !== undefined ) {
this.aliases.set(details.alias, name);
}
}
// Additional resources
const content = results[0].content;
'/assets/resources/scriptlets.js'
).then(result => {
const content = result.content;
if ( typeof content === 'string' && content.length !== 0 ) {
this.resourcesFromString(content);
}
}),
];
const store = (name, data = undefined) => {
const details = redirectableResources.get(name);
const entry = RedirectEntry.fromSelfie({
mime: mimeFromName(name),
data,
warURL: vAPI.getURL(`/web_accessible_resources/${name}`),
});
this.resources.set(name, entry);
if ( details.alias !== undefined ) {
this.aliases.set(details.alias, name);
}
};
const processBlob = (name, blob) => {
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = ( ) => {
store(name, reader.result);
resolve();
};
reader.readAsDataURL(blob);
});
};
const processText = (name, text) => {
store(name, removeTopCommentBlock(text));
};
const process = result => {
const match = /^\/web_accessible_resources\/([^?]+)/.exec(result.url);
if ( match === null ) { return; }
const name = match[1];
return result.content instanceof Blob
? processBlob(name, result.content)
: processText(name, result.content);
};
for ( const [ name, details ] of redirectableResources ) {
if ( typeof details.data !== 'string' ) {
store(name);
continue;
}
fetches.push(
µBlock.assets.fetch(
`/web_accessible_resources/${name}${vAPI.warSecret()}`,
{ responseType: details.data }
).then(
result => process(result)
)
);
}
return Promise.all(fetches);
};
/******************************************************************************/