From 4d400948d5d46b8f60501503b248ee48c2a52b3b Mon Sep 17 00:00:00 2001 From: Slava Leleka Date: Tue, 16 May 2023 18:29:54 +0300 Subject: [PATCH] mock getResponseHeader() and getAllResponseHeaders() methods for prevent-xhr #295 AG-20196 Squashed commit of the following: commit 8bd673969f1301ff982f5eaf41d7ec869ebcc755 Author: Slava Leleka Date: Tue May 16 18:07:12 2023 +0300 no deprecated helper startsWith() usage commit a0e0db1ff93e88527cc4fc7c1862d9038e09cc5a Author: Slava Leleka Date: Tue May 16 17:59:14 2023 +0300 fix trusted-replace-xhr-response commit ef04555f2fb4264c3c54db8a8b2fc281f2ec9106 Merge: 0efb80e0 6af803ce Author: Slava Leleka Date: Tue May 16 17:42:45 2023 +0300 merge master, resolve conflicts commit 0efb80e0954c8a12c970c35798f433cd1ac1eadf Author: Slava Leleka Date: Tue May 16 14:25:41 2023 +0300 update changelog commit f9c6da7cea7b031672586cfeab898e541c42d0cf Author: Slava Leleka Date: Tue May 16 14:23:22 2023 +0300 update changelog commit e15e9231e2f01197afbe6fb56ea5e6b1f3fccfef Merge: 10c03930 71d7683b Author: Slava Leleka Date: Tue May 16 14:22:13 2023 +0300 merge master, resolve conflicts commit 10c039309ea424ec86bd1d70af8d2f4214331534 Author: Slava Leleka Date: Tue May 16 14:15:31 2023 +0300 add comments for getHeaderWrapper() and getAllHeadersWrapper() commit 8ff8421a8355929be4ce383591f8f6bc1194e1cf Author: Slava Leleka Date: Tue May 16 13:34:36 2023 +0300 add todo for array destructuring commit 776201868efc30f57b9d2ac0a378c1e35f0ca5e4 Merge: e8760f3f 66582ca7 Author: Slava Leleka Date: Tue May 16 12:16:54 2023 +0300 Merge branch 'master' into fix/AG-20196 commit e8760f3fa96fd8463cde5699057d53add158eeaa Merge: 35074e59 b13ae17d Author: Slava Leleka 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 35074e5983ab6227adddc49c31a1d9f66b0d9861 Merge: eb1d72a0 898998d3 Author: Slava Leleka Date: Mon May 15 11:53:11 2023 +0300 Merge branch 'master' into fix/AG-20196 commit b13ae17d79331afe27def1309b3e5b1adf0acd08 Author: Slava Leleka Date: Fri May 12 18:06:13 2023 +0300 improve error text commit 93c3b6b363207e6136101bc9688c5b0dd5170018 Author: Slava Leleka Date: Fri May 12 18:04:11 2023 +0300 fix changelog commit eb1d72a063b2576315c0062698ae8b5d2952048d Author: Slava Leleka Date: Fri May 12 14:15:39 2023 +0300 update changelog commit f62fc97e8f9f6ec577e04b27f78b628699b94dcd Merge: b378dd4b b96e797b Author: Slava Leleka Date: Fri May 12 13:50:37 2023 +0300 Merge branch 'master' into fix/AG-20196 commit b378dd4b49a587c374472fcc7d27a708689564a1 Author: Slava Leleka Date: Fri May 12 13:49:31 2023 +0300 improve prevent-xhr - add mock getResponseHeader() and getAllResponseHeaders() methods --- CHANGELOG.md | 7 +- src/scriptlets/prevent-xhr.js | 199 ++++++++++++++---- .../trusted-replace-xhr-response.js | 16 +- tests/scriptlets/prevent-xhr.test.js | 53 ++++- 4 files changed, 229 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba659b7..ab5a7a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ## [v1.9.7] - 2023-03-14 diff --git a/src/scriptlets/prevent-xhr.js b/src/scriptlets/prevent-xhr.js index a2a62615..99f64fb5 100644 --- a/src/scriptlets/prevent-xhr.js +++ b/src/scriptlets/prevent-xhr.js @@ -3,6 +3,7 @@ import { objectToString, generateRandomResponse, matchRequestProps, + getXhrData, logMessage, // following helpers should be imported and injected // because they are used by helpers above @@ -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); @@ -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); }; @@ -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 = [ @@ -187,10 +313,11 @@ preventXHR.names = [ preventXHR.injections = [ hit, - logMessage, objectToString, - matchRequestProps, generateRandomResponse, + matchRequestProps, + getXhrData, + logMessage, toRegExp, isValidStrPattern, escapeRegExp, diff --git a/src/scriptlets/trusted-replace-xhr-response.js b/src/scriptlets/trusted-replace-xhr-response.js index f9180061..b32d5132 100644 --- a/src/scriptlets/trusted-replace-xhr-response.js +++ b/src/scriptlets/trusted-replace-xhr-response.js @@ -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 diff --git a/tests/scriptlets/prevent-xhr.test.js b/tests/scriptlets/prevent-xhr.test.js index a4bace06..8acb5438 100644 --- a/tests/scriptlets/prevent-xhr.test.js +++ b/tests/scriptlets/prevent-xhr.test.js @@ -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'); }; @@ -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`;