From 531ea12e1c5ddc7d26bd61cdc095a30894d0687e Mon Sep 17 00:00:00 2001 From: Slava Leleka Date: Wed, 10 May 2023 14:19:55 +0300 Subject: [PATCH] modify original response by prevent-fetch --- src/helpers/index.js | 1 + src/helpers/response-utils.js | 40 ++++++++++++ src/scriptlets/prevent-fetch.js | 38 ++++++++--- tests/scriptlets/prevent-fetch.test.js | 89 +++++++++++++++++++++++++- 4 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 src/helpers/response-utils.js diff --git a/src/helpers/index.js b/src/helpers/index.js index 58c75865..91885fd1 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -14,6 +14,7 @@ export * from './open-shadow-dom-utils'; export * from './prevent-utils'; export * from './prevent-window-open-utils'; export * from './regexp-utils'; +export * from './response-utils'; export * from './request-utils'; export * from './storage-utils'; export * from './string-utils'; diff --git a/src/helpers/response-utils.js b/src/helpers/response-utils.js new file mode 100644 index 00000000..82bd92ef --- /dev/null +++ b/src/helpers/response-utils.js @@ -0,0 +1,40 @@ +/** + * Modifies original response with the given replacement data. + * + * @param {Response} origResponse Original response. + * @param {Object} replacement Replacement data for response with possible keys: + * - `body`: optional, string, default to '{}'; + * - `type`: optional, string, original response type is used if not specified. + * + * @returns {Response} Modified response. + */ +export const modifyResponse = ( + origResponse, + replacement = { + body: '{}', + }, +) => { + const headers = {}; + origResponse?.headers?.forEach((value, key) => { + headers[key] = value; + }); + + const modifiedResponse = new Response(replacement.body, { + status: origResponse.status, + statusText: origResponse.statusText, + headers, + }); + + // Mock response url and type to avoid adblocker detection + // https://github.com/AdguardTeam/Scriptlets/issues/216 + Object.defineProperties(modifiedResponse, { + url: { + value: origResponse.url, + }, + type: { + value: replacement.type || origResponse.type, + }, + }); + + return modifiedResponse; +}; diff --git a/src/scriptlets/prevent-fetch.js b/src/scriptlets/prevent-fetch.js index 1c5e2501..58a151c7 100644 --- a/src/scriptlets/prevent-fetch.js +++ b/src/scriptlets/prevent-fetch.js @@ -5,6 +5,7 @@ import { noopPromiseResolve, matchRequestProps, logMessage, + modifyResponse, // following helpers should be imported and injected // because they are used by helpers above toRegExp, @@ -24,7 +25,7 @@ import { /** * @scriptlet prevent-fetch * @description - * Prevents `fetch` calls if **all** given parameters match + * Prevents `fetch` calls if **all** given parameters match. * * Related UBO scriptlet: * https://github.com/gorhill/uBlock/wiki/Resources-Library#no-fetch-ifjs- @@ -35,14 +36,18 @@ import { * ``` * * - `propsToMatch` — optional, string of space-separated properties to match; possible props: - * - string or regular expression for matching the URL passed to fetch call; empty string, wildcard `*` or invalid regular expression will match all fetch calls + * - string or regular expression for matching the URL passed to fetch call; + * empty string, wildcard `*` or invalid regular expression will match all fetch calls * - colon-separated pairs `name:value` where * - `name` is [`init` option name](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters) - * - `value` is string or regular expression for matching the value of the option passed to fetch call; invalid regular expression will cause any value matching - * - `responseBody` — optional, string for defining response body value, defaults to `emptyObj`. Possible values: + * - `value` is string or regular expression for matching the value of the option passed to fetch call; + * invalid regular expression will cause any value matching + * - `responseBody` — optional, string for defining response body value, + * defaults to `emptyObj`. Possible values: * - `emptyObj` — empty object * - `emptyArr` — empty array - * - `responseType` — optional, string for defining response type, defaults to `default`. Possible values: + * - `responseType` — optional, string for defining response type, + * original response type is used if not specified. Possible values: * - `default` * - `opaque` * @@ -92,7 +97,7 @@ import { * ``` */ /* eslint-enable max-len */ -export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', responseType = 'default') { +export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', responseType) { // 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 @@ -108,12 +113,15 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re } else if (responseBody === 'emptyArr') { strResponseBody = '[]'; } else { + logMessage(source, `Invalid responseBody parameter: ${responseBody}`); return; } - // Skip disallowed response types - if (!(responseType === 'default' || responseType === 'opaque')) { - logMessage(source, `Invalid parameter: ${responseType}`); + // Skip disallowed response types, + // specified responseType has limited list of possible values + if (typeof responseType !== 'undefined' + && !(responseType === 'default' || responseType === 'opaque')) { + logMessage(source, `Invalid responseType parameter: ${responseType}`); return; } @@ -130,7 +138,16 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re if (shouldPrevent) { hit(source); - return noopPromiseResolve(strResponseBody, fetchData.url, responseType); + return Reflect.apply(target, thisArg, args) + .then((origResponse) => { + return modifyResponse( + origResponse, + { + body: strResponseBody, + type: responseType, + }, + ); + }); } return Reflect.apply(target, thisArg, args); @@ -158,6 +175,7 @@ preventFetch.injections = [ noopPromiseResolve, matchRequestProps, logMessage, + modifyResponse, toRegExp, isValidStrPattern, escapeRegExp, diff --git a/tests/scriptlets/prevent-fetch.test.js b/tests/scriptlets/prevent-fetch.test.js index 3e48b78a..f9878c7e 100644 --- a/tests/scriptlets/prevent-fetch.test.js +++ b/tests/scriptlets/prevent-fetch.test.js @@ -1,6 +1,5 @@ /* eslint-disable no-underscore-dangle, no-console */ import { runScriptlet, clearGlobalProps } from '../helpers'; -import { startsWith } from '../../src/helpers/string-utils'; import { isEmptyObject } from '../../src/helpers/object-utils'; const { test, module } = QUnit; @@ -71,7 +70,7 @@ if (!isSupported) { return; } const EXPECTED_LOG_STR_START = `${name}: fetch( url:"${INPUT_JSON_PATH}" method:"${TEST_METHOD}"`; - assert.ok(startsWith(input, EXPECTED_LOG_STR_START), 'console.hit input'); + assert.ok(input.startsWith(EXPECTED_LOG_STR_START), 'console.hit input'); }; // no args -> just logging, no preventing @@ -129,6 +128,7 @@ if (!isSupported) { assert.ok(isEmptyObject(parsedData1), 'Response is mocked'); assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); done(); + // remove 'hit' property for following checking clearGlobalProps('hit'); const response2 = await fetch(inputRequest2); @@ -275,6 +275,23 @@ if (!isSupported) { done(); }); + test('simple fetch - valid response type', async (assert) => { + const OPAQUE_RESPONSE_TYPE = 'opaque'; + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const init = { + method: 'GET', + }; + + runScriptlet(name, ['*', '', OPAQUE_RESPONSE_TYPE]); + const done = assert.async(); + + const response = await fetch(INPUT_JSON_PATH, init); + + assert.strictEqual(response.type, OPAQUE_RESPONSE_TYPE, 'Response type is set'); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }); + test('simple fetch - invalid response type', async (assert) => { const INVALID_RESPONSE_TYPE = 'invalid_type'; const BASIC_RESPONSE_TYPE = 'basic'; @@ -301,4 +318,72 @@ if (!isSupported) { assert.strictEqual(window.hit, undefined, 'hit function fired'); done(); }); + + test('simple fetch -- all original response properties are not modified', async (assert) => { + const TEST_FILE_NAME = 'test01.json'; + const INPUT_JSON_PATH_1 = `${FETCH_OBJECTS_PATH}/${TEST_FILE_NAME}`; + const inputRequest1 = new Request(INPUT_JSON_PATH_1); + const done = assert.async(1); + + runScriptlet(name, ['*']); + + const response = await fetch(inputRequest1); + + /** + * Previously, only one header was present in the returned response + * which was `content-type: text/plain;charset=UTF-8`. + * Since we are not modifying the headers, we expect to receive more than one header. + * We cannot check the exact headers and their values + * because the response may contain different headers + * depending on whether the tests are run in Node or in a browser. + */ + let headersCount = 0; + // eslint-disable-next-line no-unused-vars + for (const key of response.headers.keys()) { + headersCount += 1; + } + + assert.strictEqual(response.type, 'basic', 'response type is "basic" by default, not modified'); + assert.true(response.url.includes(TEST_FILE_NAME), 'response url not modified'); + assert.true(headersCount > 1, 'original headers not modified'); + + const responseJsonData = await response.json(); + assert.ok(isEmptyObject(responseJsonData), 'response data is mocked'); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }); + + test('simple fetch -- original response properties are not modified except type', async (assert) => { + const TEST_FILE_NAME = 'test01.json'; + const TEST_RESPONSE_TYPE = 'opaque'; + const INPUT_JSON_PATH_1 = `${FETCH_OBJECTS_PATH}/${TEST_FILE_NAME}`; + const inputRequest1 = new Request(INPUT_JSON_PATH_1); + const done = assert.async(1); + + runScriptlet(name, ['*', 'emptyArr', TEST_RESPONSE_TYPE]); + + const response = await fetch(inputRequest1); + + let headersCount = 0; + // eslint-disable-next-line no-unused-vars + for (const key of response.headers.keys()) { + headersCount += 1; + } + + assert.strictEqual( + response.type, + TEST_RESPONSE_TYPE, + `response type is modified, equals to ${TEST_RESPONSE_TYPE}`, + ); + assert.true(response.url.includes(TEST_FILE_NAME), 'response url not modified'); + assert.true(headersCount > 1, 'original headers not modified'); + + const responseJsonData = await response.json(); + assert.ok( + Array.isArray(responseJsonData) && responseJsonData.length === 0, + 'response data is an empty array', + ); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }); }