diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d07315..4953241d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- 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 -- issue with reloading website if `$now$`/`$currentDate$` value is used in `trusted-set-cookie-reload` scriptlet [#291](https://github.com/AdguardTeam/Scriptlets/issues/291) +- website reloading if `$now$`/`$currentDate$` value is used + in `trusted-set-cookie-reload` scriptlet [#291](https://github.com/AdguardTeam/Scriptlets/issues/291) ## [v1.9.7] - 2023-03-14 ### Added -- ability for `trusted-click-element` scriptlet to click element if `cookie`/`localStorage` item doesn't exist [#298](https://github.com/AdguardTeam/Scriptlets/issues/298) -- static delay between multiple clicks in `trusted-click-element` [#284](https://github.com/AdguardTeam/Scriptlets/issues/284) +- ability for `trusted-click-element` scriptlet to click element + if `cookie`/`localStorage` item doesn't exist [#298](https://github.com/AdguardTeam/Scriptlets/issues/298) +- static delay between multiple clicks in `trusted-click-element` + [#284](https://github.com/AdguardTeam/Scriptlets/issues/284) ### Changed @@ -23,7 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- issue with `MutationObserver.disconnect()` in `trusted-click-element` [#284](https://github.com/AdguardTeam/Scriptlets/issues/284) +- issue with `MutationObserver.disconnect()` + in `trusted-click-element` [#284](https://github.com/AdguardTeam/Scriptlets/issues/284) ## [v1.9.1] - 2023-03-07 @@ -36,13 +45,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- decreased the minimal value for the `boost` parameter to `0.001` for `adjust-setTimeout` and `adjust-setInterval` [#262](https://github.com/AdguardTeam/Scriptlets/issues/262) +- decreased the minimal value for the `boost` parameter to `0.001` + for `adjust-setTimeout` and `adjust-setInterval` [#262](https://github.com/AdguardTeam/Scriptlets/issues/262) ### Fixed -- `prevent-element-src-loading` throwing error if `thisArg` is `undefined` [#270](https://github.com/AdguardTeam/Scriptlets/issues/270) +- `prevent-element-src-loading` throwing error + if `thisArg` is `undefined` [#270](https://github.com/AdguardTeam/Scriptlets/issues/270) - logging `null` in `json-prune` [#282](https://github.com/AdguardTeam/Scriptlets/issues/282) -- `xml-prune` does not prune a request if `new Request()` is used and issue with throwing error while logging some requests [#289](https://github.com/AdguardTeam/Scriptlets/issues/289) +- `xml-prune`: no pruning a request if `new Request()` is used, + throwing an error while logging some requests [#289](https://github.com/AdguardTeam/Scriptlets/issues/289) - improve performance of the `isValidScriptletName()` method @@ -53,17 +65,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - new `trusted-set-constant` scriptlet [#137](https://github.com/AdguardTeam/Scriptlets/issues/137) - new `inject-css-in-shadow-dom` scriptlet [#267](https://github.com/AdguardTeam/Scriptlets/issues/267) - `throwFunc` and `noopCallbackFunc` prop values for `set-constant` scriptlet -- `recreateIframeForSlot` method mock to `googletagservices-gpt` redirect [#259](https://github.com/AdguardTeam/Scriptlets/issues/259) +- `recreateIframeForSlot` method mock + to `googletagservices-gpt` redirect [#259](https://github.com/AdguardTeam/Scriptlets/issues/259) ### Changed -- add decimal delay matching for `prevent-setInterval` and `prevent-setTimeout` [#247](https://github.com/AdguardTeam/Scriptlets/issues/247) +- add decimal delay matching for `prevent-setInterval` and `prevent-setTimeout` + [#247](https://github.com/AdguardTeam/Scriptlets/issues/247) - debug logging to include rule text when available - `getScriptletFunction` calls to throw error on unknown scriptlet names ### Fixed -- `prevent-xhr` and `trusted-replace-xhr-response` closure bug on multiple requests [#261](https://github.com/AdguardTeam/Scriptlets/issues/261) +- `prevent-xhr` and `trusted-replace-xhr-response` closure bug on multiple requests + [#261](https://github.com/AdguardTeam/Scriptlets/issues/261) - missing `googletagmanager-gtm` in compatibility table @@ -83,7 +98,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `prevent-addEventListener` and `log-addEventListener` loosing context when encountering already bound `.addEventListener` +- `prevent-addEventListener` and `log-addEventListener` loosing context + when encountering already bound `.addEventListener` - `google-ima3` conversion @@ -91,14 +107,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* `set-constant` ADG→UBO conversion for [`emptyArr` and `emptyObj`](https://github.com/uBlockOrigin/uBlock-issues/issues/2411) +- `set-constant` ADG→UBO conversion + for [`emptyArr` and `emptyObj`](https://github.com/uBlockOrigin/uBlock-issues/issues/2411) ## [v1.7.13] - 2022-12-13 ### Fixed -* `isEmptyObject` helper not counting `prototype` as an object property +- `isEmptyObject` helper not counting `prototype` as an object property ## [v1.7.10] - 2022-12-07 @@ -110,7 +127,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `set-cookie-reload` infinite page reloading [#265](https://github.com/AdguardTeam/Scriptlets/issues/265) -- breakage of `prevent-element-src-loading` due to `window` getting into `apply` wrapper [#264](https://github.com/AdguardTeam/Scriptlets/issues/264) +- breakage of `prevent-element-src-loading` due to `window` getting into `apply` wrapper + [#264](https://github.com/AdguardTeam/Scriptlets/issues/264) - spread of args bug at `getXhrData` call for `trusted-replace-xhr-response` - request properties array not being served to `getRequestData` and `parseMatchProps` helpers 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/helpers/string-utils.js b/src/helpers/string-utils.js index a03361a3..0d44e3b8 100644 --- a/src/helpers/string-utils.js +++ b/src/helpers/string-utils.js @@ -90,6 +90,8 @@ export const getBeforeRegExp = (str, rx) => { /** * Checks whether the string starts with the substring * + * @deprecated use String.prototype.startsWith() instead. AG-18883 + * * @param {string} str full string * @param {string} prefix substring * @returns {boolean} if string start with the substring @@ -103,6 +105,8 @@ export const startsWith = (str, prefix) => { /** * Checks whether the string ends with the substring * + * @deprecated use String.prototype.endsWith() instead. AG-18883 + * * @param {string} str full string * @param {string} ending substring * @returns {boolean} string ends with the substring diff --git a/src/scriptlets/prevent-fetch.js b/src/scriptlets/prevent-fetch.js index 1c5e2501..b9bf5404 100644 --- a/src/scriptlets/prevent-fetch.js +++ b/src/scriptlets/prevent-fetch.js @@ -2,9 +2,9 @@ import { hit, getFetchData, objectToString, - noopPromiseResolve, matchRequestProps, logMessage, + modifyResponse, // following helpers should be imported and injected // because they are used by helpers above toRegExp, @@ -24,7 +24,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 +35,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 +96,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,16 +112,27 @@ 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}`); + const isResponseTypeSpecified = typeof responseType !== 'undefined'; + const isResponseTypeSupported = (responseType) => { + const SUPPORTED_TYPES = [ + 'default', + 'opaque', + ]; + return SUPPORTED_TYPES.includes(responseType); + }; + // Skip disallowed response types, + // specified responseType has limited list of possible values + if (isResponseTypeSpecified + && !isResponseTypeSupported(responseType)) { + logMessage(source, `Invalid responseType parameter: '${responseType}'`); return; } - const handlerWrapper = (target, thisArg, args) => { + const handlerWrapper = async (target, thisArg, args) => { let shouldPrevent = false; const fetchData = getFetchData(args); if (typeof propsToMatch === 'undefined') { @@ -130,7 +145,14 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re if (shouldPrevent) { hit(source); - return noopPromiseResolve(strResponseBody, fetchData.url, responseType); + const origResponse = await Reflect.apply(target, thisArg, args); + return modifyResponse( + origResponse, + { + body: strResponseBody, + type: responseType, + }, + ); } return Reflect.apply(target, thisArg, args); @@ -155,9 +177,9 @@ preventFetch.injections = [ hit, getFetchData, objectToString, - 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..2e71b482 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 = `${FETCH_OBJECTS_PATH}/${TEST_FILE_NAME}`; + const inputRequest1 = new Request(INPUT_JSON_PATH); + const done = assert.async(); + + 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 = `${FETCH_OBJECTS_PATH}/${TEST_FILE_NAME}`; + const inputRequest1 = new Request(INPUT_JSON_PATH); + const done = assert.async(); + + 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(); + }); }