From 53892946c7480920be05fbfb16c215df44f1fd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3blewski?= Date: Tue, 7 Nov 2023 18:34:56 +0100 Subject: [PATCH] Add trusted-prune-inbound-object scriptlet --- CHANGELOG.md | 2 + src/helpers/prune-utils.ts | 17 +- src/scriptlets/evaldata-prune.js | 9 +- src/scriptlets/json-prune.js | 8 +- src/scriptlets/scriptlets-list.js | 1 + .../trusted-prune-inbound-object.js | 145 ++++++++++++++++++ tests/scriptlets/evaldata-prune.test.js | 18 ++- tests/scriptlets/index.test.js | 1 + .../trusted-prune-inbound-object.test.js | 136 ++++++++++++++++ 9 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 src/scriptlets/trusted-prune-inbound-object.js create mode 100644 tests/scriptlets/trusted-prune-inbound-object.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5e8680..2e0868a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- new `trusted-prune-inbound-object` scriptlet [#372](https://github.com/AdguardTeam/Scriptlets/issues/372) - new values to `set-cookie` scriptlet: `on`, `off`, `accepted`, `notaccepted`, `rejected`, `allowed`, `disallow`, `enable`, `enabled`, `disable`, `disabled` [#375](https://github.com/AdguardTeam/Scriptlets/issues/375) - new values to `set-local-storage-item` and `set-session-storage-item` scriptlets: `on`, `off` @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- issue with `stack` in `evaldata-prune` scriptlet [#378](https://github.com/AdguardTeam/Scriptlets/issues/378) - issue with setting values to wrong properties in `set-constant` scriptlet [#373](https://github.com/AdguardTeam/Scriptlets/issues/373) diff --git a/src/helpers/prune-utils.ts b/src/helpers/prune-utils.ts index b62abfa4..81632f66 100644 --- a/src/helpers/prune-utils.ts +++ b/src/helpers/prune-utils.ts @@ -11,6 +11,8 @@ import { matchStackTrace } from './match-stack'; * @param root object which should be pruned or logged * @param prunePaths array with string of space-separated property chains to remove * @param requiredPaths array with string of space-separated propertiy chains + * @param stack string which should be matched by stack trace + * @param nativeObjects reference to native objects, required for a trusted-prune-inbound-object to fix infinite loop * which must be all present for the pruning to occur * @returns true if prunning is required */ @@ -20,22 +22,25 @@ export function isPruningNeeded( prunePaths: string[], requiredPaths: string[], stack: string, + nativeObjects: any, ): boolean | undefined { if (!root) { return false; } + const { nativeStringify } = nativeObjects; + let shouldProcess; // Only log hostname and matched JSON payload if only second argument is present if (prunePaths.length === 0 && requiredPaths.length > 0) { - const rootString = JSON.stringify(root); + const rootString = nativeStringify(root); const matchRegex = toRegExp(requiredPaths.join('')); const shouldLog = matchRegex.test(rootString); if (shouldLog) { logMessage( source, - `${window.location.hostname}\n${JSON.stringify(root, null, 2)}\nStack trace:\n${new Error().stack}`, + `${window.location.hostname}\n${nativeStringify(root, null, 2)}\nStack trace:\n${new Error().stack}`, true, ); if (root && typeof root === 'object') { @@ -95,6 +100,8 @@ export function isPruningNeeded( * @param root object which should be pruned or logged * @param prunePaths array with string of space-separated properties to remove * @param requiredPaths array with string of space-separated properties + * @param stack string which should be matched by stack trace + * @param nativeObjects reference to native objects, required for a trusted-prune-inbound-object to fix infinite loop * which must be all present for the pruning to occur * @returns pruned root */ @@ -104,11 +111,13 @@ export const jsonPruner = ( prunePaths: string[], requiredPaths: string[], stack: string, + nativeObjects: any, ): ArbitraryObject => { + const { nativeStringify } = nativeObjects; if (prunePaths.length === 0 && requiredPaths.length === 0) { logMessage( source, - `${window.location.hostname}\n${JSON.stringify(root, null, 2)}\nStack trace:\n${new Error().stack}`, + `${window.location.hostname}\n${nativeStringify(root, null, 2)}\nStack trace:\n${new Error().stack}`, true, ); if (root && typeof root === 'object') { @@ -118,7 +127,7 @@ export const jsonPruner = ( } try { - if (isPruningNeeded(source, root, prunePaths, requiredPaths, stack) === false) { + if (isPruningNeeded(source, root, prunePaths, requiredPaths, stack, nativeObjects) === false) { return root; } diff --git a/src/scriptlets/evaldata-prune.js b/src/scriptlets/evaldata-prune.js index d7466343..53bba8c2 100644 --- a/src/scriptlets/evaldata-prune.js +++ b/src/scriptlets/evaldata-prune.js @@ -96,9 +96,6 @@ import { */ /* eslint-enable max-len */ export function evalDataPrune(source, propsToRemove, requiredInitialProps, stack) { - if (!!stack && !matchStackTrace(stack, new Error().stack)) { - return; - } const prunePaths = propsToRemove !== undefined && propsToRemove !== '' ? propsToRemove.split(/ +/) : []; @@ -106,10 +103,14 @@ export function evalDataPrune(source, propsToRemove, requiredInitialProps, stack ? requiredInitialProps.split(/ +/) : []; + const nativeObjects = { + nativeStringify: window.JSON.stringify, + }; + const evalWrapper = (target, thisArg, args) => { let data = Reflect.apply(target, thisArg, args); if (typeof data === 'object') { - data = jsonPruner(source, data, prunePaths, requiredPaths); + data = jsonPruner(source, data, prunePaths, requiredPaths, stack, nativeObjects); } return data; }; diff --git a/src/scriptlets/json-prune.js b/src/scriptlets/json-prune.js index 81cedf44..2e52ad96 100644 --- a/src/scriptlets/json-prune.js +++ b/src/scriptlets/json-prune.js @@ -106,12 +106,16 @@ export function jsonPrune(source, propsToRemove, requiredInitialProps, stack = ' ? requiredInitialProps.split(/ +/) : []; + const nativeObjects = { + nativeStringify: window.JSON.stringify, + }; + const nativeJSONParse = JSON.parse; const jsonParseWrapper = (...args) => { // dealing with stringified json in args, which should be parsed. // so we call nativeJSONParse as JSON.parse which is bound to JSON object const root = nativeJSONParse.apply(JSON, args); - return jsonPruner(source, root, prunePaths, requiredPaths, stack); + return jsonPruner(source, root, prunePaths, requiredPaths, stack, nativeObjects); }; // JSON.parse mocking @@ -123,7 +127,7 @@ export function jsonPrune(source, propsToRemove, requiredInitialProps, stack = ' const responseJsonWrapper = function () { const promise = nativeResponseJson.apply(this); return promise.then((obj) => { - return jsonPruner(source, obj, prunePaths, requiredPaths, stack); + return jsonPruner(source, obj, prunePaths, requiredPaths, stack, nativeObjects); }); }; diff --git a/src/scriptlets/scriptlets-list.js b/src/scriptlets/scriptlets-list.js index 75637382..4da4f759 100644 --- a/src/scriptlets/scriptlets-list.js +++ b/src/scriptlets/scriptlets-list.js @@ -60,3 +60,4 @@ export * from './inject-css-in-shadow-dom'; export * from './remove-node-text'; export * from './trusted-replace-node-text'; export * from './evaldata-prune'; +export * from './trusted-prune-inbound-object'; diff --git a/src/scriptlets/trusted-prune-inbound-object.js b/src/scriptlets/trusted-prune-inbound-object.js new file mode 100644 index 00000000..e38ec618 --- /dev/null +++ b/src/scriptlets/trusted-prune-inbound-object.js @@ -0,0 +1,145 @@ +import { + hit, + matchStackTrace, + getPropertyInChain, + getWildcardPropertyInChain, + logMessage, + isPruningNeeded, + jsonPruner, + // following helpers are needed for helpers above + toRegExp, + getNativeRegexpTest, + shouldAbortInlineOrInjectedScript, + isEmptyObject, +} from '../helpers/index'; + +/* eslint-disable max-len */ +/** + * @scriptlet trusted-prune-inbound-object + * + * @description + * Removes listed properties from the result of calling specific function (if payload contains `Object`) and returns to the caller. + * + * Related UBO scriptlet: + * https://github.com/gorhill/uBlock/commit/1c9da227d7 + * + * ### Syntax + * + * ```text + * example.org#%#//scriptlet('trusted-prune-inbound-object'[inboundObject, [, propsToRemove [, obligatoryProps [, stack]]]]) + * ``` + * + * - `inboundObject` — required, the name of the function to trap + * - `propsToRemove` — optional, string of space-separated properties to remove + * - `obligatoryProps` — optional, string of space-separated properties + * which must be all present for the pruning to occur + * - `stack` — optional, string or regular expression that must match the current function call stack trace; + * if regular expression is invalid it will be skipped + * + * > Note please that you can use wildcard `*` for chain property name, + * > e.g. `ad.*.src` instead of `ad.0.src ad.1.src ad.2.src`. + * + * ### Examples + * + * 1. Removes property `example` from the payload of the Object.getOwnPropertyNames call + * + * ```adblock + * example.org#%#//scriptlet('trusted-prune-inbound-object', 'Object.getOwnPropertyNames', 'example') + * ``` + * + * For instance, the following call will return `['one']` + * + * ```html + * Object.getOwnPropertyNames({ one: 1, example: true }) + * ``` + * + * 2. Removes property `ads` from the payload of the Object.keys call + * + * ```adblock + * example.org#%#//scriptlet('trusted-prune-inbound-object', 'Object.keys', 'ads') + * ``` + * + * For instance, the following call will return `['one', 'two']` + * + * ```html + * Object.keys({ one: 1, two: 2, ads: true }) + * ``` + * + * 3. Removes property `foo.bar` from the payload of the JSON.stringify call + * + * ```adblock + * example.org#%#//scriptlet('trusted-prune-inbound-object', 'JSON.stringify', 'foo.bar') + * ``` + * + * For instance, the following call will return `'{"foo":{"a":2},"b":3}'` + * + * ```html + * JSON.stringify({ foo: { bar: 1, a: 2 }, b: 3 }) + * ``` + * + * 4. Removes property `foo.bar` from the payload of the JSON.stringify call if its error stack trace contains `test.js` + * + * ```adblock + * example.org#%#//scriptlet('trusted-prune-inbound-object', 'JSON.stringify', 'foo.bar', '', 'test.js') + * ``` + * + * + */ +/* eslint-enable max-len */ + +export function trustedPruneInboundObject(source, inboundObject, propsToRemove, requiredInitialProps, stack = '') { + if (!inboundObject) { + return; + } + + const nativeObjects = { + nativeStringify: window.JSON.stringify, + }; + + const { base, prop } = getPropertyInChain(window, inboundObject); + if (!base || !prop || typeof base[prop] !== 'function') { + return; + } + + const prunePaths = propsToRemove !== undefined && propsToRemove !== '' + ? propsToRemove.split(/ +/) + : []; + const requiredPaths = requiredInitialProps !== undefined && requiredInitialProps !== '' + ? requiredInitialProps.split(/ +/) + : []; + + const objectWrapper = (target, thisArg, args) => { + let data = args[0]; + if (typeof data === 'object') { + data = jsonPruner(source, data, prunePaths, requiredPaths, stack, nativeObjects); + args[0] = data; + } + return Reflect.apply(target, thisArg, args); + }; + + const objecthHandler = { + apply: objectWrapper, + }; + + base[prop] = new Proxy(base[prop], objecthHandler); +} + +trustedPruneInboundObject.names = [ + 'trusted-prune-inbound-object', + // trusted scriptlets support no aliases +]; + +trustedPruneInboundObject.injections = [ + hit, + matchStackTrace, + getPropertyInChain, + getWildcardPropertyInChain, + logMessage, + isPruningNeeded, + jsonPruner, + // following helpers are needed for helpers above + toRegExp, + getNativeRegexpTest, + shouldAbortInlineOrInjectedScript, + isEmptyObject, +]; diff --git a/tests/scriptlets/evaldata-prune.test.js b/tests/scriptlets/evaldata-prune.test.js index f3d76a88..acdb32c2 100644 --- a/tests/scriptlets/evaldata-prune.test.js +++ b/tests/scriptlets/evaldata-prune.test.js @@ -169,18 +169,26 @@ test('does NOT remove propsToRemove if invoked without parameter propsToRemove a }); test('removes propsToRemove + stack match', (assert) => { - const stackMatch = 'evaldata-prune'; + const firstStackMatch = 'testForFirstStackMatch'; + const secondStackMatch = 'testForSecondStackMatch'; + + runScriptlet(name, ['c', '', firstStackMatch]); + + const testForFirstStackMatch = () => eval({ a: 1, b: 2, c: 3 }); + const firstResult = testForFirstStackMatch(); - runScriptlet(name, ['c', '', stackMatch]); assert.deepEqual( - eval({ a: 1, b: 2, c: 3 }), + firstResult, { a: 1, b: 2 }, 'stack match: should remove single propsToRemove', ); - runScriptlet(name, ['nested.c nested.b', '', stackMatch]); + runScriptlet(name, ['nested.c nested.b', '', secondStackMatch]); + const testForSecondStackMatch = () => eval({ nested: { a: 1, b: 2, c: 3 } }); + const secondResult = testForSecondStackMatch(); + assert.deepEqual( - eval({ nested: { a: 1, b: 2, c: 3 } }), + secondResult, { nested: { a: 1 } }, 'stack match: should remove multiple nested propsToRemove', ); diff --git a/tests/scriptlets/index.test.js b/tests/scriptlets/index.test.js index 1301dbf4..83e15880 100644 --- a/tests/scriptlets/index.test.js +++ b/tests/scriptlets/index.test.js @@ -54,3 +54,4 @@ import './trusted-set-constant.test'; import './inject-css-in-shadow-dom.test'; import './remove-node-text.test'; import './trusted-replace-node-text.test'; +import './trusted-prune-inbound-object.test'; diff --git a/tests/scriptlets/trusted-prune-inbound-object.test.js b/tests/scriptlets/trusted-prune-inbound-object.test.js new file mode 100644 index 00000000..806c48f1 --- /dev/null +++ b/tests/scriptlets/trusted-prune-inbound-object.test.js @@ -0,0 +1,136 @@ +/* eslint-disable no-underscore-dangle, no-console */ +import { runScriptlet, clearGlobalProps } from '../helpers'; + +const { test, module } = QUnit; +const name = 'trusted-prune-inbound-object'; + +// It's used in tests which check if console.log was called to exclude puppeteer logs +// otherwise it will stuck in infinite loop +const PUPPETEER_MARKER = '"qunit_puppeteer_runner_log"'; + +const nativeConsole = console.log; +const nativeStringify = JSON.stringify; +const nativeObjKeys = Object.keys; +const nativeObjGetOwnPropNames = Object.getOwnPropertyNames; +const nativeSeal = Object.seal; +// eslint-disable-next-line no-eval +const nativeEval = eval; + +const beforeEach = () => { + window.__debug = () => { + window.hit = 'FIRED'; + }; +}; + +const afterEach = () => { + clearGlobalProps('hit', '__debug'); + console.log = nativeConsole; + JSON.stringify = nativeStringify; + Object.keys = nativeObjKeys; + Object.getOwnPropertyNames = nativeObjGetOwnPropNames; + Object.seal = nativeSeal; + // eslint-disable-next-line no-eval + window.eval = nativeEval; +}; + +module(name, { beforeEach, afterEach }); + +test('logs matched object and hostname if invoked with only first arg - JSON.stringify', (assert) => { + console.log('This is a test'); + assert.expect(2); + console.log = (...args) => { + if (args.length === 1 && !args[0].includes(PUPPETEER_MARKER)) { + assert.ok(args[0].includes(window.location.hostname), 'should log hostname in console'); + assert.ok(args[0].includes('"abcdef": 1'), 'should log parameters in console'); + } + nativeConsole(...args); + }; + runScriptlet(name, ['JSON.stringify']); + JSON.stringify({ abcdef: 1 }); +}); + +test('logs matched object and hostname if invoked with only first and third arg - JSON.stringify', (assert) => { + assert.expect(4); + console.log = (...args) => { + if (args.length === 1 && !args[0].includes(PUPPETEER_MARKER)) { + assert.ok(args[0].includes(window.location.hostname), 'should log hostname in console'); + assert.ok(args[0].includes('"testLog": 1'), 'should log parameters in console'); + assert.notOk(args[0].includes('doNotLog'), 'should not log parameters in console'); + } + nativeConsole(...args); + }; + + runScriptlet(name, ['JSON.stringify', '', 'zx']); + assert.deepEqual( + JSON.stringify({ zx: { testLog: 1 }, y: 2 }), + '{"zx":{"testLog":1},"y":2}', + 'should remove propsToRemove if equals to requiredInitialProps', + ); + + JSON.stringify({ asdfg: { doNotLog: 1 }, y: 2 }); +}); + +test('removes propsToRemove - Object.getOwnPropertyNames, Object.keys , eval', (assert) => { + runScriptlet(name, ['Object.getOwnPropertyNames', 'c']); + assert.deepEqual( + Object.getOwnPropertyNames({ a: 1, b: 2, c: 3 }), + ['a', 'b'], 'should remove single propsToRemove', + ); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + + runScriptlet(name, ['Object.keys', 'ads foo']); + assert.deepEqual( + Object.keys({ q: 1, ads: true, foo: 'bar' }), + ['q'], 'should remove multiple propsToRemove', + ); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + + runScriptlet(name, ['eval', 'ads foo']); + assert.deepEqual( + // eslint-disable-next-line no-eval + eval({ q: 1, ads: true, foo: 'bar' }), + { q: 1 }, 'should remove multiple propsToRemove', + ); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); +}); + +test('removes propsToRemove if requiredInitialProps are specified - JSON.stringify', (assert) => { + runScriptlet(name, ['JSON.stringify', 'y', 'y']); + assert.deepEqual( + JSON.stringify({ z: 1, y: 2 }), + '{"z":1}', + 'should remove propsToRemove if equals to requiredInitialProps', + ); + runScriptlet(name, ['JSON.stringify', 'test', 'qwerty']); + assert.deepEqual( + JSON.stringify({ test: 1, qwerty: 2 }), + '{"qwerty":2}', + 'should remove propsToRemove if single requiredInitialProps is specified', + ); +}); + +test('removes propsToRemove + stack match - Object.seal', (assert) => { + const stackMatch = 'helloThere'; + + runScriptlet(name, ['Object.seal', 'c', '', stackMatch]); + + const helloThere = () => Object.seal({ a: 1, b: 2, c: 3 }); + const result = helloThere(); + + assert.deepEqual( + result, + { a: 1, b: 2 }, + 'stack match: should remove single propsToRemove', + ); +}); + +test('can NOT remove propsToRemove because of no stack match - Object.seal', (assert) => { + const stackNoMatch = 'no_match.js'; + + runScriptlet(name, ['Object.seal', 'c', '', stackNoMatch]); + assert.deepEqual( + Object.seal({ a: 1, b: 2, c: 3 }), + { a: 1, b: 2, c: 3 }, + 'stack match: should remove single propsToRemove', + ); +});