From fe49ced2ac937a8556a19cb61f4c2cb05ab3c54c Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Thu, 3 Oct 2024 13:31:52 -0400 Subject: [PATCH] Imrpove `prevent-xhr` scriptlet; add `trusted-prevent-xhr` scriptlet Add support for synchronous `send()` calls. `trusted-prevent-xhr` is essentially the same as `prevent-xhr` except that if the `directive` argument is not a known token, it will be used as is as the response text of the xhr request, whereas `prevent-xhr` returns an empty string when the directive is unknown. --- assets/resources/scriptlets.js | 432 ++++++++++++++++++--------------- 1 file changed, 236 insertions(+), 196 deletions(-) diff --git a/assets/resources/scriptlets.js b/assets/resources/scriptlets.js index 74e873974..895159baa 100644 --- a/assets/resources/scriptlets.js +++ b/assets/resources/scriptlets.js @@ -321,6 +321,9 @@ function runAtHtmlElementFn(fn) { // Reference: // https://github.com/AdguardTeam/Scriptlets/blob/master/wiki/about-scriptlets.md#prevent-xhr +// +// Added `trusted` argument to allow for returning arbitrary text. Can only +// be used through scriptlets requiring trusted source. builtinScriptlets.push({ name: 'generate-content.fn', @@ -329,7 +332,7 @@ builtinScriptlets.push({ 'safe-self.fn', ], }); -function generateContentFn(directive) { +function generateContentFn(trusted, directive) { const safe = safeSelf(); const randomize = len => { const chunks = []; @@ -343,27 +346,27 @@ function generateContentFn(directive) { return chunks.join(' ').slice(0, len); }; if ( directive === 'true' ) { - return Promise.resolve(randomize(10)); + return randomize(10); } if ( directive === 'emptyObj' ) { - return Promise.resolve('{}'); + return '{}'; } if ( directive === 'emptyArr' ) { - return Promise.resolve('[]'); + return '[]'; } if ( directive === 'emptyStr' ) { - return Promise.resolve(''); + return ''; } if ( directive.startsWith('length:') ) { const match = /^length:(\d+)(?:-(\d+))?$/.exec(directive); - if ( match ) { - const min = parseInt(match[1], 10); - const extent = safe.Math_max(parseInt(match[2], 10) || 0, min) - min; - const len = safe.Math_min(min + extent * safe.Math_random(), 500000); - return Promise.resolve(randomize(len | 0)); - } + if ( match === null ) { return ''; } + const min = parseInt(match[1], 10); + const extent = safe.Math_max(parseInt(match[2], 10) || 0, min) - min; + const len = safe.Math_min(min + extent * safe.Math_random(), 500000); + return randomize(len | 0); } - if ( directive.startsWith('war:') && scriptletGlobals.warOrigin ) { + if ( directive.startsWith('war:') ) { + if ( scriptletGlobals.warOrigin === undefined ) { return ''; } return new Promise(resolve => { const warOrigin = scriptletGlobals.warOrigin; const warName = directive.slice(4); @@ -379,9 +382,12 @@ function generateContentFn(directive) { }; warXHR.open('GET', fullpath.join('')); warXHR.send(); - }); + }).catch(( ) => ''); } - return Promise.resolve(''); + if ( trusted ) { + return directive; + } + return ''; } /******************************************************************************/ @@ -1565,6 +1571,196 @@ function proxyApplyFn( context[prop] = new Proxy(fn, proxyDetails); } +/******************************************************************************/ + +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 = parsePropertiesToMatch(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, defer, ...args) { + xhrInstances.delete(this); + if ( warOrigin !== undefined && url.startsWith(warOrigin) ) { + return super.open(method, url, defer, ...args); + } + const haystack = { method, url }; + if ( propsToMatch === '' && directive === '' ) { + safe.uboLog(logPrefix, `Called: ${safe.JSON_stringify(haystack, null, 2)}`); + return super.open(method, url, defer, ...args); + } + if ( matchObjectProperties(propNeedles, haystack) ) { + const xhrDetails = Object.assign(haystack, { + xhr: this, + defer, + directive, + headers: { + 'date': '', + 'content-type': '', + 'content-length': '', + }, + props: { + response: { value: '' }, + responseText: { value: '' }, + responseXML: { value: null }, + responseURL: { value: haystack.url }, + }, + }); + xhrInstances.set(this, xhrDetails); + } + return super.open(method, url, defer, ...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 }, + 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 }, + }); + 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(); + }; +} + + + + /******************************************************************************* Injectable scriptlets @@ -2271,7 +2467,7 @@ function noFetchIf( if ( proceed ) { return context.reflect(); } - return generateContentFn(responseBody).then(text => { + return Promise.resolve(generateContentFn(false, responseBody)).then(text => { safe.uboLog(logPrefix, `Prevented with response "${text}"`); const response = new Response(text, { headers: { @@ -2730,192 +2926,17 @@ function webrtcIf( /******************************************************************************/ builtinScriptlets.push({ - name: 'no-xhr-if.js', + name: 'prevent-xhr.js', aliases: [ - 'prevent-xhr.js', + 'no-xhr-if.js', ], - fn: noXhrIf, + fn: preventXhr, dependencies: [ - 'generate-content.fn', - 'match-object-properties.fn', - 'parse-properties-to-match.fn', - 'safe-self.fn', + 'prevent-xhr.fn', ], }); -function noXhrIf( - propsToMatch = '', - directive = '' -) { - if ( typeof propsToMatch !== 'string' ) { return; } - const safe = safeSelf(); - const logPrefix = safe.makeLogPrefix('prevent-xhr', propsToMatch, directive); - const xhrInstances = new WeakMap(); - const propNeedles = parsePropertiesToMatch(propsToMatch, 'url'); - const warOrigin = scriptletGlobals.warOrigin; - const headers = { - 'date': '', - 'content-type': '', - 'content-length': '', - }; - 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 ( matchObjectProperties(propNeedles, haystack) ) { - xhrInstances.set(this, haystack); - } - haystack.headers = Object.assign({}, headers); - return super.open(method, url, ...args); - } - send(...args) { - const haystack = xhrInstances.get(this); - if ( haystack === undefined ) { - return super.send(...args); - } - haystack.headers['date'] = (new Date()).toUTCString(); - let promise = Promise.resolve({ - xhr: this, - directive, - response: { - response: { value: '' }, - responseText: { value: '' }, - responseXML: { value: null }, - responseURL: { value: haystack.url }, - } - }); - switch ( this.responseType ) { - case 'arraybuffer': - promise = promise.then(details => { - const response = details.response; - response.response.value = new ArrayBuffer(0); - return details; - }); - haystack.headers['content-type'] = 'application/octet-stream'; - break; - case 'blob': - promise = promise.then(details => { - const response = details.response; - response.response.value = new Blob([]); - return details; - }); - haystack.headers['content-type'] = 'application/octet-stream'; - break; - case 'document': { - promise = promise.then(details => { - const parser = new DOMParser(); - const doc = parser.parseFromString('', 'text/html'); - const response = details.response; - response.response.value = doc; - response.responseXML.value = doc; - return details; - }); - haystack.headers['content-type'] = 'text/html'; - break; - } - case 'json': - promise = promise.then(details => { - const response = details.response; - response.response.value = {}; - response.responseText.value = '{}'; - return details; - }); - haystack.headers['content-type'] = 'application/json'; - break; - default: - if ( directive === '' ) { break; } - promise = promise.then(details => { - return generateContentFn(details.directive).then(text => { - const response = details.response; - response.response.value = text; - response.responseText.value = text; - return details; - }); - }); - haystack.headers['content-type'] = 'text/plain'; - break; - } - promise.then(details => { - Object.defineProperties(details.xhr, { - readyState: { value: 1, configurable: true }, - }); - safeDispatchEvent(details.xhr, 'readystatechange'); - return details; - }).then(details => { - const response = details.response; - haystack.headers['content-length'] = `${response.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.response); - 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 haystack = xhrInstances.get(this); - if ( haystack === undefined || this.readyState < this.HEADERS_RECEIVED ) { - return super.getResponseHeader(headerName); - } - const value = haystack.headers[headerName.toLowerCase()]; - if ( value !== undefined && value !== '' ) { return value; } - return null; - } - getAllResponseHeaders() { - const haystack = xhrInstances.get(this); - if ( haystack === undefined || this.readyState < this.HEADERS_RECEIVED ) { - return super.getAllResponseHeaders(); - } - const out = []; - for ( const [ name, value ] of Object.entries(haystack.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(); - }; +function preventXhr(...args) { + return preventXhrFn(false, ...args); } /******************************************************************************/ @@ -5103,4 +5124,23 @@ 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); +} + /******************************************************************************/