mirror of
https://github.com/gorhill/uBlock.git
synced 2024-11-09 12:22:33 +01:00
62f2a3e68d
Related commit:
bf591d93fb
476 lines
16 KiB
JavaScript
476 lines
16 KiB
JavaScript
/*******************************************************************************
|
|
|
|
uBlock Origin - a browser extension to block requests.
|
|
Copyright (C) 2015-present Raymond Hill
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see {http://www.gnu.org/licenses/}.
|
|
|
|
Home: https://github.com/gorhill/uBlock
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
/******************************************************************************/
|
|
|
|
import redirectableResources from './redirect-resources.js';
|
|
|
|
import {
|
|
LineIterator,
|
|
orphanizeString,
|
|
} from './text-utils.js';
|
|
|
|
/******************************************************************************/
|
|
|
|
const extToMimeMap = new Map([
|
|
[ 'css', 'text/css' ],
|
|
[ 'fn', 'fn/javascript' ], // invented mime type for internal use
|
|
[ 'gif', 'image/gif' ],
|
|
[ 'html', 'text/html' ],
|
|
[ 'js', 'text/javascript' ],
|
|
[ 'mp3', 'audio/mp3' ],
|
|
[ 'mp4', 'video/mp4' ],
|
|
[ 'png', 'image/png' ],
|
|
[ 'txt', 'text/plain' ],
|
|
[ 'xml', 'text/xml' ],
|
|
]);
|
|
|
|
const typeToMimeMap = new Map([
|
|
[ 'main_frame', 'text/html' ],
|
|
[ 'other', 'text/plain' ],
|
|
[ 'script', 'text/javascript' ],
|
|
[ 'stylesheet', 'text/css' ],
|
|
[ 'sub_frame', 'text/html' ],
|
|
[ 'xmlhttprequest', 'text/plain' ],
|
|
]);
|
|
|
|
const validMimes = new Set(extToMimeMap.values());
|
|
|
|
const mimeFromName = name => {
|
|
const match = /\.([^.]+)$/.exec(name);
|
|
if ( match === null ) { return ''; }
|
|
return extToMimeMap.get(match[1]);
|
|
};
|
|
|
|
const removeTopCommentBlock = text => {
|
|
return text.replace(/^\/\*[\S\s]+?\n\*\/\s*/, '');
|
|
};
|
|
|
|
// 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.short
|
|
: ( ) => '';
|
|
|
|
const RESOURCES_SELFIE_VERSION = 7;
|
|
const RESOURCES_SELFIE_NAME = 'compiled/redirectEngine/resources';
|
|
|
|
/******************************************************************************/
|
|
/******************************************************************************/
|
|
|
|
class RedirectEntry {
|
|
constructor() {
|
|
this.mime = '';
|
|
this.data = '';
|
|
this.warURL = undefined;
|
|
this.params = undefined;
|
|
this.requiresTrust = false;
|
|
this.world = 'MAIN';
|
|
this.dependencies = [];
|
|
}
|
|
|
|
// Prevent redirection to web accessible resources when the request is
|
|
// of type 'xmlhttprequest', because XMLHttpRequest.responseURL would
|
|
// cause leakage of extension id. See:
|
|
// - https://stackoverflow.com/a/8056313
|
|
// - https://bugzilla.mozilla.org/show_bug.cgi?id=998076
|
|
// https://www.reddit.com/r/uBlockOrigin/comments/cpxm1v/
|
|
// User-supplied resources may already be base64 encoded.
|
|
|
|
toURL(fctxt, asDataURI = false) {
|
|
if (
|
|
this.warURL !== undefined &&
|
|
asDataURI !== true &&
|
|
fctxt instanceof Object &&
|
|
fctxt.type !== 'xmlhttprequest'
|
|
) {
|
|
const params = [];
|
|
const secret = warSecret();
|
|
if ( secret !== '' ) { params.push(`secret=${secret}`); }
|
|
if ( this.params !== undefined ) {
|
|
for ( const name of this.params ) {
|
|
const value = fctxt[name];
|
|
if ( value === undefined ) { continue; }
|
|
params.push(`${name}=${encodeURIComponent(value)}`);
|
|
}
|
|
}
|
|
let url = `${this.warURL}`;
|
|
if ( params.length !== 0 ) {
|
|
url += `?${params.join('&')}`;
|
|
}
|
|
return url;
|
|
}
|
|
if ( this.data === undefined ) { return; }
|
|
// https://github.com/uBlockOrigin/uBlock-issues/issues/701
|
|
if ( this.data === '' ) {
|
|
const mime = typeToMimeMap.get(fctxt.type);
|
|
if ( mime === '' ) { return; }
|
|
return `data:${mime},`;
|
|
}
|
|
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}`;
|
|
}
|
|
}
|
|
return this.data;
|
|
}
|
|
|
|
toContent() {
|
|
if ( this.data.startsWith('data:') ) {
|
|
const pos = this.data.indexOf(',');
|
|
const base64 = this.data.endsWith(';base64', pos);
|
|
this.data = this.data.slice(pos + 1);
|
|
if ( base64 ) {
|
|
this.data = atob(this.data);
|
|
}
|
|
}
|
|
return this.data;
|
|
}
|
|
|
|
static fromDetails(details) {
|
|
const r = new RedirectEntry();
|
|
Object.assign(r, details);
|
|
return r;
|
|
}
|
|
}
|
|
|
|
/******************************************************************************/
|
|
/******************************************************************************/
|
|
|
|
class RedirectEngine {
|
|
constructor() {
|
|
this.aliases = new Map();
|
|
this.resources = new Map();
|
|
this.reset();
|
|
this.modifyTime = Date.now();
|
|
this.resourceNameRegister = '';
|
|
}
|
|
|
|
reset() {
|
|
}
|
|
|
|
freeze() {
|
|
}
|
|
|
|
tokenToURL(
|
|
fctxt,
|
|
token,
|
|
asDataURI = false
|
|
) {
|
|
const entry = this.resources.get(this.aliases.get(token) || token);
|
|
if ( entry === undefined ) { return; }
|
|
this.resourceNameRegister = token;
|
|
return entry.toURL(fctxt, asDataURI);
|
|
}
|
|
|
|
tokenToDNR(token) {
|
|
const entry = this.resources.get(this.aliases.get(token) || token);
|
|
if ( entry === undefined ) { return; }
|
|
if ( entry.warURL === undefined ) { return; }
|
|
return entry.warURL;
|
|
}
|
|
|
|
hasToken(token) {
|
|
if ( token === 'none' ) { return true; }
|
|
const asDataURI = token.charCodeAt(0) === 0x25 /* '%' */;
|
|
if ( asDataURI ) {
|
|
token = token.slice(1);
|
|
}
|
|
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() {
|
|
}
|
|
|
|
async fromSelfie() {
|
|
return true;
|
|
}
|
|
|
|
contentFromName(name, mime = '') {
|
|
const entry = this.resources.get(this.aliases.get(name) || name);
|
|
if ( entry === undefined ) { return; }
|
|
if ( entry.mime.startsWith(mime) === false ) { return; }
|
|
return {
|
|
js: entry.toContent(),
|
|
world: entry.world,
|
|
dependencies: entry.dependencies.slice(),
|
|
};
|
|
}
|
|
|
|
// https://github.com/uBlockOrigin/uAssets/commit/deefe8755511
|
|
// Consider 'none' a reserved keyword, to be used to disable redirection.
|
|
// https://github.com/uBlockOrigin/uBlock-issues/issues/1419
|
|
// Append newlines to raw text to ensure processing of trailing resource.
|
|
|
|
resourcesFromString(text) {
|
|
const lineIter = new LineIterator(
|
|
removeTopCommentBlock(text) + '\n\n'
|
|
);
|
|
const reNonEmptyLine = /\S/;
|
|
let fields, encoded, details;
|
|
|
|
while ( lineIter.eot() === false ) {
|
|
const line = lineIter.next();
|
|
if ( line.startsWith('#') ) { continue; }
|
|
if ( line.startsWith('// ') ) { continue; }
|
|
|
|
if ( fields === undefined ) {
|
|
if ( line === '' ) { continue; }
|
|
// Modern parser
|
|
if ( line.startsWith('/// ') ) {
|
|
const name = line.slice(4).trim();
|
|
fields = [ name, mimeFromName(name) ];
|
|
continue;
|
|
}
|
|
// Legacy parser
|
|
const head = line.trim().split(/\s+/);
|
|
if ( head.length !== 2 ) { continue; }
|
|
if ( head[0] === 'none' ) { continue; }
|
|
let pos = head[1].indexOf(';');
|
|
if ( pos === -1 ) { pos = head[1].length; }
|
|
if ( validMimes.has(head[1].slice(0, pos)) === false ) {
|
|
continue;
|
|
}
|
|
encoded = head[1].indexOf(';') !== -1;
|
|
fields = head;
|
|
continue;
|
|
}
|
|
|
|
if ( line.startsWith('/// ') ) {
|
|
if ( details === undefined ) {
|
|
details = [];
|
|
}
|
|
const [ prop, value ] = line.slice(4).trim().split(/\s+/);
|
|
if ( value !== undefined ) {
|
|
details.push({ prop, value });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ( reNonEmptyLine.test(line) ) {
|
|
fields.push(encoded ? line.trim() : line);
|
|
continue;
|
|
}
|
|
|
|
// No more data, add the resource.
|
|
const name = this.aliases.get(fields[0]) || fields[0];
|
|
const mime = fields[1];
|
|
const data = orphanizeString(
|
|
fields.slice(2).join(encoded ? '' : '\n')
|
|
);
|
|
this.resources.set(name, RedirectEntry.fromDetails({ mime, data }));
|
|
if ( Array.isArray(details) ) {
|
|
const resource = this.resources.get(name);
|
|
for ( const { prop, value } of details ) {
|
|
switch ( prop ) {
|
|
case 'alias':
|
|
this.aliases.set(value, name);
|
|
break;
|
|
case 'world':
|
|
if ( /^isolated$/i.test(value) === false ) { break; }
|
|
resource.world = 'ISOLATED';
|
|
break;
|
|
case 'dependency':
|
|
if ( this.resources.has(value) === false ) { break; }
|
|
resource.dependencies.push(value);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fields = undefined;
|
|
details = undefined;
|
|
}
|
|
|
|
this.modifyTime = Date.now();
|
|
}
|
|
|
|
loadBuiltinResources(fetcher) {
|
|
this.resources = new Map();
|
|
this.aliases = new Map();
|
|
|
|
const fetches = [
|
|
import('/assets/resources/scriptlets.js').then(module => {
|
|
for ( const scriptlet of module.builtinScriptlets ) {
|
|
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, details.name);
|
|
}
|
|
}
|
|
this.modifyTime = Date.now();
|
|
}),
|
|
];
|
|
|
|
const store = (name, data = undefined) => {
|
|
const details = redirectableResources.get(name);
|
|
const entry = RedirectEntry.fromDetails({
|
|
mime: mimeFromName(name),
|
|
data,
|
|
warURL: `/web_accessible_resources/${name}`,
|
|
params: details.params,
|
|
});
|
|
this.resources.set(name, entry);
|
|
if ( details.alias === undefined ) { return; }
|
|
if ( Array.isArray(details.alias) ) {
|
|
for ( const alias of details.alias ) {
|
|
this.aliases.set(alias, name);
|
|
}
|
|
} else {
|
|
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.onabort = reader.onerror = ( ) => {
|
|
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(
|
|
fetcher(`/web_accessible_resources/${name}`, {
|
|
responseType: details.data
|
|
}).then(
|
|
result => process(result)
|
|
)
|
|
);
|
|
}
|
|
|
|
return Promise.all(fetches);
|
|
}
|
|
|
|
getResourceDetails() {
|
|
const out = new Map([
|
|
[ 'none', { canInject: false, canRedirect: true, aliasOf: '' } ],
|
|
]);
|
|
for ( const [ name, entry ] of this.resources ) {
|
|
out.set(name, {
|
|
canInject: typeof entry.data === 'string',
|
|
canRedirect: entry.warURL !== undefined,
|
|
aliasOf: '',
|
|
extensionPath: entry.warURL,
|
|
});
|
|
}
|
|
for ( const [ alias, name ] of this.aliases ) {
|
|
const original = out.get(name);
|
|
if ( original === undefined ) { continue; }
|
|
const aliased = Object.assign({}, original);
|
|
aliased.aliasOf = name;
|
|
out.set(alias, aliased);
|
|
}
|
|
return Array.from(out).sort((a, b) => {
|
|
return a[0].localeCompare(b[0]);
|
|
});
|
|
}
|
|
|
|
selfieFromResources(storage) {
|
|
storage.put(
|
|
RESOURCES_SELFIE_NAME,
|
|
JSON.stringify({
|
|
version: RESOURCES_SELFIE_VERSION,
|
|
aliases: Array.from(this.aliases),
|
|
resources: Array.from(this.resources),
|
|
})
|
|
);
|
|
}
|
|
|
|
async resourcesFromSelfie(storage) {
|
|
const result = await storage.get(RESOURCES_SELFIE_NAME);
|
|
let selfie;
|
|
try {
|
|
selfie = JSON.parse(result.content);
|
|
} catch(ex) {
|
|
}
|
|
if (
|
|
selfie instanceof Object === false ||
|
|
selfie.version !== RESOURCES_SELFIE_VERSION ||
|
|
Array.isArray(selfie.resources) === false
|
|
) {
|
|
return false;
|
|
}
|
|
this.aliases = new Map(selfie.aliases);
|
|
this.resources = new Map();
|
|
for ( const [ token, entry ] of selfie.resources ) {
|
|
this.resources.set(token, RedirectEntry.fromDetails(entry));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
invalidateResourcesSelfie(storage) {
|
|
storage.remove(RESOURCES_SELFIE_NAME);
|
|
}
|
|
}
|
|
|
|
/******************************************************************************/
|
|
|
|
const redirectEngine = new RedirectEngine();
|
|
|
|
export { redirectEngine };
|
|
|
|
/******************************************************************************/
|