diff --git a/CHANGELOG.md b/CHANGELOG.md index 48aa9a10c..778b12f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic +## [Unreleased] + +### Added + +- support for matching line number in `abort-on-stack-trace` scriptlet + when `inlineScript` or `injectedScript` option is used [#439] + +[Unreleased]: https://github.com/AdguardTeam/Scriptlets/compare/v1.11.16...HEAD +[#439]: https://github.com/AdguardTeam/Scriptlets/issues/439 + ## [v1.11.16] - 2024-08-01 ### Added diff --git a/src/helpers/script-source-utils.ts b/src/helpers/script-source-utils.ts index 9e923900a..520aa2023 100644 --- a/src/helpers/script-source-utils.ts +++ b/src/helpers/script-source-utils.ts @@ -1,3 +1,5 @@ +import { toRegExp } from './string-utils'; + /** * Determines if type of script is inline or injected * and when it's one of them then return true, otherwise false @@ -30,27 +32,32 @@ export const shouldAbortInlineOrInjectedScript = (stackMatch: string, stackTrace const stackSteps = stackTrace.split('\n').slice(2).map((line) => line.trim()); const stackLines = stackSteps.map((line) => { let stack; - // Get stack trace URL + // Get stack trace values // in Firefox stack trace looks like this: advanceTaskQueue@http://127.0.0.1:8080/scriptlets/tests/dist/qunit.js:1834:20 // in Chrome like this: at Assert.throws (http://127.0.0.1:8080/scriptlets/tests/dist/qunit.js:3178:16) - // so, first group "(.*?@)" is required for Firefox, second group contains URL - const getStackTraceURL = /(.*?@)?(\S+)(:\d+):\d+\)?$/.exec(line); - if (getStackTraceURL) { - let stackURL = getStackTraceURL[2]; + // so, first group "(.*?@)" is required for Firefox, second group contains URL, + // third group contains line number, fourth group contains column number + const getStackTraceValues = /(.*?@)?(\S+)(:\d+)(:\d+)\)?$/.exec(line); + if (getStackTraceValues) { + let stackURL = getStackTraceValues[2]; + const stackLine = getStackTraceValues[3]; + const stackCol = getStackTraceValues[4]; if (stackURL?.startsWith('(')) { stackURL = stackURL.slice(1); } if (stackURL?.startsWith(INJECTED_SCRIPT_MARKER)) { stackURL = INJECTED_SCRIPT_STRING; - let stackFunction = getStackTraceURL[1] !== undefined - ? getStackTraceURL[1].slice(0, -1) - : line.slice(0, getStackTraceURL.index).trim(); + let stackFunction = getStackTraceValues[1] !== undefined + ? getStackTraceValues[1].slice(0, -1) + : line.slice(0, getStackTraceValues.index).trim(); if (stackFunction?.startsWith('at')) { stackFunction = stackFunction.slice(2).trim(); } - stack = `${stackFunction} ${stackURL}`.trim(); + stack = `${stackFunction} ${stackURL}${stackLine}${stackCol}`.trim(); + } else if (stackURL === documentURL) { + stack = `${INLINE_SCRIPT_STRING}${stackLine}${stackCol}`.trim(); } else { - stack = stackURL; + stack = `${stackURL}${stackLine}${stackCol}`.trim(); } } else { stack = line; @@ -59,11 +66,18 @@ export const shouldAbortInlineOrInjectedScript = (stackMatch: string, stackTrace }); if (stackLines) { for (let index = 0; index < stackLines.length; index += 1) { - if (isInlineScript(stackMatch) && documentURL === stackLines[index]) { + if ( + isInlineScript(stackMatch) + && stackLines[index].startsWith(INLINE_SCRIPT_STRING) + && stackLines[index].match(toRegExp(stackMatch)) + ) { return true; } - if (isInjectedScript(stackMatch) - && stackLines[index].startsWith(INJECTED_SCRIPT_STRING)) { + if ( + isInjectedScript(stackMatch) + && stackLines[index].startsWith(INJECTED_SCRIPT_STRING) + && stackLines[index].match(toRegExp(stackMatch)) + ) { return true; } } diff --git a/tests/scriptlets/abort-on-stack-trace.test.js b/tests/scriptlets/abort-on-stack-trace.test.js index db58037b5..50cd17ea5 100644 --- a/tests/scriptlets/abort-on-stack-trace.test.js +++ b/tests/scriptlets/abort-on-stack-trace.test.js @@ -317,6 +317,71 @@ test('abort Math.random, injected script', (assert) => { assert.strictEqual(window.hit, 'FIRED', 'hit fired'); }); +test('abort Math.ceil, injected script with line number', (assert) => { + const property = 'Math.ceil'; + const stackMatch = 'injectedScript:1'; + const scriptletArgs = [property, stackMatch]; + runScriptlet(name, scriptletArgs); + + window.testPassed = false; + const scriptElement = document.createElement('script'); + scriptElement.type = 'text/javascript'; + // set window.testPassed to true if script is aborted + // eslint-disable-next-line max-len + scriptElement.innerText = 'try { Math.ceil(2.1); } catch(error) { window.testPassed = true; console.log("Script aborted:", error); }'; + document.body.appendChild(scriptElement); + scriptElement.parentNode.removeChild(scriptElement); + + assert.strictEqual(window.testPassed, true, 'testPassed set to true, script has been aborted'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('abort Math.floor, injected script with line number regexp', (assert) => { + const property = 'Math.floor'; + const stackMatch = '/injectedScript:\\d:\\d/'; + const scriptletArgs = [property, stackMatch]; + runScriptlet(name, scriptletArgs); + + window.testPassed = false; + const scriptElement = document.createElement('script'); + scriptElement.type = 'text/javascript'; + // set window.testPassed to true if script is aborted + // eslint-disable-next-line max-len + scriptElement.innerText = 'try { Math.floor(1.1); } catch(error) { window.testPassed = true; console.log("Script aborted:", error); }'; + document.body.appendChild(scriptElement); + scriptElement.parentNode.removeChild(scriptElement); + + assert.strictEqual(window.testPassed, true, 'testPassed set to true, script has been aborted'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('abort Math.pow, injected script with line number regexp, two scripts abort only first', (assert) => { + const property = 'Math.pow'; + const stackMatch = '/injectedScript:\\d:1/'; + const scriptletArgs = [property, stackMatch]; + runScriptlet(name, scriptletArgs); + + window.testPassed = false; + const scriptElement1 = document.createElement('script'); + scriptElement1.type = 'text/javascript'; + // set window.testPassed to true if script is aborted + // eslint-disable-next-line max-len + scriptElement1.innerText = 'try { Math.pow(2, 2); } catch(error) { window.testPassed = true; console.log("Script aborted:", error); }'; + document.body.appendChild(scriptElement1); + scriptElement1.parentNode.removeChild(scriptElement1); + + const scriptElement2 = document.createElement('script'); + scriptElement2.type = 'text/javascript'; + // This script should not be aborted, so set window.testPassed to false if script is aborted + // eslint-disable-next-line max-len + scriptElement2.innerText = 'try { (()=>{ const test1 = 1; const test2 = 2; const test3 = 3; const test4 = Math.pow(2, 2); })() } catch(error) { window.testPassed = false; }'; + document.body.appendChild(scriptElement2); + scriptElement2.parentNode.removeChild(scriptElement2); + + assert.strictEqual(window.testPassed, true, 'testPassed set to true, only first script has been aborted'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + test('abort String.fromCharCode, inline script', (assert) => { const property = 'String.fromCharCode'; const stackMatch = 'inlineScript'; @@ -330,6 +395,46 @@ test('abort String.fromCharCode, inline script', (assert) => { assert.strictEqual(window.hit, 'FIRED', 'hit fired'); }); +test('abort String.fromCodePoint, inline script line number regexp', (assert) => { + const property = 'String.fromCodePoint'; + const stackMatch = '/inlineScript:\\d/'; + const scriptletArgs = [property, stackMatch]; + runScriptlet(name, scriptletArgs); + assert.throws( + () => String.fromCodePoint(65), + /ReferenceError/, + 'Reference error thrown when trying to access property String.fromCodePoint', + ); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('abort JSON.parse, inline script line number regexp, two scripts abort only second', (assert) => { + const property = 'JSON.parse'; + const stackMatch = '/inlineScript:33/'; + const scriptletArgs = [property, stackMatch]; + runScriptlet(name, scriptletArgs); + + let obj = {}; + + // This should not be aborted + try { + obj = JSON.parse('{"test":true}'); + } catch (error) { + /* empty */ + } + + assert.throws( + () => { + const objString = '{}'; + JSON.parse(objString); + }, + /ReferenceError/, + 'Reference error thrown when trying to access property JSON.parse', + ); + assert.strictEqual(obj.test, true, 'obj.test is true'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + test('do NOT abort Math.round, test for injected script', (assert) => { const property = 'Math.round'; const stackMatch = 'injectedScript';