/******************************************************************************* 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 }; /******************************************************************************/