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.
This commit is contained in:
Raymond Hill 2024-10-03 13:31:52 -04:00
parent bcb31db176
commit fe49ced2ac
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
1 changed files with 236 additions and 196 deletions

View File

@ -321,6 +321,9 @@ function runAtHtmlElementFn(fn) {
// Reference: // Reference:
// https://github.com/AdguardTeam/Scriptlets/blob/master/wiki/about-scriptlets.md#prevent-xhr // 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({ builtinScriptlets.push({
name: 'generate-content.fn', name: 'generate-content.fn',
@ -329,7 +332,7 @@ builtinScriptlets.push({
'safe-self.fn', 'safe-self.fn',
], ],
}); });
function generateContentFn(directive) { function generateContentFn(trusted, directive) {
const safe = safeSelf(); const safe = safeSelf();
const randomize = len => { const randomize = len => {
const chunks = []; const chunks = [];
@ -343,27 +346,27 @@ function generateContentFn(directive) {
return chunks.join(' ').slice(0, len); return chunks.join(' ').slice(0, len);
}; };
if ( directive === 'true' ) { if ( directive === 'true' ) {
return Promise.resolve(randomize(10)); return randomize(10);
} }
if ( directive === 'emptyObj' ) { if ( directive === 'emptyObj' ) {
return Promise.resolve('{}'); return '{}';
} }
if ( directive === 'emptyArr' ) { if ( directive === 'emptyArr' ) {
return Promise.resolve('[]'); return '[]';
} }
if ( directive === 'emptyStr' ) { if ( directive === 'emptyStr' ) {
return Promise.resolve(''); return '';
} }
if ( directive.startsWith('length:') ) { if ( directive.startsWith('length:') ) {
const match = /^length:(\d+)(?:-(\d+))?$/.exec(directive); const match = /^length:(\d+)(?:-(\d+))?$/.exec(directive);
if ( match ) { if ( match === null ) { return ''; }
const min = parseInt(match[1], 10); const min = parseInt(match[1], 10);
const extent = safe.Math_max(parseInt(match[2], 10) || 0, min) - min; const extent = safe.Math_max(parseInt(match[2], 10) || 0, min) - min;
const len = safe.Math_min(min + extent * safe.Math_random(), 500000); const len = safe.Math_min(min + extent * safe.Math_random(), 500000);
return Promise.resolve(randomize(len | 0)); return randomize(len | 0);
} }
} if ( directive.startsWith('war:') ) {
if ( directive.startsWith('war:') && scriptletGlobals.warOrigin ) { if ( scriptletGlobals.warOrigin === undefined ) { return ''; }
return new Promise(resolve => { return new Promise(resolve => {
const warOrigin = scriptletGlobals.warOrigin; const warOrigin = scriptletGlobals.warOrigin;
const warName = directive.slice(4); const warName = directive.slice(4);
@ -379,9 +382,12 @@ function generateContentFn(directive) {
}; };
warXHR.open('GET', fullpath.join('')); warXHR.open('GET', fullpath.join(''));
warXHR.send(); warXHR.send();
}); }).catch(( ) => '');
} }
return Promise.resolve(''); if ( trusted ) {
return directive;
}
return '';
} }
/******************************************************************************/ /******************************************************************************/
@ -1565,6 +1571,196 @@ function proxyApplyFn(
context[prop] = new Proxy(fn, proxyDetails); 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 Injectable scriptlets
@ -2271,7 +2467,7 @@ function noFetchIf(
if ( proceed ) { if ( proceed ) {
return context.reflect(); return context.reflect();
} }
return generateContentFn(responseBody).then(text => { return Promise.resolve(generateContentFn(false, responseBody)).then(text => {
safe.uboLog(logPrefix, `Prevented with response "${text}"`); safe.uboLog(logPrefix, `Prevented with response "${text}"`);
const response = new Response(text, { const response = new Response(text, {
headers: { headers: {
@ -2730,192 +2926,17 @@ function webrtcIf(
/******************************************************************************/ /******************************************************************************/
builtinScriptlets.push({ builtinScriptlets.push({
name: 'no-xhr-if.js', name: 'prevent-xhr.js',
aliases: [ aliases: [
'prevent-xhr.js', 'no-xhr-if.js',
], ],
fn: noXhrIf, fn: preventXhr,
dependencies: [ dependencies: [
'generate-content.fn', 'prevent-xhr.fn',
'match-object-properties.fn',
'parse-properties-to-match.fn',
'safe-self.fn',
], ],
}); });
function noXhrIf( function preventXhr(...args) {
propsToMatch = '', return preventXhrFn(false, ...args);
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();
};
} }
/******************************************************************************/ /******************************************************************************/
@ -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);
}
/******************************************************************************/ /******************************************************************************/