Skip to content

Commit

Permalink
Merge branch 'release/v1.7' into feature/AG-17043
Browse files Browse the repository at this point in the history
  • Loading branch information
stanislav-atr committed Oct 25, 2022
2 parents 039c5b0 + c01dd65 commit b2cf83c
Show file tree
Hide file tree
Showing 11 changed files with 482 additions and 5 deletions.
3 changes: 2 additions & 1 deletion src/helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export * from './array-utils';
export * from './cookie-utils';
export * from './number-utils';
export * from './adjust-set-utils';
export * from './fetch-utils';
export * from './request-utils';
export * from './object-utils';
export * from './prevent-window-open-utils';
export * from './add-event-listener-utils';
Expand All @@ -26,3 +26,4 @@ export * from './regexp-utils';
export * from './random-response';
export * from './get-descriptor-addon';
export * from './parse-flags';
export * from './match-request-props';
35 changes: 35 additions & 0 deletions src/helpers/match-request-props.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
getMatchPropsData,
validateParsedData,
parseMatchProps,
} from './request-utils';

/**
* Checks if given propsToMatch string matches with given request data
* This is used by prevent-xhr, prevent-fetch, trusted-replace-xhr-response
* and trusted-replace-fetch-response scriptlets
* @param {string} propsToMatch
* @param {Object} requestData object with standard properties of fetch/xhr like url, method etc
* @returns {boolean}
*/
export const matchRequestProps = (propsToMatch, requestData) => {
let isMatched;

const parsedData = parseMatchProps(propsToMatch);
if (!validateParsedData(parsedData)) {
// eslint-disable-next-line no-console
console.log(`Invalid parameter: ${propsToMatch}`);
isMatched = false;
} else {
const matchData = getMatchPropsData(parsedData);
// prevent only if all props match
isMatched = Object.keys(matchData)
.every((matchKey) => {
const matchValue = matchData[matchKey];
return Object.prototype.hasOwnProperty.call(requestData, matchKey)
&& matchValue.test(requestData[matchKey]);
});
}

return isMatched;
};
19 changes: 19 additions & 0 deletions src/helpers/fetch-utils.js → src/helpers/request-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ export const getFetchData = (args) => {
return fetchPropsObj;
};

/**
* Collect xhr.open arguments to object
* @param {string} method
* @param {string} url
* @param {string} async
* @param {string} user
* @param {string} password
* @returns {Object}
*/
export const getXhrData = (method, url, async, user, password) => {
return {
method,
url,
async,
user,
password,
};
};

/**
* Parse propsToMatch input string into object;
* used for prevent-fetch and prevent-xhr
Expand Down
4 changes: 3 additions & 1 deletion src/redirects/googlesyndication-adsbygoogle.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export function GoogleSyndicationAdsByGoogle(source) {
for (const key of Object.keys(arg)) {
if (typeof arg[key] === 'function') {
try {
arg[key].call();
// https://github.com/AdguardTeam/Scriptlets/issues/252
// argument "{}" is needed to fix issue with undefined argument
arg[key].call(this, {});
} catch {
/* empty */
}
Expand Down
2 changes: 1 addition & 1 deletion src/scriptlets/prevent-xhr.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
* - value — range on numbers, for example `100-300`, limited to 500000 characters
*
* > Usage with no arguments will log XMLHttpRequest objects to browser console;
* which is useful for debugging but permitted for production filter lists.
* which is useful for debugging but not allowed for production filter lists.
*
* **Examples**
* 1. Log all XMLHttpRequests
Expand Down
1 change: 1 addition & 0 deletions src/scriptlets/scriptlets-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ export * from './close-window';
export * from './prevent-refresh';
export * from './prevent-element-src-loading';
export * from './no-topics';
export * from './trusted-replace-xhr-response';
export * from './xml-prune';
export * from './trusted-replace-fetch-response';
1 change: 1 addition & 0 deletions src/scriptlets/trusted-click-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =

trustedClickElement.names = [
'trusted-click-element',
// trusted scriptlets support no aliases
];

trustedClickElement.injections = [
Expand Down
240 changes: 240 additions & 0 deletions src/scriptlets/trusted-replace-xhr-response.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import {
hit,
toRegExp,
objectToString,
getWildcardSymbol,
matchRequestProps,
getXhrData,
// following helpers should be imported and injected
// because they are used by helpers above
getMatchPropsData,
validateParsedData,
parseMatchProps,
isValidStrPattern,
escapeRegExp,
isEmptyObject,
getObjectEntries,
} from '../helpers/index';

/* eslint-disable max-len */
/**
* @scriptlet trusted-replace-xhr-response
*
* @description
* Replaces response content of `xhr` requests if **all** given parameters match.
*
* **Syntax**
* ```
* example.org#%#//scriptlet('trusted-replace-xhr-response'[, pattern, replacement[, propsToMatch]])
* ```
*
* - pattern - optional, argument for matching contents of responseText that should be replaced. If set, `replacement` is required;
* possible values:
* - '*' to match all text content
* - string
* - regular expression
* - replacement — optional, should be set if `pattern` is set. String to replace matched content with. Empty string to remove content.
* - propsToMatch — optional, string of space-separated properties to match for extra condition; possible props:
* - string or regular expression for matching the URL passed to `.open()` call;
* - colon-separated pairs name:value where
* - name - name is string or regular expression for matching XMLHttpRequest property name
* - value is string or regular expression for matching the value of the option passed to `.open()` call
*
* > Usage with no arguments will log XMLHttpRequest objects to browser console;
* which is useful for debugging but not permitted for production filter lists.
*
* **Examples**
* 1. Log all XMLHttpRequests
* ```
* example.org#%#//scriptlet('trusted-replace-xhr-response')
* ```
*
* 2. Replace text content of XMLHttpRequests with specific url
* ```
* example.org#%#//scriptlet('trusted-replace-xhr-response', 'adb_detect:true', 'adb_detect:false', 'example.org')
* example.org#%#//scriptlet('trusted-replace-xhr-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', 'example.org')
* ```
*
* 3. Remove all text content of XMLHttpRequests with specific request method
* ```
* example.org#%#//scriptlet('trusted-replace-xhr-response', '*', '', 'method:GET')
* ```
*
* 4. Replace text content of XMLHttpRequests matching by URL regex and request methods
* ```
* example.org#%#//scriptlet('trusted-replace-xhr-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', '/\.m3u8/ method:/GET|HEAD/')
* ```
* 5. Remove all text content of all XMLHttpRequests for example.com
* ```
* example.org#%#//scriptlet('trusted-replace-xhr-response', '*', '', 'example.com')
* ```
*/
/* eslint-enable max-len */
export function trustedReplaceXhrResponse(source, pattern = '', replacement = '', propsToMatch = '') {
// do nothing if browser does not support Proxy (e.g. Internet Explorer)
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
if (typeof Proxy === 'undefined') {
return;
}

if (typeof pattern === 'undefined' || typeof replacement === 'undefined') {
return;
}

// eslint-disable-next-line no-console
const log = console.log.bind(console);
const nativeOpen = window.XMLHttpRequest.prototype.open;
const nativeSend = window.XMLHttpRequest.prototype.send;

let shouldReplace = false;
let xhrData;
let requestHeaders = [];

const openWrapper = (target, thisArg, args) => {
xhrData = getXhrData(...args);

if (pattern === '' && replacement === '') {
// Log if no propsToMatch given
const logMessage = `log: xhr( ${objectToString(xhrData)} )`;
log(source, logMessage);
} else {
shouldReplace = matchRequestProps(propsToMatch, xhrData);
}

// Trap setRequestHeader of target xhr object to mimic request headers later
if (shouldReplace) {
const setRequestHeaderWrapper = (target, thisArg, args) => {
// Collect headers
requestHeaders.push(args);
return Reflect.apply(target, thisArg, args);
};

const setRequestHeaderHandler = {
apply: setRequestHeaderWrapper,
};

// setRequestHeader can only be called on open xhr object,
// so we can safely proxy it here
thisArg.setRequestHeader = new Proxy(thisArg.setRequestHeader, setRequestHeaderHandler);
}

return Reflect.apply(target, thisArg, args);
};

const sendWrapper = async (target, thisArg, args) => {
if (!shouldReplace) {
return Reflect.apply(target, thisArg, args);
}

/**
* Create separate XHR request with original request's input
* to be able to collect response data without triggering
* listeners on original XHR object
*/
const replacingRequest = new XMLHttpRequest();
replacingRequest.addEventListener('readystatechange', () => {
if (replacingRequest.readyState !== 4) {
return;
}

const {
readyState,
response,
responseText,
responseURL,
responseXML,
status,
statusText,
} = replacingRequest;

// Extract content from response
const content = responseText || response;
if (typeof content !== 'string') {
return;
}

const patternRegexp = pattern === getWildcardSymbol()
? toRegExp
: toRegExp(pattern);

const modifiedContent = content.replace(patternRegexp, replacement);

// Manually put required values into target XHR object
// as thisArg can't be redefined and XHR objects can't be (re)assigned or copied
Object.defineProperties(thisArg, {
readyState: { value: readyState },
response: { value: modifiedContent },
responseText: { value: modifiedContent },
responseURL: { value: responseURL },
responseXML: { value: responseXML },
status: { value: status },
statusText: { value: statusText },
});

// Mock events
setTimeout(() => {
const stateEvent = new Event('readystatechange');
thisArg.dispatchEvent(stateEvent);

const loadEvent = new Event('load');
thisArg.dispatchEvent(loadEvent);

const loadEndEvent = new Event('loadend');
thisArg.dispatchEvent(loadEndEvent);
}, 1);

hit(source);
});

nativeOpen.apply(replacingRequest, [xhrData.method, xhrData.url]);

// Mimic request headers before sending
// setRequestHeader can only be called on open request objects
requestHeaders.forEach((header) => {
const name = header[0];
const value = header[1];

replacingRequest.setRequestHeader(name, value);
});
requestHeaders = [];

try {
nativeSend.call(replacingRequest, args);
} catch {
return Reflect.apply(target, thisArg, args);
}
return undefined;
};

const openHandler = {
apply: openWrapper,
};

const sendHandler = {
apply: sendWrapper,
};

XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, openHandler);
XMLHttpRequest.prototype.send = new Proxy(XMLHttpRequest.prototype.send, sendHandler);
}

trustedReplaceXhrResponse.names = [
'trusted-replace-xhr-response',
// trusted scriptlets support no aliases
];

trustedReplaceXhrResponse.injections = [
hit,
toRegExp,
objectToString,
getWildcardSymbol,
matchRequestProps,
getXhrData,
getMatchPropsData,
validateParsedData,
parseMatchProps,
isValidStrPattern,
escapeRegExp,
isEmptyObject,
getObjectEntries,
];
13 changes: 11 additions & 2 deletions tests/redirects/googlesyndication-adsbygoogle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,17 @@ test('Redirect testing', (assert) => {

assert.strictEqual(window.adsbygoogle.length, undefined, 'adsbygoogle.length check');
assert.strictEqual(window.adsbygoogle.push.length, 1, 'push.length check');
const pushCallback = () => {
assert.ok(true, 'callback was called');
const pushCallback = (arg) => {
try {
// Test for https://github.com/AdguardTeam/Scriptlets/issues/252
// If arg is not defined then error will be thrown
if (arg.whatever) {
arg.whatever = 1;
}
assert.ok(typeof arg !== 'undefined', 'arg is defined');
} catch (error) {
assert.ok(false, 'something went wrong');
}
};
const pushArg = {
test: 'test',
Expand Down
1 change: 1 addition & 0 deletions tests/scriptlets/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import './close-window.test';
import './prevent-refresh.test';
import './prevent-element-src-loading.test';
import './no-topics.test';
import './trusted-replace-xhr-response.test';
import './xml-prune.test';
import './trusted-click-element.test';
import './trusted-replace-fetch-response.test';
Loading

0 comments on commit b2cf83c

Please sign in to comment.