diff options
Diffstat (limited to 'src/js/traffic.js')
-rw-r--r-- | src/js/traffic.js | 1261 |
1 files changed, 1261 insertions, 0 deletions
diff --git a/src/js/traffic.js b/src/js/traffic.js new file mode 100644 index 0000000..bf34fd4 --- /dev/null +++ b/src/js/traffic.js @@ -0,0 +1,1261 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2014-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 +*/ + +/* globals browser */ + +'use strict'; + +/******************************************************************************/ + +import htmlFilteringEngine from './html-filtering.js'; +import httpheaderFilteringEngine from './httpheader-filtering.js'; +import logger from './logger.js'; +import scriptletFilteringEngine from './scriptlet-filtering.js'; +import staticNetFilteringEngine from './static-net-filtering.js'; +import textEncode from './text-encode.js'; +import µb from './background.js'; +import * as sfp from './static-filtering-parser.js'; +import * as fc from './filtering-context.js'; +import { isNetworkURI } from './uri-utils.js'; + +import { + sessionFirewall, + sessionSwitches, + sessionURLFiltering, +} from './filtering-engines.js'; + + +/******************************************************************************/ + +// Platform-specific behavior. + +// https://github.com/uBlockOrigin/uBlock-issues/issues/42 +// https://bugzilla.mozilla.org/show_bug.cgi?id=1376932 +// Add proper version number detection once issue is fixed in Firefox. +let dontCacheResponseHeaders = + vAPI.webextFlavor.soup.has('firefox'); + +// The real actual webextFlavor value may not be set in stone, so listen +// for possible future changes. +window.addEventListener('webextFlavor', function() { + dontCacheResponseHeaders = + vAPI.webextFlavor.soup.has('firefox'); +}, { once: true }); + +/******************************************************************************/ + +const patchLocalRedirectURL = url => url.charCodeAt(0) === 0x2F /* '/' */ + ? vAPI.getURL(url) + : url; + +/******************************************************************************/ + +// Intercept and filter web requests. + +const onBeforeRequest = function(details) { + const fctxt = µb.filteringContext.fromWebrequestDetails(details); + + // Special handling for root document. + // https://github.com/chrisaljoudi/uBlock/issues/1001 + // This must be executed regardless of whether the request is + // behind-the-scene + if ( fctxt.itype === fctxt.MAIN_FRAME ) { + return onBeforeRootFrameRequest(fctxt); + } + + // Special treatment: behind-the-scene requests + const tabId = details.tabId; + if ( tabId < 0 ) { + return onBeforeBehindTheSceneRequest(fctxt); + } + + // Lookup the page store associated with this tab id. + let pageStore = µb.pageStoreFromTabId(tabId); + if ( pageStore === null ) { + const tabContext = µb.tabContextManager.mustLookup(tabId); + if ( tabContext.tabId < 0 ) { + return onBeforeBehindTheSceneRequest(fctxt); + } + vAPI.tabs.onNavigation({ tabId, frameId: 0, url: tabContext.rawURL }); + pageStore = µb.pageStoreFromTabId(tabId); + } + + const result = pageStore.filterRequest(fctxt); + + pageStore.journalAddRequest(fctxt, result); + + if ( logger.enabled ) { + fctxt.setRealm('network').toLogger(); + } + + // Redirected + + if ( fctxt.redirectURL !== undefined ) { + return { redirectUrl: patchLocalRedirectURL(fctxt.redirectURL) }; + } + + // Not redirected + + // Blocked + if ( result === 1 ) { + return { cancel: true }; + } + + // Not blocked + if ( + fctxt.itype === fctxt.SUB_FRAME && + details.parentFrameId !== -1 && + details.aliasURL === undefined + ) { + pageStore.setFrameURL(details); + } + + if ( result === 2 ) { + return { cancel: false }; + } +}; + +/******************************************************************************/ + +const onBeforeRootFrameRequest = function(fctxt) { + const requestURL = fctxt.url; + + // Special handling for root document. + // https://github.com/chrisaljoudi/uBlock/issues/1001 + // This must be executed regardless of whether the request is + // behind-the-scene + const requestHostname = fctxt.getHostname(); + let result = 0; + let logData; + + // If the site is whitelisted, disregard strict blocking + const trusted = µb.getNetFilteringSwitch(requestURL) === false; + if ( trusted ) { + result = 2; + if ( logger.enabled ) { + logData = { engine: 'u', result: 2, raw: 'whitelisted' }; + } + } + + // Permanently unrestricted? + if ( + result === 0 && + sessionSwitches.evaluateZ('no-strict-blocking', requestHostname) + ) { + result = 2; + if ( logger.enabled ) { + logData = { + engine: 'u', + result: 2, + raw: `no-strict-blocking: ${sessionSwitches.z} true` + }; + } + } + + // Temporarily whitelisted? + if ( result === 0 && strictBlockBypasser.isBypassed(requestHostname) ) { + result = 2; + if ( logger.enabled ) { + logData = { + engine: 'u', + result: 2, + raw: 'no-strict-blocking: true (temporary)' + }; + } + } + + // Static filtering + if ( result === 0 ) { + ({ result, logData } = shouldStrictBlock(fctxt, logger.enabled)); + } + + const pageStore = µb.bindTabToPageStore(fctxt.tabId, 'beforeRequest'); + if ( pageStore !== null ) { + pageStore.journalAddRootFrame('uncommitted', requestURL); + pageStore.journalAddRequest(fctxt, result); + } + + if ( logger.enabled ) { + fctxt.setFilter(logData); + } + + // https://github.com/uBlockOrigin/uBlock-issues/issues/760 + // Redirect non-blocked request? + if ( result !== 1 && trusted === false && pageStore !== null ) { + pageStore.redirectNonBlockedRequest(fctxt); + } + + if ( logger.enabled ) { + fctxt.setRealm('network').toLogger(); + } + + // Redirected + + if ( fctxt.redirectURL !== undefined ) { + return { redirectUrl: patchLocalRedirectURL(fctxt.redirectURL) }; + } + + // Not blocked + + if ( result !== 1 ) { return; } + + // No log data means no strict blocking (because we need to report why + // the blocking occurs. + if ( logData === undefined ) { return; } + + // Blocked + + const query = encodeURIComponent(JSON.stringify({ + url: requestURL, + hn: requestHostname, + dn: fctxt.getDomain() || requestHostname, + fs: logData.raw + })); + + vAPI.tabs.replace( + fctxt.tabId, + vAPI.getURL('document-blocked.html?details=') + query + ); + + return { cancel: true }; +}; + +/******************************************************************************/ + +// Strict blocking through static filtering +// +// https://github.com/chrisaljoudi/uBlock/issues/1128 +// Do not block if the match begins after the hostname, +// except when the filter is specifically of type `other`. +// https://github.com/gorhill/uBlock/issues/490 +// Removing this for the time being, will need a new, dedicated type. +// https://github.com/uBlockOrigin/uBlock-issues/issues/1501 +// Support explicit exception filters. +// +// Let result of match for specific `document` type be `rs` +// Let result of match for no specific type be `rg` *after* going through +// confirmation necessary for implicit matches +// Let `important` be `i` +// Let final result be logical combination of `rs` and `rg` as follow: +// +// | rs | +// +--------+--------+--------+--------| +// | 0 | 1 | 1i | 2 | +// --------+--------+--------+--------+--------+--------| +// | 0 | rg | rs | rs | rs | +// rg | 1 | rg | rs | rs | rs | +// | 1i | rg | rg | rs | rg | +// | 2 | rg | rg | rs | rs | +// --------+--------+--------+--------+--------+--------+ + +const shouldStrictBlock = function(fctxt, loggerEnabled) { + const snfe = staticNetFilteringEngine; + + // Explicit filtering: `document` option + const rs = snfe.matchRequest(fctxt, 0b0011); + const is = rs === 1 && snfe.isBlockImportant(); + let lds; + if ( rs !== 0 || loggerEnabled ) { + lds = snfe.toLogData(); + } + + // | rs | + // +--------+--------+--------+--------| + // | 0 | 1 | 1i | 2 | + // --------+--------+--------+--------+--------+--------| + // | 0 | rg | rs | x | rs | + // rg | 1 | rg | rs | x | rs | + // | 1i | rg | rg | x | rg | + // | 2 | rg | rg | x | rs | + // --------+--------+--------+--------+--------+--------+ + if ( rs === 1 && is ) { + return { result: rs, logData: lds }; + } + + // Implicit filtering: no `document` option + fctxt.type = 'no_type'; + let rg = snfe.matchRequest(fctxt, 0b0011); + fctxt.type = 'main_frame'; + const ig = rg === 1 && snfe.isBlockImportant(); + let ldg; + if ( rg !== 0 || loggerEnabled ) { + ldg = snfe.toLogData(); + if ( rg === 1 && validateStrictBlock(fctxt, ldg) === false ) { + rg = 0; ldg = undefined; + } + } + + // | rs | + // +--------+--------+--------+--------| + // | 0 | 1 | 1i | 2 | + // --------+--------+--------+--------+--------+--------| + // | 0 | x | rs | - | rs | + // rg | 1 | x | rs | - | rs | + // | 1i | x | x | - | x | + // | 2 | x | x | - | rs | + // --------+--------+--------+--------+--------+--------+ + if ( rs === 0 || rg === 1 && ig || rg === 2 && rs !== 2 ) { + return { result: rg, logData: ldg }; + } + + // | rs | + // +--------+--------+--------+--------| + // | 0 | 1 | 1i | 2 | + // --------+--------+--------+--------+--------+--------| + // | 0 | - | x | - | x | + // rg | 1 | - | x | - | x | + // | 1i | - | - | - | - | + // | 2 | - | - | - | x | + // --------+--------+--------+--------+--------+--------+ + return { result: rs, logData: lds }; +}; + +/******************************************************************************/ + +// https://github.com/gorhill/uBlock/issues/3208 +// Mind case insensitivity. +// https://github.com/uBlockOrigin/uBlock-issues/issues/1147 +// Do not strict-block if the filter pattern does not contain at least one +// token character. + +const validateStrictBlock = function(fctxt, logData) { + if ( typeof logData.regex !== 'string' ) { return false; } + if ( typeof logData.raw === 'string' && /\w/.test(logData.raw) === false ) { + return false; + } + const url = fctxt.url; + const re = new RegExp(logData.regex, 'i'); + const match = re.exec(url.toLowerCase()); + if ( match === null ) { return false; } + + // https://github.com/chrisaljoudi/uBlock/issues/1128 + // https://github.com/chrisaljoudi/uBlock/issues/1212 + // Verify that the end of the match is anchored to the end of the + // hostname. + // https://github.com/uBlockOrigin/uAssets/issues/7619#issuecomment-653010310 + // Also match FQDN. + const hostname = fctxt.getHostname(); + const hnpos = url.indexOf(hostname); + const hnlen = hostname.length; + const end = match.index + match[0].length - hnpos - hnlen; + return end === 0 || end === 1 || + end === 2 && url.charCodeAt(hnpos + hnlen) === 0x2E /* '.' */; +}; + +/******************************************************************************/ + +// Intercept and filter behind-the-scene requests. + +const onBeforeBehindTheSceneRequest = function(fctxt) { + const pageStore = µb.pageStoreFromTabId(fctxt.tabId); + if ( pageStore === null ) { return; } + + // https://github.com/gorhill/uBlock/issues/3150 + // Ability to globally block CSP reports MUST also apply to + // behind-the-scene network requests. + + let result = 0; + + // https://github.com/uBlockOrigin/uBlock-issues/issues/339 + // Need to also test against `-scheme` since tabOrigin is normalized. + // Not especially elegant but for now this accomplishes the purpose of + // not dealing with network requests fired from a synthetic scope, + // that is unless advanced user mode is enabled. + + if ( + fctxt.tabOrigin.endsWith('-scheme') === false && + isNetworkURI(fctxt.tabOrigin) || + µb.userSettings.advancedUserEnabled || + fctxt.itype === fctxt.CSP_REPORT + ) { + result = pageStore.filterRequest(fctxt); + + // The "any-tab" scope is not whitelist-able, and in such case we must + // use the origin URL as the scope. Most such requests aren't going to + // be blocked, so we test for whitelisting and modify the result only + // when the request is being blocked. + // + // https://github.com/uBlockOrigin/uBlock-issues/issues/1478 + // Also remove potential redirection when request is to be + // whitelisted. + if ( + result === 1 && + µb.getNetFilteringSwitch(fctxt.tabOrigin) === false + ) { + result = 2; + fctxt.redirectURL = undefined; + fctxt.filter = { engine: 'u', result: 2, raw: 'whitelisted' }; + } + } + + // https://github.com/uBlockOrigin/uBlock-issues/issues/1204 + onBeforeBehindTheSceneRequest.journalAddRequest(fctxt, result); + + if ( logger.enabled ) { + fctxt.setRealm('network').toLogger(); + } + + // Redirected + + if ( fctxt.redirectURL !== undefined ) { + return { redirectUrl: patchLocalRedirectURL(fctxt.redirectURL) }; + } + + // Blocked? + + if ( result === 1 ) { + return { cancel: true }; + } +}; + +// https://github.com/uBlockOrigin/uBlock-issues/issues/1204 +// Report the tabless network requests to all page stores matching the +// document origin. This is an approximation, there is unfortunately no +// way to know for sure which exact page triggered a tabless network +// request. + +{ + const pageStores = new Set(); + let hostname = ''; + let pageStoresToken = 0; + + const reset = function() { + hostname = ''; + pageStores.clear(); + pageStoresToken = 0; + }; + + const gc = ( ) => { + if ( pageStoresToken !== µb.pageStoresToken ) { return reset(); } + gcTimer.on(30011); + }; + + const gcTimer = vAPI.defer.create(gc); + + onBeforeBehindTheSceneRequest.journalAddRequest = (fctxt, result) => { + const docHostname = fctxt.getDocHostname(); + if ( + docHostname !== hostname || + pageStoresToken !== µb.pageStoresToken + ) { + hostname = docHostname; + pageStores.clear(); + for ( const pageStore of µb.pageStores.values() ) { + if ( pageStore.tabHostname !== docHostname ) { continue; } + pageStores.add(pageStore); + } + pageStoresToken = µb.pageStoresToken; + gcTimer.offon(30011); + } + for ( const pageStore of pageStores ) { + pageStore.journalAddRequest(fctxt, result); + } + }; +} + +/******************************************************************************/ + +// To handle: +// - Media elements larger than n kB +// - Scriptlet injection (requires ability to modify response body) +// - HTML filtering (requires ability to modify response body) +// - CSP injection + +const onHeadersReceived = function(details) { + + const fctxt = µb.filteringContext.fromWebrequestDetails(details); + const isRootDoc = fctxt.itype === fctxt.MAIN_FRAME; + + let pageStore = µb.pageStoreFromTabId(fctxt.tabId); + if ( pageStore === null ) { + if ( isRootDoc === false ) { return; } + pageStore = µb.bindTabToPageStore(fctxt.tabId, 'beforeRequest'); + } + if ( pageStore.getNetFilteringSwitch(fctxt) === false ) { return; } + + if ( fctxt.itype === fctxt.IMAGE || fctxt.itype === fctxt.MEDIA ) { + const result = foilLargeMediaElement(details, fctxt, pageStore); + if ( result !== undefined ) { return result; } + } + + // Keep in mind response headers will be modified in-place if needed, so + // `details.responseHeaders` will always point to the modified response + // headers. + const { responseHeaders } = details; + if ( Array.isArray(responseHeaders) === false ) { return; } + + if ( isRootDoc === false ) { + const result = pageStore.filterOnHeaders(fctxt, responseHeaders); + if ( result !== 0 ) { + if ( logger.enabled ) { + fctxt.setRealm('network').toLogger(); + } + if ( result === 1 ) { + pageStore.journalAddRequest(fctxt, 1); + return { cancel: true }; + } + } + } + + const mime = mimeFromHeaders(responseHeaders); + + // https://github.com/gorhill/uBlock/issues/2813 + // Disable the blocking of large media elements if the document is itself + // a media element: the resource was not prevented from loading so no + // point to further block large media elements for the current document. + if ( isRootDoc ) { + if ( reMediaContentTypes.test(mime) ) { + pageStore.allowLargeMediaElementsUntil = 0; + // Fall-through: this could be an SVG document, which supports + // script tags. + } + } + + if ( bodyFilterer.canFilter(fctxt, details) ) { + const jobs = []; + // `replace=` filter option + const replaceDirectives = + staticNetFilteringEngine.matchAndFetchModifiers(fctxt, 'replace'); + if ( replaceDirectives ) { + jobs.push({ + fn: textResponseFilterer, + args: [ replaceDirectives ], + }); + } + // html filtering + if ( mime === 'text/html' || mime === 'application/xhtml+xml' ) { + const selectors = htmlFilteringEngine.retrieve(fctxt); + if ( selectors ) { + jobs.push({ + fn: htmlResponseFilterer, + args: [ selectors ], + }); + } + } + if ( jobs.length !== 0 ) { + bodyFilterer.doFilter(fctxt, jobs); + } + } + + let modifiedHeaders = false; + if ( httpheaderFilteringEngine.apply(fctxt, responseHeaders) === true ) { + modifiedHeaders = true; + } + if ( injectCSP(fctxt, pageStore, responseHeaders) === true ) { + modifiedHeaders = true; + } + if ( injectPP(fctxt, pageStore, responseHeaders) === true ) { + modifiedHeaders = true; + } + + // https://bugzilla.mozilla.org/show_bug.cgi?id=1376932 + // Prevent document from being cached by the browser if we modified it, + // either through HTML filtering and/or modified response headers. + // https://github.com/uBlockOrigin/uBlock-issues/issues/229 + // Use `no-cache` instead of `no-cache, no-store, must-revalidate`, this + // allows Firefox's offline mode to work as expected. + if ( modifiedHeaders && dontCacheResponseHeaders ) { + const cacheControl = µb.hiddenSettings.cacheControlForFirefox1376932; + if ( cacheControl !== 'unset' ) { + let i = headerIndexFromName('cache-control', responseHeaders); + if ( i !== -1 ) { + responseHeaders[i].value = cacheControl; + } else { + responseHeaders.push({ name: 'Cache-Control', value: cacheControl }); + } + modifiedHeaders = true; + } + } + + if ( modifiedHeaders ) { + return { responseHeaders }; + } +}; + +const reMediaContentTypes = /^(?:audio|image|video)\//; + +/******************************************************************************/ + +const mimeFromHeaders = headers => { + if ( Array.isArray(headers) === false ) { return ''; } + return mimeFromContentType(headerValueFromName('content-type', headers)); +}; + +const mimeFromContentType = contentType => { + const match = reContentTypeMime.exec(contentType); + if ( match === null ) { return ''; } + return match[0].toLowerCase(); +}; + +const reContentTypeMime = /^[^;]+/i; + +/******************************************************************************/ + +function textResponseFilterer(session, directives) { + const applied = []; + for ( const directive of directives ) { + if ( directive.refs instanceof Object === false ) { continue; } + if ( directive.result !== 1 ) { + applied.push(directive); + continue; + } + const { refs } = directive; + if ( refs.$cache === null ) { + refs.$cache = sfp.parseReplaceValue(refs.value); + } + const cache = refs.$cache; + if ( cache === undefined ) { continue; } + cache.re.lastIndex = 0; + if ( cache.re.test(session.getString()) !== true ) { continue; } + cache.re.lastIndex = 0; + session.setString(session.getString().replace( + cache.re, + cache.replacement + )); + applied.push(directive); + } + if ( applied.length === 0 ) { return; } + if ( logger.enabled !== true ) { return; } + session.setRealm('network') + .pushFilters(applied.map(a => a.logData())) + .toLogger(); +} + +/******************************************************************************/ + +function htmlResponseFilterer(session, selectors) { + if ( htmlResponseFilterer.domParser === null ) { + htmlResponseFilterer.domParser = new DOMParser(); + htmlResponseFilterer.xmlSerializer = new XMLSerializer(); + } + + const doc = htmlResponseFilterer.domParser.parseFromString( + session.getString(), + session.mime + ); + + if ( selectors === undefined ) { return; } + if ( htmlFilteringEngine.apply(doc, session, selectors) !== true ) { return; } + + // https://stackoverflow.com/questions/6088972/get-doctype-of-an-html-as-string-with-javascript/10162353#10162353 + const doctypeStr = [ + doc.doctype instanceof Object ? + htmlResponseFilterer.xmlSerializer.serializeToString(doc.doctype) + '\n' : + '', + doc.documentElement.outerHTML, + ].join('\n'); + session.setString(doctypeStr); +} +htmlResponseFilterer.domParser = null; +htmlResponseFilterer.xmlSerializer = null; + + +/******************************************************************************* + + The response body filterer is responsible for: + + - Realize static network filter option `replace=` + - HTML filtering + +**/ + +const bodyFilterer = (( ) => { + const sessions = new Map(); + const reContentTypeCharset = /charset=['"]?([^'" ]+)/i; + const otherValidMimes = new Set([ + 'application/javascript', + 'application/json', + 'application/mpegurl', + 'application/vnd.api+json', + 'application/vnd.apple.mpegurl', + 'application/vnd.apple.mpegurl.audio', + 'application/x-javascript', + 'application/x-mpegurl', + 'application/xhtml+xml', + 'application/xml', + 'audio/mpegurl', + 'audio/x-mpegurl', + ]); + const BINARY_TYPES = fc.FONT | fc.IMAGE | fc.MEDIA | fc.WEBSOCKET; + const MAX_BUFFER_LENGTH = 3 * 1024 * 1024; + + let textDecoder, textEncoder; + let mime = ''; + let charset = ''; + + const contentTypeFromDetails = details => { + switch ( details.type ) { + case 'script': + return 'text/javascript; charset=utf-8'; + case 'stylesheet': + return 'text/css'; + default: + break; + } + return ''; + }; + + const charsetFromContentType = contentType => { + const match = reContentTypeCharset.exec(contentType); + if ( match === null ) { return; } + return match[1].toLowerCase(); + }; + + const charsetFromMime = mime => { + switch ( mime ) { + case 'application/xml': + case 'application/xhtml+xml': + case 'text/html': + case 'text/css': + return; + default: + break; + } + return 'utf-8'; + }; + + const charsetFromStream = bytes => { + if ( bytes.length < 3 ) { return; } + if ( bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ) { + return 'utf-8'; + } + let i = -1; + while ( i < 65536 ) { + i += 1; + /* c */ if ( bytes[i+0] !== 0x63 ) { continue; } + /* h */ if ( bytes[i+1] !== 0x68 ) { continue; } + /* a */ if ( bytes[i+2] !== 0x61 ) { continue; } + /* r */ if ( bytes[i+3] !== 0x72 ) { continue; } + /* s */ if ( bytes[i+4] !== 0x73 ) { continue; } + /* e */ if ( bytes[i+5] !== 0x65 ) { continue; } + /* t */ if ( bytes[i+6] !== 0x74 ) { continue; } + break; + } + if ( (i - 40) >= 65536 ) { return; } + i += 8; + // find first alpha character + let j = -1; + while ( j < 8 ) { + j += 1; + const c = bytes[i+j]; + if ( c >= 0x41 && c <= 0x5A ) { break; } + if ( c >= 0x61 && c <= 0x7A ) { break; } + } + if ( j === 8 ) { return; } + i += j; + // Collect characters until first non charset-name-character + const chars = []; + j = 0; + while ( j < 24 ) { + const c = bytes[i+j]; + if ( c < 0x2D ) { break; } + if ( c > 0x2D && c < 0x30 ) { break; } + if ( c > 0x39 && c < 0x41 ) { break; } + if ( c > 0x5A && c < 0x61 ) { break; } + if ( c > 0x7A ) { break; } + chars.push(c); + j += 1; + } + if ( j === 20 ) { return; } + return String.fromCharCode(...chars).toLowerCase(); + }; + + const streamClose = (session, buffer) => { + if ( buffer !== undefined ) { + session.stream.write(buffer); + } else if ( session.buffer !== undefined ) { + session.stream.write(session.buffer); + } + session.stream.close(); + }; + + const onStreamData = function(ev) { + const session = sessions.get(this); + if ( session === undefined ) { + this.write(ev.data); + this.disconnect(); + return; + } + if ( this.status !== 'transferringdata' ) { + if ( this.status !== 'finishedtransferringdata' ) { + sessions.delete(this); + this.disconnect(); + return; + } + } + if ( session.buffer === null ) { + session.buffer = new Uint8Array(ev.data); + return; + } + const buffer = new Uint8Array( + session.buffer.byteLength + ev.data.byteLength + ); + buffer.set(session.buffer); + buffer.set(new Uint8Array(ev.data), session.buffer.byteLength); + session.buffer = buffer; + if ( session.buffer.length >= MAX_BUFFER_LENGTH ) { + sessions.delete(this); + this.write(session.buffer); + this.disconnect(); + } + }; + + const onStreamStop = function() { + const session = sessions.get(this); + sessions.delete(this); + if ( session === undefined || session.buffer === null ) { + this.close(); + return; + } + if ( this.status !== 'finishedtransferringdata' ) { return; } + + // If encoding is still unknown, try to extract from stream data + if ( session.charset === undefined ) { + const charsetFound = charsetFromStream(session.buffer); + if ( charsetFound === undefined ) { return streamClose(session); } + const charsetUsed = textEncode.normalizeCharset(charsetFound); + if ( charsetUsed === undefined ) { return streamClose(session); } + session.charset = charsetUsed; + } + + while ( session.jobs.length !== 0 ) { + const job = session.jobs.shift(); + job.fn(session, ...job.args); + } + if ( session.modified !== true ) { return streamClose(session); } + + if ( textEncoder === undefined ) { + textEncoder = new TextEncoder(); + } + let encodedStream = textEncoder.encode(session.str); + + if ( session.charset !== 'utf-8' ) { + encodedStream = textEncode.encode(session.charset, encodedStream); + } + + streamClose(session, encodedStream); + }; + + const onStreamError = function() { + sessions.delete(this); + }; + + return class Session extends µb.FilteringContext { + constructor(fctxt, mime, charset, jobs) { + super(fctxt); + this.stream = null; + this.buffer = null; + this.mime = mime; + this.charset = charset; + this.str = null; + this.modified = false; + this.jobs = jobs; + } + getString() { + if ( this.str !== null ) { return this.str; } + if ( textDecoder !== undefined ) { + if ( textDecoder.encoding !== this.charset ) { + textDecoder = undefined; + } + } + if ( textDecoder === undefined ) { + textDecoder = new TextDecoder(this.charset); + } + this.str = textDecoder.decode(this.buffer); + return this.str; + } + setString(s) { + this.str = s; + this.modified = true; + } + static doFilter(fctxt, jobs) { + if ( jobs.length === 0 ) { return; } + const session = new Session(fctxt, mime, charset, jobs); + session.stream = browser.webRequest.filterResponseData(session.id); + session.stream.ondata = onStreamData; + session.stream.onstop = onStreamStop; + session.stream.onerror = onStreamError; + sessions.set(session.stream, session); + } + static canFilter(fctxt, details) { + if ( µb.canFilterResponseData !== true ) { return; } + + if ( (fctxt.itype & BINARY_TYPES) !== 0 ) { return; } + + if ( fctxt.method !== fc.METHOD_GET ) { + if ( fctxt.method !== fc.METHOD_POST ) { + return; + } + } + + // https://github.com/gorhill/uBlock/issues/3478 + const statusCode = details.statusCode || 0; + if ( statusCode === 0 ) { return; } + + const hostname = fctxt.getHostname(); + if ( hostname === '' ) { return; } + + // https://bugzilla.mozilla.org/show_bug.cgi?id=1426789 + const headers = details.responseHeaders; + const disposition = headerValueFromName('content-disposition', headers); + if ( disposition !== '' ) { + if ( disposition.startsWith('inline') === false ) { return; } + } + + mime = 'text/plain'; + charset = 'utf-8'; + const contentType = headerValueFromName('content-type', headers) || + contentTypeFromDetails(details); + if ( contentType !== '' ) { + mime = mimeFromContentType(contentType); + if ( mime === undefined ) { return; } + if ( mime.startsWith('text/') === false ) { + if ( otherValidMimes.has(mime) === false ) { return; } + } + charset = charsetFromContentType(contentType); + if ( charset !== undefined ) { + charset = textEncode.normalizeCharset(charset); + if ( charset === undefined ) { return; } + } else { + charset = charsetFromMime(mime); + } + } + + return true; + } + }; +})(); + +/******************************************************************************/ + +const injectCSP = function(fctxt, pageStore, responseHeaders) { + const cspSubsets = []; + const requestType = fctxt.type; + + // Start collecting policies >>>>>>>> + + // ======== built-in policies + + const builtinDirectives = []; + + if ( pageStore.filterScripting(fctxt, true) === 1 ) { + builtinDirectives.push(µb.cspNoScripting); + if ( logger.enabled ) { + fctxt.setRealm('network').setType('scripting').toLogger(); + } + } + // https://github.com/uBlockOrigin/uBlock-issues/issues/422 + // We need to derive a special context for filtering `inline-script`, + // as the embedding document for this "resource" will always be the + // frame itself, not that of the parent of the frame. + else { + const fctxt2 = fctxt.duplicate(); + fctxt2.type = 'inline-script'; + fctxt2.setDocOriginFromURL(fctxt.url); + const result = pageStore.filterRequest(fctxt2); + if ( result === 1 ) { + builtinDirectives.push(µb.cspNoInlineScript); + } + if ( result === 2 && logger.enabled ) { + fctxt2.setRealm('network').toLogger(); + } + } + + // https://github.com/gorhill/uBlock/issues/1539 + // - Use a CSP to also forbid inline fonts if remote fonts are blocked. + fctxt.type = 'inline-font'; + if ( pageStore.filterRequest(fctxt) === 1 ) { + builtinDirectives.push(µb.cspNoInlineFont); + if ( logger.enabled ) { + fctxt.setRealm('network').toLogger(); + } + } + + if ( builtinDirectives.length !== 0 ) { + cspSubsets[0] = builtinDirectives.join(', '); + } + + // ======== filter-based policies + + // Static filtering. + + fctxt.type = requestType; + const staticDirectives = + staticNetFilteringEngine.matchAndFetchModifiers(fctxt, 'csp'); + if ( staticDirectives !== undefined ) { + for ( const directive of staticDirectives ) { + if ( directive.result !== 1 ) { continue; } + cspSubsets.push(directive.value); + } + } + + // URL filtering `allow` rules override static filtering. + if ( + cspSubsets.length !== 0 && + sessionURLFiltering.evaluateZ( + fctxt.getTabHostname(), + fctxt.url, + 'csp' + ) === 2 + ) { + if ( logger.enabled ) { + fctxt.setRealm('network') + .setType('csp') + .setFilter(sessionURLFiltering.toLogData()) + .toLogger(); + } + return; + } + + // Dynamic filtering `allow` rules override static filtering. + if ( + cspSubsets.length !== 0 && + µb.userSettings.advancedUserEnabled && + sessionFirewall.evaluateCellZY( + fctxt.getTabHostname(), + fctxt.getTabHostname(), + '*' + ) === 2 + ) { + if ( logger.enabled ) { + fctxt.setRealm('network') + .setType('csp') + .setFilter(sessionFirewall.toLogData()) + .toLogger(); + } + return; + } + + // <<<<<<<< All policies have been collected + + // Static CSP policies will be applied. + + if ( logger.enabled && staticDirectives !== undefined ) { + fctxt.setRealm('network') + .pushFilters(staticDirectives.map(a => a.logData())) + .toLogger(); + } + + if ( cspSubsets.length === 0 ) { return; } + + µb.updateToolbarIcon(fctxt.tabId, 0b0010); + + // Use comma to merge CSP directives. + // Ref.: https://www.w3.org/TR/CSP2/#implementation-considerations + // + // https://github.com/gorhill/uMatrix/issues/967 + // Inject a new CSP header rather than modify an existing one, except + // if the current environment does not support merging headers: + // Firefox 58/webext and less can't merge CSP headers, so we will merge + // them here. + + responseHeaders.push({ + name: 'Content-Security-Policy', + value: cspSubsets.join(', ') + }); + + return true; +}; + +/******************************************************************************/ + +const injectPP = function(fctxt, pageStore, responseHeaders) { + const permissions = []; + const directives = staticNetFilteringEngine.matchAndFetchModifiers(fctxt, 'permissions'); + if ( directives !== undefined ) { + for ( const directive of directives ) { + if ( directive.result !== 1 ) { continue; } + permissions.push(directive.value.replace('|', ', ')); + } + } + + if ( logger.enabled && directives !== undefined ) { + fctxt.setRealm('network') + .pushFilters(directives.map(a => a.logData())) + .toLogger(); + } + + if ( permissions.length === 0 ) { return; } + + µb.updateToolbarIcon(fctxt.tabId, 0x02); + + responseHeaders.push({ + name: 'permissions-policy', + value: permissions.join(', ') + }); + + return true; +}; + +/******************************************************************************/ + +// https://github.com/gorhill/uBlock/issues/1163 +// "Block elements by size". +// https://github.com/gorhill/uBlock/issues/1390#issuecomment-187310719 +// Do not foil when the media element is fetched from the browser +// cache. This works only when the webext API supports the `fromCache` +// property (Firefox). + +const foilLargeMediaElement = function(details, fctxt, pageStore) { + if ( details.fromCache === true ) { return; } + + let size = 0; + if ( µb.userSettings.largeMediaSize !== 0 ) { + const headers = details.responseHeaders; + const i = headerIndexFromName('content-length', headers); + if ( i === -1 ) { return; } + size = parseInt(headers[i].value, 10) || 0; + } + + const result = pageStore.filterLargeMediaElement(fctxt, size); + if ( result === 0 ) { return; } + + if ( logger.enabled ) { + fctxt.setRealm('network').toLogger(); + } + + return { cancel: true }; +}; + +/******************************************************************************/ + +// Caller must ensure headerName is normalized to lower case. + +const headerIndexFromName = function(headerName, headers) { + let i = headers.length; + while ( i-- ) { + if ( headers[i].name.toLowerCase() === headerName ) { + return i; + } + } + return -1; +}; + +const headerValueFromName = function(headerName, headers) { + const i = headerIndexFromName(headerName, headers); + return i !== -1 ? headers[i].value : ''; +}; + +/******************************************************************************/ + +const strictBlockBypasser = { + hostnameToDeadlineMap: new Map(), + cleanupTimer: vAPI.defer.create(( ) => { + strictBlockBypasser.cleanup(); + }), + + cleanup: function() { + for ( const [ hostname, deadline ] of this.hostnameToDeadlineMap ) { + if ( deadline <= Date.now() ) { + this.hostnameToDeadlineMap.delete(hostname); + } + } + }, + + revokeTime: function() { + return Date.now() + µb.hiddenSettings.strictBlockingBypassDuration * 1000; + }, + + bypass: function(hostname) { + if ( typeof hostname !== 'string' || hostname === '' ) { return; } + this.hostnameToDeadlineMap.set(hostname, this.revokeTime()); + }, + + isBypassed: function(hostname) { + if ( this.hostnameToDeadlineMap.size === 0 ) { return false; } + this.cleanupTimer.on({ sec: µb.hiddenSettings.strictBlockingBypassDuration + 10 }); + for (;;) { + const deadline = this.hostnameToDeadlineMap.get(hostname); + if ( deadline !== undefined ) { + if ( deadline > Date.now() ) { + this.hostnameToDeadlineMap.set(hostname, this.revokeTime()); + return true; + } + this.hostnameToDeadlineMap.delete(hostname); + } + const pos = hostname.indexOf('.'); + if ( pos === -1 ) { break; } + hostname = hostname.slice(pos + 1); + } + return false; + } +}; + +/******************************************************************************/ + +// https://github.com/uBlockOrigin/uBlock-issues/issues/2350 +// Added scriptlet injection attempt at onResponseStarted time as per +// https://github.com/AdguardTeam/AdguardBrowserExtension/issues/1029 and +// https://github.com/AdguardTeam/AdguardBrowserExtension/blob/9ab85be5/Extension/src/background/webrequest.js#L620 + +const webRequest = { + onBeforeRequest, + + start: (( ) => { + vAPI.net = new vAPI.Net(); + if ( vAPI.Net.canSuspend() ) { + vAPI.net.suspend(); + } + + return ( ) => { + vAPI.net.setSuspendableListener(onBeforeRequest); + vAPI.net.addListener( + 'onHeadersReceived', + onHeadersReceived, + { urls: [ 'http://*/*', 'https://*/*' ] }, + [ 'blocking', 'responseHeaders' ] + ); + vAPI.net.addListener( + 'onResponseStarted', + details => { + if ( details.tabId === -1 ) { return; } + const pageStore = µb.pageStoreFromTabId(details.tabId); + if ( pageStore === null ) { return; } + if ( pageStore.getNetFilteringSwitch() === false ) { return; } + scriptletFilteringEngine.injectNow(details); + }, + { + types: [ 'main_frame', 'sub_frame' ], + urls: [ 'http://*/*', 'https://*/*' ] + } + ); + vAPI.defer.once({ sec: µb.hiddenSettings.toolbarWarningTimeout }).then(( ) => { + if ( vAPI.net.hasUnprocessedRequest() === false ) { return; } + vAPI.net.removeUnprocessedRequest(); + return vAPI.tabs.getCurrent(); + }).then(tab => { + if ( tab instanceof Object === false ) { return; } + µb.updateToolbarIcon(tab.id, 0b0110); + }); + vAPI.net.unsuspend({ all: true }); + }; + })(), + + strictBlockBypass: hostname => { + strictBlockBypasser.bypass(hostname); + }, +}; + +/******************************************************************************/ + +export default webRequest; + +/******************************************************************************/ |