diff --git a/src/js/resources/prevent-xhr.js b/src/js/resources/prevent-xhr.js new file mode 100644 index 000000000..e25a7e895 --- /dev/null +++ b/src/js/resources/prevent-xhr.js @@ -0,0 +1,270 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2019-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 + +*/ + +import { + generateContentFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, +} from './utils.js'; +import { proxyApplyFn } from './proxy-apply.js'; +import { registerScriptlet } from './base.js'; +import { safeSelf } from './safe-self.js'; + +// Externally added to the private namespace in which scriptlets execute. +/* global scriptletGlobals */ + +/******************************************************************************/ + +function preventXhrFn( + trusted = false, + propsToMatch = '', + directive = '' +) { + if ( typeof propsToMatch !== 'string' ) { return; } + const safe = safeSelf(); + const scriptletName = trusted ? 'trusted-prevent-xhr' : 'prevent-xhr'; + const logPrefix = safe.makeLogPrefix(scriptletName, propsToMatch, directive); + const xhrInstances = new WeakMap(); + const propNeedles = parsePropertiesToMatchFn(propsToMatch, 'url'); + const warOrigin = scriptletGlobals.warOrigin; + const safeDispatchEvent = (xhr, type) => { + try { + xhr.dispatchEvent(new Event(type)); + } catch { + } + }; + proxyApplyFn('XMLHttpRequest.prototype.open', function(context) { + const { thisArg, callArgs } = context; + xhrInstances.delete(thisArg); + const [ method, url, ...args ] = callArgs; + if ( warOrigin !== undefined && url.startsWith(warOrigin) ) { + return context.reflect(); + } + const haystack = { method, url }; + if ( propsToMatch === '' && directive === '' ) { + safe.uboLog(logPrefix, `Called: ${safe.JSON_stringify(haystack, null, 2)}`); + return context.reflect(); + } + if ( matchObjectPropertiesFn(propNeedles, haystack) ) { + const xhrDetails = Object.assign(haystack, { + xhr: thisArg, + defer: args.length === 0 || !!args[0], + directive, + headers: { + 'date': '', + 'content-type': '', + 'content-length': '', + }, + url: haystack.url, + props: { + response: { value: '' }, + responseText: { value: '' }, + responseXML: { value: null }, + }, + }); + xhrInstances.set(thisArg, xhrDetails); + } + return context.reflect(); + }); + proxyApplyFn('XMLHttpRequest.prototype.send', function(context) { + const { thisArg } = context; + const xhrDetails = xhrInstances.get(thisArg); + if ( xhrDetails === undefined ) { + return context.reflect(); + } + xhrDetails.headers['date'] = (new Date()).toUTCString(); + let xhrText = ''; + switch ( thisArg.responseType ) { + case 'arraybuffer': + xhrDetails.props.response.value = new ArrayBuffer(0); + xhrDetails.headers['content-type'] = 'application/octet-stream'; + break; + case 'blob': + xhrDetails.props.response.value = new Blob([]); + xhrDetails.headers['content-type'] = 'application/octet-stream'; + break; + case 'document': { + const parser = new DOMParser(); + const doc = parser.parseFromString('', 'text/html'); + xhrDetails.props.response.value = doc; + xhrDetails.props.responseXML.value = doc; + xhrDetails.headers['content-type'] = 'text/html'; + break; + } + case 'json': + xhrDetails.props.response.value = {}; + xhrDetails.props.responseText.value = '{}'; + xhrDetails.headers['content-type'] = 'application/json'; + break; + default: { + if ( directive === '' ) { break; } + xhrText = generateContentFn(trusted, xhrDetails.directive); + if ( xhrText instanceof Promise ) { + xhrText = xhrText.then(text => { + xhrDetails.props.response.value = text; + xhrDetails.props.responseText.value = text; + }); + } else { + xhrDetails.props.response.value = xhrText; + xhrDetails.props.responseText.value = xhrText; + } + xhrDetails.headers['content-type'] = 'text/plain'; + break; + } + } + if ( xhrDetails.defer === false ) { + xhrDetails.headers['content-length'] = `${xhrDetails.props.response.value}`.length; + Object.defineProperties(xhrDetails.xhr, { + readyState: { value: 4 }, + responseURL: { value: xhrDetails.url }, + status: { value: 200 }, + statusText: { value: 'OK' }, + }); + Object.defineProperties(xhrDetails.xhr, xhrDetails.props); + return; + } + Promise.resolve(xhrText).then(( ) => xhrDetails).then(details => { + Object.defineProperties(details.xhr, { + readyState: { value: 1, configurable: true }, + responseURL: { value: xhrDetails.url }, + }); + safeDispatchEvent(details.xhr, 'readystatechange'); + return details; + }).then(details => { + xhrDetails.headers['content-length'] = `${details.props.response.value}`.length; + Object.defineProperties(details.xhr, { + readyState: { value: 2, configurable: true }, + status: { value: 200 }, + statusText: { value: 'OK' }, + }); + safeDispatchEvent(details.xhr, 'readystatechange'); + return details; + }).then(details => { + Object.defineProperties(details.xhr, { + readyState: { value: 3, configurable: true }, + }); + Object.defineProperties(details.xhr, details.props); + safeDispatchEvent(details.xhr, 'readystatechange'); + return details; + }).then(details => { + Object.defineProperties(details.xhr, { + readyState: { value: 4 }, + }); + safeDispatchEvent(details.xhr, 'readystatechange'); + safeDispatchEvent(details.xhr, 'load'); + safeDispatchEvent(details.xhr, 'loadend'); + safe.uboLog(logPrefix, `Prevented with response:\n${details.xhr.response}`); + }); + }); + proxyApplyFn('XMLHttpRequest.prototype.getResponseHeader', function(context) { + const { thisArg } = context; + const xhrDetails = xhrInstances.get(thisArg); + if ( xhrDetails === undefined || thisArg.readyState < thisArg.HEADERS_RECEIVED ) { + return context.reflect(); + } + const headerName = `${context.callArgs[0]}`; + const value = xhrDetails.headers[headerName.toLowerCase()]; + if ( value !== undefined && value !== '' ) { return value; } + return null; + }); + proxyApplyFn('XMLHttpRequest.prototype.getAllResponseHeaders', function(context) { + const { thisArg } = context; + const xhrDetails = xhrInstances.get(thisArg); + if ( xhrDetails === undefined || thisArg.readyState < thisArg.HEADERS_RECEIVED ) { + return context.reflect(); + } + const out = []; + for ( const [ name, value ] of Object.entries(xhrDetails.headers) ) { + if ( !value ) { continue; } + out.push(`${name}: ${value}`); + } + if ( out.length !== 0 ) { out.push(''); } + return out.join('\r\n'); + }); +} +registerScriptlet(preventXhrFn, { + name: 'prevent-xhr.fn', + dependencies: [ + generateContentFn, + matchObjectPropertiesFn, + parsePropertiesToMatchFn, + proxyApplyFn, + safeSelf, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet prevent-xhr + * + * @description + * Prevent a XMLHttpRequest-baesed request from being sent to a remote server. + * + * @param propsToMatch + * The fetch arguments to match for the prevention to be triggered. The + * untrusted flavor limits the realm of response to return to safe values. + * + * @param [responseBody] + * Optional. The reponse to return when the prevention occurs. The response + * must be a safe constant value. + * + * */ + +function preventXhr(...args) { + return preventXhrFn(false, ...args); +} +registerScriptlet(preventXhr, { + name: 'prevent-xhr.js', + aliases: [ + 'no-xhr-if.js', + ], + dependencies: [ + preventXhrFn, + ], +}); + +/******************************************************************************/ +/** + * @scriptlet trusted-prevent-xhr + * + * @description + * Prevent a XMLHttpRequest-based request from being sent to a remote server. + * + * @param propsToMatch + * The fetch arguments to match for the prevention to be triggered. The + * untrusted flavor limits the realm of response to return to safe values. + * + * @param [responseBody] + * Optional. The reponse to return when the prevention occurs. The trusted + * version allows arbitrary response. + * + * */ + +function trustedPreventXhr(...args) { + return preventXhrFn(true, ...args); +} +registerScriptlet(trustedPreventXhr, { + name: 'trusted-prevent-xhr.js', + dependencies: [ + preventXhrFn, + ], +}); diff --git a/src/js/resources/scriptlets.js b/src/js/resources/scriptlets.js index 746ac17e7..687aa69c1 100755 --- a/src/js/resources/scriptlets.js +++ b/src/js/resources/scriptlets.js @@ -32,12 +32,12 @@ import './prevent-dialog.js'; import './prevent-fetch.js'; import './prevent-innerHTML.js'; import './prevent-settimeout.js'; +import './prevent-xhr.js'; import './replace-argument.js'; import './spoof-css.js'; import { collateFetchArgumentsFn, - generateContentFn, getExceptionTokenFn, getRandomTokenFn, matchObjectPropertiesFn, @@ -381,196 +381,6 @@ function replaceFetchResponseFn( }); } -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'prevent-xhr.fn', - fn: preventXhrFn, - dependencies: [ - 'generate-content.fn', - 'match-object-properties.fn', - 'parse-properties-to-match.fn', - 'safe-self.fn', - ], -}); -function preventXhrFn( - trusted = false, - propsToMatch = '', - directive = '' -) { - if ( typeof propsToMatch !== 'string' ) { return; } - const safe = safeSelf(); - const scriptletName = trusted ? 'trusted-prevent-xhr' : 'prevent-xhr'; - const logPrefix = safe.makeLogPrefix(scriptletName, propsToMatch, directive); - const xhrInstances = new WeakMap(); - const propNeedles = parsePropertiesToMatchFn(propsToMatch, 'url'); - const warOrigin = scriptletGlobals.warOrigin; - const safeDispatchEvent = (xhr, type) => { - try { - xhr.dispatchEvent(new Event(type)); - } catch { - } - }; - const XHRBefore = XMLHttpRequest.prototype; - self.XMLHttpRequest = class extends self.XMLHttpRequest { - open(method, url, ...args) { - xhrInstances.delete(this); - if ( warOrigin !== undefined && url.startsWith(warOrigin) ) { - return super.open(method, url, ...args); - } - const haystack = { method, url }; - if ( propsToMatch === '' && directive === '' ) { - safe.uboLog(logPrefix, `Called: ${safe.JSON_stringify(haystack, null, 2)}`); - return super.open(method, url, ...args); - } - if ( matchObjectPropertiesFn(propNeedles, haystack) ) { - const xhrDetails = Object.assign(haystack, { - xhr: this, - defer: args.length === 0 || !!args[0], - directive, - headers: { - 'date': '', - 'content-type': '', - 'content-length': '', - }, - url: haystack.url, - props: { - response: { value: '' }, - responseText: { value: '' }, - responseXML: { value: null }, - }, - }); - xhrInstances.set(this, xhrDetails); - } - return super.open(method, url, ...args); - } - send(...args) { - const xhrDetails = xhrInstances.get(this); - if ( xhrDetails === undefined ) { - return super.send(...args); - } - xhrDetails.headers['date'] = (new Date()).toUTCString(); - let xhrText = ''; - switch ( this.responseType ) { - case 'arraybuffer': - xhrDetails.props.response.value = new ArrayBuffer(0); - xhrDetails.headers['content-type'] = 'application/octet-stream'; - break; - case 'blob': - xhrDetails.props.response.value = new Blob([]); - xhrDetails.headers['content-type'] = 'application/octet-stream'; - break; - case 'document': { - const parser = new DOMParser(); - const doc = parser.parseFromString('', 'text/html'); - xhrDetails.props.response.value = doc; - xhrDetails.props.responseXML.value = doc; - xhrDetails.headers['content-type'] = 'text/html'; - break; - } - case 'json': - xhrDetails.props.response.value = {}; - xhrDetails.props.responseText.value = '{}'; - xhrDetails.headers['content-type'] = 'application/json'; - break; - default: { - if ( directive === '' ) { break; } - xhrText = generateContentFn(trusted, xhrDetails.directive); - if ( xhrText instanceof Promise ) { - xhrText = xhrText.then(text => { - xhrDetails.props.response.value = text; - xhrDetails.props.responseText.value = text; - }); - } else { - xhrDetails.props.response.value = xhrText; - xhrDetails.props.responseText.value = xhrText; - } - xhrDetails.headers['content-type'] = 'text/plain'; - break; - } - } - if ( xhrDetails.defer === false ) { - xhrDetails.headers['content-length'] = `${xhrDetails.props.response.value}`.length; - Object.defineProperties(xhrDetails.xhr, { - readyState: { value: 4 }, - responseURL: { value: xhrDetails.url }, - status: { value: 200 }, - statusText: { value: 'OK' }, - }); - Object.defineProperties(xhrDetails.xhr, xhrDetails.props); - return; - } - Promise.resolve(xhrText).then(( ) => xhrDetails).then(details => { - Object.defineProperties(details.xhr, { - readyState: { value: 1, configurable: true }, - responseURL: { value: xhrDetails.url }, - }); - safeDispatchEvent(details.xhr, 'readystatechange'); - return details; - }).then(details => { - xhrDetails.headers['content-length'] = `${details.props.response.value}`.length; - Object.defineProperties(details.xhr, { - readyState: { value: 2, configurable: true }, - status: { value: 200 }, - statusText: { value: 'OK' }, - }); - safeDispatchEvent(details.xhr, 'readystatechange'); - return details; - }).then(details => { - Object.defineProperties(details.xhr, { - readyState: { value: 3, configurable: true }, - }); - Object.defineProperties(details.xhr, details.props); - safeDispatchEvent(details.xhr, 'readystatechange'); - return details; - }).then(details => { - Object.defineProperties(details.xhr, { - readyState: { value: 4 }, - }); - safeDispatchEvent(details.xhr, 'readystatechange'); - safeDispatchEvent(details.xhr, 'load'); - safeDispatchEvent(details.xhr, 'loadend'); - safe.uboLog(logPrefix, `Prevented with response:\n${details.xhr.response}`); - }); - } - getResponseHeader(headerName) { - const xhrDetails = xhrInstances.get(this); - if ( xhrDetails === undefined || this.readyState < this.HEADERS_RECEIVED ) { - return super.getResponseHeader(headerName); - } - const value = xhrDetails.headers[headerName.toLowerCase()]; - if ( value !== undefined && value !== '' ) { return value; } - return null; - } - getAllResponseHeaders() { - const xhrDetails = xhrInstances.get(this); - if ( xhrDetails === undefined || this.readyState < this.HEADERS_RECEIVED ) { - return super.getAllResponseHeaders(); - } - const out = []; - for ( const [ name, value ] of Object.entries(xhrDetails.headers) ) { - if ( !value ) { continue; } - out.push(`${name}: ${value}`); - } - if ( out.length !== 0 ) { out.push(''); } - return out.join('\r\n'); - } - }; - self.XMLHttpRequest.prototype.open.toString = function() { - return XHRBefore.open.toString(); - }; - self.XMLHttpRequest.prototype.send.toString = function() { - return XHRBefore.send.toString(); - }; - self.XMLHttpRequest.prototype.getResponseHeader.toString = function() { - return XHRBefore.getResponseHeader.toString(); - }; - self.XMLHttpRequest.prototype.getAllResponseHeaders.toString = function() { - return XHRBefore.getAllResponseHeaders.toString(); - }; -} - - /******************************************************************************* @@ -992,22 +802,6 @@ function webrtcIf( }); } -/******************************************************************************/ - -builtinScriptlets.push({ - name: 'prevent-xhr.js', - aliases: [ - 'no-xhr-if.js', - ], - fn: preventXhr, - dependencies: [ - 'prevent-xhr.fn', - ], -}); -function preventXhr(...args) { - return preventXhrFn(false, ...args); -} - /** * @scriptlet prevent-window-open * @@ -2337,25 +2131,6 @@ function trustedSuppressNativeMethod( }); } -/******************************************************************************* - * - * Trusted version of prevent-xhr(), which allows the use of an arbitrary - * string as response text. - * - * */ - -builtinScriptlets.push({ - name: 'trusted-prevent-xhr.js', - requiresTrust: true, - fn: trustedPreventXhr, - dependencies: [ - 'prevent-xhr.fn', - ], -}); -function trustedPreventXhr(...args) { - return preventXhrFn(true, ...args); -} - /** * @trustedScriptlet trusted-prevent-dom-bypass *