Skip to content

Commit

Permalink
Fix issue with pruning when addEventListener was used before calling …
Browse files Browse the repository at this point in the history
…send() method in m3u-prune and xml-prune

Fix issue with "Cannot construct a Request with a Request object that has already been used" when request contains specified body
  • Loading branch information
AdamWr committed May 22, 2023
1 parent 712c295 commit f17a26c
Show file tree
Hide file tree
Showing 5 changed files with 499 additions and 116 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- issue with pruning when `addEventListener` was used before calling `send()` method
in `m3u-prune` and `xml-prune` scriptlets [#315](https://github.com/AdguardTeam/Scriptlets/issues/315)
- issue with `updateTargetingFromMap()` method
in `googletagservices-gpt` redirect [#293](https://github.com/AdguardTeam/Scriptlets/issues/293)
- website reloading if `$now$`/`$currentDate$` value is used
Expand Down
192 changes: 157 additions & 35 deletions src/scriptlets/m3u-prune.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import {
hit,
toRegExp,
logMessage,
getXhrData,
objectToString,
matchRequestProps,
// following helpers should be imported and injected
// because they are used by helpers above
getMatchPropsData,
getRequestProps,
validateParsedData,
parseMatchProps,
isValidStrPattern,
escapeRegExp,
isEmptyObject,
} from '../helpers/index';

/* eslint-disable max-len */
Expand Down Expand Up @@ -46,7 +58,7 @@ import {
*/
/* eslint-enable max-len */

export function m3uPrune(source, propsToRemove, urlToMatch) {
export function m3uPrune(source, propsToRemove, urlToMatch = '') {
// do nothing if browser does not support fetch or Proxy (e.g. Internet Explorer)
// https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
Expand Down Expand Up @@ -308,46 +320,142 @@ export function m3uPrune(source, propsToRemove, urlToMatch) {
.join('\n');
};

const xhrWrapper = (target, thisArg, args) => {
const xhrURL = args[1];
if (typeof xhrURL !== 'string' || xhrURL.length === 0) {
const nativeOpen = window.XMLHttpRequest.prototype.open;
const nativeSend = window.XMLHttpRequest.prototype.send;

let xhrData;

const openWrapper = (target, thisArg, args) => {
// eslint-disable-next-line prefer-spread
xhrData = getXhrData.apply(null, args);

if (matchRequestProps(source, urlToMatch, xhrData)) {
thisArg.shouldBePrevented = true;
}

// Trap setRequestHeader of target xhr object to mimic request headers later
if (thisArg.shouldBePrevented) {
thisArg.collectedHeaders = [];
const setRequestHeaderWrapper = (target, thisArg, args) => {
// Collect headers
thisArg.collectedHeaders.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 = (target, thisArg, args) => {
const allowedResponseTypeValues = ['', 'text'];
// Do nothing if request do not match
// or response type is not a string
if (!thisArg.shouldBePrevented || !allowedResponseTypeValues.includes(thisArg.responseType)) {
return Reflect.apply(target, thisArg, args);
}
if (urlMatchRegexp.test(xhrURL)) {
thisArg.addEventListener('readystatechange', function pruneResponse() {
if (thisArg.readyState === 4) {
const { response } = thisArg;
thisArg.removeEventListener('readystatechange', pruneResponse);
// If "propsToRemove" is not defined, then response should be logged only
if (!propsToRemove) {
if (isM3U(response)) {
const message = `XMLHttpRequest.open() URL: ${xhrURL}\nresponse: ${response}`;
logMessage(source, message);
}
} else {
shouldPruneResponse = isPruningNeeded(response, removeM3ULineRegexp);
}
if (shouldPruneResponse) {
const prunedResponseContent = pruneM3U(response);
Object.defineProperty(thisArg, 'response', {
value: prunedResponseContent,
});
Object.defineProperty(thisArg, 'responseText', {
value: prunedResponseContent,
});
hit(source);
}

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

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

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

if (!propsToRemove) {
if (isM3U(response)) {
const message = `XMLHttpRequest.open() URL: ${responseURL}\nresponse: ${response}`;
logMessage(source, message);
}
} else {
shouldPruneResponse = isPruningNeeded(response, removeM3ULineRegexp);
}
const responseContent = shouldPruneResponse ? pruneM3U(response) : response;
// 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, {
// original values
readyState: { value: readyState, writable: false },
responseURL: { value: responseURL, writable: false },
responseXML: { value: responseXML, writable: false },
status: { value: status, writable: false },
statusText: { value: statusText, writable: false },
// modified values
response: { value: responseContent, writable: false },
responseText: { value: responseContent, writable: false },
});

// 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(forgedRequest, [xhrData.method, xhrData.url]);

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

forgedRequest.setRequestHeader(name, value);
});
thisArg.collectedHeaders = [];

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

const xhrHandler = {
apply: xhrWrapper,
const openHandler = {
apply: openWrapper,
};
// eslint-disable-next-line max-len
window.XMLHttpRequest.prototype.open = new Proxy(window.XMLHttpRequest.prototype.open, xhrHandler);

const sendHandler = {
apply: sendWrapper,
};

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

const nativeFetch = window.fetch;

Expand All @@ -358,12 +466,16 @@ export function m3uPrune(source, propsToRemove, urlToMatch) {
}
if (urlMatchRegexp.test(fetchURL)) {
const response = await nativeFetch(...args);
// It's required to fix issue with - Request with body": Failed to execute 'fetch' on 'Window':
// Cannot construct a Request with a Request object that has already been used.
// For example, it occurs on youtube when scriptlet is used without arguments
const clonedResponse = response.clone();
const responseText = await response.text();
// If "propsToRemove" is not defined, then response should be logged only
if (!propsToRemove && isM3U(responseText)) {
const message = `fetch URL: ${fetchURL}\nresponse text: ${responseText}`;
logMessage(source, message);
return Reflect.apply(target, thisArg, args);
return clonedResponse;
}
if (isPruningNeeded(responseText, removeM3ULineRegexp)) {
const prunedText = pruneM3U(responseText);
Expand All @@ -374,7 +486,7 @@ export function m3uPrune(source, propsToRemove, urlToMatch) {
headers: response.headers,
});
}
return Reflect.apply(target, thisArg, args);
return clonedResponse;
}
return Reflect.apply(target, thisArg, args);
};
Expand All @@ -397,4 +509,14 @@ m3uPrune.injections = [
hit,
toRegExp,
logMessage,
getXhrData,
objectToString,
matchRequestProps,
getMatchPropsData,
getRequestProps,
validateParsedData,
parseMatchProps,
isValidStrPattern,
escapeRegExp,
isEmptyObject,
];
Loading

0 comments on commit f17a26c

Please sign in to comment.