Skip to content

Commit

Permalink
mock getResponseHeader() and getAllResponseHeaders() methods for prev…
Browse files Browse the repository at this point in the history
…ent-xhr

#295 AG-20196

Squashed commit of the following:

commit 8bd6739
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 18:07:12 2023 +0300

    no deprecated helper startsWith() usage

commit a0e0db1
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 17:59:14 2023 +0300

    fix trusted-replace-xhr-response

commit ef04555
Merge: 0efb80e 6af803c
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 17:42:45 2023 +0300

    merge master, resolve conflicts

commit 0efb80e
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 14:25:41 2023 +0300

    update changelog

commit f9c6da7
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 14:23:22 2023 +0300

    update changelog

commit e15e923
Merge: 10c0393 71d7683
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 14:22:13 2023 +0300

    merge master, resolve conflicts

commit 10c0393
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 14:15:31 2023 +0300

    add comments for getHeaderWrapper() and getAllHeadersWrapper()

commit 8ff8421
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 13:34:36 2023 +0300

    add todo for array destructuring

commit 7762018
Merge: e8760f3 66582ca
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Tue May 16 12:16:54 2023 +0300

    Merge branch 'master' into fix/AG-20196

commit e8760f3
Merge: 35074e5 b13ae17
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Mon May 15 11:53:57 2023 +0300

    Merge branch 'fix/AG-20196' of ssh://bit.adguard.com:7999/adguard-filters/scriptlets into fix/AG-20196

commit 35074e5
Merge: eb1d72a 898998d
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Mon May 15 11:53:11 2023 +0300

    Merge branch 'master' into fix/AG-20196

commit b13ae17
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Fri May 12 18:06:13 2023 +0300

    improve error text

commit 93c3b6b
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Fri May 12 18:04:11 2023 +0300

    fix changelog

commit eb1d72a
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Fri May 12 14:15:39 2023 +0300

    update changelog

commit f62fc97
Merge: b378dd4 b96e797
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Fri May 12 13:50:37 2023 +0300

    Merge branch 'master' into fix/AG-20196

commit b378dd4
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Fri May 12 13:49:31 2023 +0300

    improve prevent-xhr - add mock getResponseHeader() and getAllResponseHeaders() methods
  • Loading branch information
slavaleleka committed May 16, 2023
1 parent 6af803c commit 4d40094
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 46 deletions.
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- `trusted-set-cookie` and `trusted-set-cookie-reaload` scriptlets to not encode cookie name and value [#311](https://github.com/AdguardTeam/Scriptlets/issues/311)
- improved `prevent-fetch` — if `responseType` is not specified,
- `trusted-set-cookie` and `trusted-set-cookie-reload` scriptlets to not encode cookie name and value
[#311](https://github.com/AdguardTeam/Scriptlets/issues/311)
- improved `prevent-fetch`: if `responseType` is not specified,
original response type is returned instead of `default` [#297](https://github.com/AdguardTeam/Scriptlets/issues/291)

### Fixed

- website reloading if `$now$`/`$currentDate$` value is used
in `trusted-set-cookie-reload` scriptlet [#291](https://github.com/AdguardTeam/Scriptlets/issues/291)
- `getResponseHeader()` and `getAllResponseHeaders()` methods mock
in `prevent-xhr` scriptlet [#295](https://github.com/AdguardTeam/Scriptlets/issues/295)

## <a name="v1.9.7"></a> [v1.9.7] - 2023-03-14

Expand Down
199 changes: 163 additions & 36 deletions src/scriptlets/prevent-xhr.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
objectToString,
generateRandomResponse,
matchRequestProps,
getXhrData,
logMessage,
// following helpers should be imported and injected
// because they are used by helpers above
Expand Down Expand Up @@ -97,16 +98,18 @@ export function preventXHR(source, propsToMatch, customResponseText) {
return;
}

let response = '';
let responseText = '';
let responseUrl;
const nativeOpen = window.XMLHttpRequest.prototype.open;
const nativeSend = window.XMLHttpRequest.prototype.send;

let xhrData;
let modifiedResponse = '';
let modifiedResponseText = '';

const openWrapper = (target, thisArg, args) => {
// Get method and url from .open()
const xhrData = {
method: args[0],
url: args[1],
};
responseUrl = xhrData.url;
// Get original request properties
// eslint-disable-next-line prefer-spread
xhrData = getXhrData.apply(null, args);

if (typeof propsToMatch === 'undefined') {
// Log if no propsToMatch given
logMessage(source, `xhr( ${objectToString(xhrData)} )`, true);
Expand All @@ -115,6 +118,22 @@ export function preventXHR(source, propsToMatch, customResponseText) {
thisArg.shouldBePrevented = true;
}

// Trap setRequestHeader of target xhr object to mimic request headers later;
// needed for getResponseHeader() and getAllResponseHeaders() methods
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 xhr.open(),
// so we can safely proxy it here
thisArg.setRequestHeader = new Proxy(thisArg.setRequestHeader, setRequestHeaderHandler);
}
return Reflect.apply(target, thisArg, args);
};

Expand All @@ -124,57 +143,164 @@ export function preventXHR(source, propsToMatch, customResponseText) {
}

if (thisArg.responseType === 'blob') {
response = new Blob();
modifiedResponse = new Blob();
}

if (thisArg.responseType === 'arraybuffer') {
response = new ArrayBuffer();
modifiedResponse = new ArrayBuffer();
}

if (customResponseText) {
const randomText = generateRandomResponse(customResponseText);
if (randomText) {
responseText = randomText;
modifiedResponseText = randomText;
} else {
logMessage(source, `Invalid range: ${customResponseText}`);
logMessage(source, `Invalid randomize parameter: '${customResponseText}'`);
}
}
// Mock response object
Object.defineProperties(thisArg, {
readyState: { value: 4, writable: false },
response: { value: response, writable: false },
responseText: { value: responseText, writable: false },
responseURL: { value: responseUrl, writable: false },
responseXML: { value: '', writable: false },
status: { value: 200, writable: false },
statusText: { value: 'OK', writable: false },

/**
* 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,
responseURL,
responseXML,
status,
statusText,
} = forgedRequest;

// Mock response object
Object.defineProperties(thisArg, {
// original values
readyState: { value: readyState, writable: false },
status: { value: status, writable: false },
statusText: { value: statusText, writable: false },
responseURL: { value: responseURL, writable: false },
responseXML: { value: responseXML, writable: false },
// modified values
response: { value: modifiedResponse, writable: false },
responseText: { value: modifiedResponseText, 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);
});
// Mock events
setTimeout(() => {
const stateEvent = new Event('readystatechange');
thisArg.dispatchEvent(stateEvent);

const loadEvent = new Event('load');
thisArg.dispatchEvent(loadEvent);
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);
});

const loadEndEvent = new Event('loadend');
thisArg.dispatchEvent(loadEndEvent);
}, 1);
try {
nativeSend.call(forgedRequest, args);
} catch {
return Reflect.apply(target, thisArg, args);
}

hit(source);
return undefined;
};

/**
* Mock XMLHttpRequest.prototype.getHeaderHandler() to avoid adblocker detection.
*
* @param {Function} target XMLHttpRequest.prototype.getHeaderHandler().
* @param {XMLHttpRequest} thisArg The request.
* @param {string[]} args Header name is passed as first argument.
*
* @returns {string|null} Header value or null if header is not set.
*/
const getHeaderWrapper = (target, thisArg, args) => {
if (!thisArg.collectedHeaders.length) {
return null;
}
// The search for the header name is case-insensitive
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getResponseHeader
const searchHeaderName = args[0].toLowerCase();
const matchedHeader = thisArg.collectedHeaders.find((header) => {
const headerName = header[0].toLowerCase();
return headerName === searchHeaderName;
});
return matchedHeader
? matchedHeader[1]
: null;
};

/**
* Mock XMLHttpRequest.prototype.getAllResponseHeaders() to avoid adblocker detection.
*
* @param {Function} target XMLHttpRequest.prototype.getAllResponseHeaders().
* @param {XMLHttpRequest} thisArg The request.
*
* @returns {string} All headers as a string. For no headers an empty string is returned.
*/
const getAllHeadersWrapper = (target, thisArg) => {
if (!thisArg.collectedHeaders.length) {
return '';
}
const allHeadersStr = thisArg.collectedHeaders
.map((header) => {
/**
* TODO: array destructuring may be used here
* after the typescript implementation and bundling refactoring
* as now there is an error: slicedToArray is not defined
*/
const headerName = header[0];
const headerValue = header[1];
// In modern browsers, the header names are returned in all lower case, as per the latest spec.
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getAllResponseHeaders
return `${headerName.toLowerCase()}: ${headerValue}`;
})
.join('\r\n');
return allHeadersStr;
};

const openHandler = {
apply: openWrapper,
};

const sendHandler = {
apply: sendWrapper,
};
const getHeaderHandler = {
apply: getHeaderWrapper,
};
const getAllHeadersHandler = {
apply: getAllHeadersWrapper,
};

XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, openHandler);
XMLHttpRequest.prototype.send = new Proxy(XMLHttpRequest.prototype.send, sendHandler);
XMLHttpRequest.prototype.getResponseHeader = new Proxy(
XMLHttpRequest.prototype.getResponseHeader,
getHeaderHandler,
);
XMLHttpRequest.prototype.getAllResponseHeaders = new Proxy(
XMLHttpRequest.prototype.getAllResponseHeaders,
getAllHeadersHandler,
);
}

preventXHR.names = [
Expand All @@ -187,10 +313,11 @@ preventXHR.names = [

preventXHR.injections = [
hit,
logMessage,
objectToString,
matchRequestProps,
generateRandomResponse,
matchRequestProps,
getXhrData,
logMessage,
toRegExp,
isValidStrPattern,
escapeRegExp,
Expand Down
16 changes: 9 additions & 7 deletions src/scriptlets/trusted-replace-xhr-response.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,15 @@ export function trustedReplaceXhrResponse(source, pattern = '', 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 },
// 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: modifiedContent, writable: false },
responseText: { value: modifiedContent, writable: false },
});

// Mock events
Expand Down
53 changes: 52 additions & 1 deletion tests/scriptlets/prevent-xhr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ if (isSupported) {
if (input.includes('trace')) {
return;
}
const EXPECTED_LOG_STR = `${name}: xhr( method:"${METHOD}" url:"${URL}" )`;
// eslint-disable-next-line max-len
const EXPECTED_LOG_STR = `${name}: xhr( method:"${METHOD}" url:"${URL}" async:"undefined" user:"undefined" password:"undefined" )`;
assert.ok(input.startsWith(EXPECTED_LOG_STR), 'console.hit input');
};

Expand Down Expand Up @@ -96,6 +97,56 @@ if (isSupported) {
xhr.send();
});

test('Empty arg to prevent all, check getResponseHeader() and getAllResponseHeaders() methods', async (assert) => {
const METHOD = 'GET';
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
const MATCH_DATA = [''];
const HEADER_NAME_1 = 'Test-Type';
const HEADER_VALUE_1 = 'application/json';
const HEADER_NAME_2 = 'Test-Length';
const HEADER_VALUE_2 = '12345';
const ABSENT_HEADER_NAME = 'Test-Absent';

runScriptlet(name, MATCH_DATA);

const done = assert.async();

const xhr = new XMLHttpRequest();
xhr.open(METHOD, URL);
xhr.setRequestHeader(HEADER_NAME_1, HEADER_VALUE_1);
xhr.setRequestHeader(HEADER_NAME_2, HEADER_VALUE_2);

xhr.onload = () => {
assert.strictEqual(xhr.readyState, 4, 'Response done');
assert.strictEqual(xhr.response, '', 'Response data mocked');
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
done();
};
xhr.send();

assert.strictEqual(
xhr.getResponseHeader(HEADER_NAME_1),
HEADER_VALUE_1,
'getResponseHeader() is mocked, value 1 returned',
);
assert.strictEqual(
xhr.getResponseHeader(HEADER_NAME_2),
HEADER_VALUE_2,
'getResponseHeader() is mocked',
);
assert.strictEqual(
xhr.getResponseHeader(ABSENT_HEADER_NAME),
null,
'getResponseHeader() is mocked, null returned for non-existent header',
);

const expectedAllHeaders = [
`${HEADER_NAME_1.toLowerCase()}: ${HEADER_VALUE_1}`,
`${HEADER_NAME_2.toLowerCase()}: ${HEADER_VALUE_2}`,
].join('\r\n');
assert.strictEqual(xhr.getAllResponseHeaders(), expectedAllHeaders, 'getAllResponseHeaders() is mocked');
});

test('Empty arg, prevent all, randomize response text', async (assert) => {
const METHOD = 'GET';
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
Expand Down

0 comments on commit 4d40094

Please sign in to comment.