diff --git a/.eslintrc b/.eslintrc index 7cd5f0b5..d997e1b5 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,7 +11,7 @@ "qunit": true }, "rules": { - "indent": ["error", 4], + "indent": ["error", 4, { "SwitchCase": 1}], "no-param-reassign": 0, "no-shadow": 0, "import/prefer-default-export": 0, diff --git a/src/helpers/parse-rule.js b/src/helpers/parse-rule.js index 35284923..3ca15544 100644 --- a/src/helpers/parse-rule.js +++ b/src/helpers/parse-rule.js @@ -72,27 +72,27 @@ export const parseRule = (ruleText) => { const char = rule[index]; let transition; switch (char) { - case ' ': - case '(': - case ',': { - transition = TRANSITION.OPENED; - break; - } - case '\'': - case '"': { - sep.symb = char; - transition = TRANSITION.PARAM; - break; - } - case ')': { - transition = index === rule.length - 1 - ? TRANSITION.CLOSED - : TRANSITION.OPENED; - break; - } - default: { - throw new Error('The rule is not a scriptlet'); - } + case ' ': + case '(': + case ',': { + transition = TRANSITION.OPENED; + break; + } + case '\'': + case '"': { + sep.symb = char; + transition = TRANSITION.PARAM; + break; + } + case ')': { + transition = index === rule.length - 1 + ? TRANSITION.CLOSED + : TRANSITION.OPENED; + break; + } + default: { + throw new Error('The rule is not a scriptlet'); + } } return transition; @@ -108,21 +108,21 @@ export const parseRule = (ruleText) => { const param = (rule, index, { saver, sep }) => { const char = rule[index]; switch (char) { - case '\'': - case '"': { - const preIndex = index - 1; - const before = rule[preIndex]; - if (char === sep.symb && before !== '\\') { - sep.symb = null; - saver.saveStr(); - return TRANSITION.OPENED; + case '\'': + case '"': { + const preIndex = index - 1; + const before = rule[preIndex]; + if (char === sep.symb && before !== '\\') { + sep.symb = null; + saver.saveStr(); + return TRANSITION.OPENED; + } + } + // eslint-disable-next-line no-fallthrough + default: { + saver.saveSymb(char); + return TRANSITION.PARAM; } - } - // eslint-disable-next-line no-fallthrough - default: { - saver.saveSymb(char); - return TRANSITION.PARAM; - } } }; const transitions = { diff --git a/src/scriptlets/prevent-element-src-loading.js b/src/scriptlets/prevent-element-src-loading.js index 58a6777e..d191a1c6 100644 --- a/src/scriptlets/prevent-element-src-loading.js +++ b/src/scriptlets/prevent-element-src-loading.js @@ -2,6 +2,7 @@ import { hit, toRegExp, safeGetDescriptor, + noopFunc, } from '../helpers/index'; /* eslint-disable max-len, consistent-return */ @@ -34,6 +35,7 @@ export function preventElementSrcLoading(source, tagName, match) { if (typeof Proxy === 'undefined' || typeof Reflect === 'undefined') { return; } + const srcMockData = { // "KCk9Pnt9" = "()=>{}" script: 'data:text/javascript;base64,KCk9Pnt9', @@ -42,6 +44,7 @@ export function preventElementSrcLoading(source, tagName, match) { // Empty h1 tag iframe: 'data:text/html;base64, PGRpdj48L2Rpdj4=', }; + let instance; if (tagName === 'script') { instance = HTMLScriptElement; @@ -52,6 +55,7 @@ export function preventElementSrcLoading(source, tagName, match) { } else { return; } + // For websites that use Trusted Types // https://w3c.github.io/webappsec-trusted-types/dist/spec/ const hasTrustedTypes = window.trustedTypes && typeof window.trustedTypes.createPolicy === 'function'; @@ -61,9 +65,15 @@ export function preventElementSrcLoading(source, tagName, match) { createScriptURL: (arg) => arg, }); } + const SOURCE_PROPERTY_NAME = 'src'; + const ONERROR_PROPERTY_NAME = 'onerror'; const searchRegexp = toRegExp(match); + // This will be needed to silent error events on matched element, + // as url wont be available + const setMatchedAttribute = (elem) => elem.setAttribute(source.name, 'matched'); + const setAttributeWrapper = (target, thisArg, args) => { // Check if arguments are present if (!args[0] || !args[1]) { @@ -82,6 +92,7 @@ export function preventElementSrcLoading(source, tagName, match) { } hit(source); + setMatchedAttribute(thisArg); // Forward the URI that corresponds with element's MIME type return Reflect.apply(target, thisArg, [attrName, srcMockData[nodeName]]); }; @@ -92,15 +103,15 @@ export function preventElementSrcLoading(source, tagName, match) { // eslint-disable-next-line max-len instance.prototype.setAttribute = new Proxy(Element.prototype.setAttribute, setAttributeHandler); - const origDescriptor = safeGetDescriptor(instance.prototype, SOURCE_PROPERTY_NAME); - if (!origDescriptor) { + const origSrcDescriptor = safeGetDescriptor(instance.prototype, SOURCE_PROPERTY_NAME); + if (!origSrcDescriptor) { return; } Object.defineProperty(instance.prototype, SOURCE_PROPERTY_NAME, { enumerable: true, configurable: true, get() { - return origDescriptor.get.call(this); + return origSrcDescriptor.get.call(this); }, set(urlValue) { const nodeName = this.nodeName.toLowerCase(); @@ -109,21 +120,71 @@ export function preventElementSrcLoading(source, tagName, match) { && searchRegexp.test(urlValue); if (!isMatched) { - origDescriptor.set.call(this, urlValue); - return; + origSrcDescriptor.set.call(this, urlValue); + return true; } // eslint-disable-next-line no-undef if (policy && urlValue instanceof TrustedScriptURL) { const trustedSrc = policy.createScriptURL(urlValue); - origDescriptor.set.call(this, trustedSrc); + origSrcDescriptor.set.call(this, trustedSrc); hit(source); return; } - origDescriptor.set.call(this, srcMockData[nodeName]); + setMatchedAttribute(this); + origSrcDescriptor.set.call(this, srcMockData[nodeName]); hit(source); }, }); + + // https://github.com/AdguardTeam/Scriptlets/issues/228 + // Prevent error event being triggered by other sources + const origOnerrorDescriptor = safeGetDescriptor(HTMLElement.prototype, ONERROR_PROPERTY_NAME); + if (!origOnerrorDescriptor) { + return; + } + + Object.defineProperty(HTMLElement.prototype, ONERROR_PROPERTY_NAME, { + enumerable: true, + configurable: true, + get() { + return origOnerrorDescriptor.get.call(this); + }, + set(cb) { + const isMatched = this.getAttribute(source.name) === 'matched'; + + if (!isMatched) { + origOnerrorDescriptor.set.call(this, cb); + return true; + } + + origOnerrorDescriptor.set.call(this, noopFunc); + return true; + }, + }); + + const addEventListenerWrapper = (target, thisArg, args) => { + // Check if arguments are present + if (!args[0] || !args[1]) { + return Reflect.apply(target, thisArg, args); + } + + const eventName = args[0]; + const isMatched = thisArg.getAttribute(source.name) === 'matched' + && eventName === 'error'; + + if (isMatched) { + return Reflect.apply(target, thisArg, [eventName, noopFunc]); + } + + return Reflect.apply(target, thisArg, args); + }; + + const addEventListenerHandler = { + apply: addEventListenerWrapper, + }; + // eslint-disable-next-line max-len + EventTarget.prototype.addEventListener = new Proxy(EventTarget.prototype.addEventListener, addEventListenerHandler); } preventElementSrcLoading.names = [ @@ -134,4 +195,5 @@ preventElementSrcLoading.injections = [ hit, toRegExp, safeGetDescriptor, + noopFunc, ]; diff --git a/tests/scriptlets/prevent-element-src-loading.test.js b/tests/scriptlets/prevent-element-src-loading.test.js index 5e2e4a6b..d345fa4a 100644 --- a/tests/scriptlets/prevent-element-src-loading.test.js +++ b/tests/scriptlets/prevent-element-src-loading.test.js @@ -17,36 +17,51 @@ const afterEach = () => { clearGlobalProps('hit', '__debug', 'elem'); }; -const createTagWithSetAttr = (assert, nodeName, url) => { - const done = assert.async(); +const SET_SRC_ATTRIBUTE = 'setSrcAttribute'; +const SET_SRC_PROP = 'srcProp'; +const ONERROR_PROP = 'onerrorProp'; +const ERROR_LISTENER = 'addErrorListener'; +// Create tag using combination of different methods +const createTestTag = (assert, nodeName, url, srcMethod, onerrorMethod) => { + const done = assert.async(); const node = document.createElement(nodeName); - node.onload = () => { - assert.ok(true, '.onload triggered'); - done(); - }; - node.onerror = () => { - assert.ok(false, '.onerror triggered'); - }; - node.setAttribute('src', url); - document.body.append(node); - return node; -}; -const createTagWithSrcProp = (assert, nodeName, url) => { - const done = assert.async(); + // Set url with .src or .setAttribute + switch (srcMethod) { + case SET_SRC_PROP: { + node.src = url; + break; + } + case SET_SRC_ATTRIBUTE: { + node.setAttribute('src', url); + break; + } + default: + // do nothing + } + + // Set onerror callback with .onerror or .addEventListener + const onerrorHandler = () => assert.ok(false, '.onerror should not have been triggered'); + switch (onerrorMethod) { + case ONERROR_PROP: { + node.onerror = onerrorHandler; + break; + } + case ERROR_LISTENER: { + node.addEventListener('error', onerrorHandler); + break; + } + default: + // do nothing + } - const node = document.createElement(nodeName); node.onload = () => { assert.ok(true, '.onload triggered'); done(); }; - node.onerror = () => { - assert.ok(false, '.onerror triggered'); - }; - node.src = url; - document.body.append(node); + document.body.appendChild(node); return node; }; @@ -66,123 +81,214 @@ const IFRAME_TARGET_NODE = 'iframe'; module(name, { beforeEach, afterEach }); -/** - * document.body.append does not work in Edge 15 - * https://caniuse.com/mdn-api_element_append - */ -const isSupported = (() => typeof document.body.append !== 'undefined')(); - -if (!isSupported) { - test('unsupported', (assert) => { - assert.ok(true, 'Browser does not support it'); - }); -} else { - test('setAttribute, matching script element', (assert) => { - const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT01_FILENAME}`; - const scriptletArgs = [SCRIPT_TARGET_NODE, TEST_SCRIPT01_FILENAME]; - runScriptlet(name, scriptletArgs); - - var elem = createTagWithSetAttr(assert, SCRIPT_TARGET_NODE, SOURCE_PATH); - assert.strictEqual(elem.src, srcMockData[SCRIPT_TARGET_NODE], 'src was mocked'); - assert.strictEqual(window.hit, 'FIRED', 'hit fired'); - }); - - test('setAttribute, matching image element', (assert) => { - const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_IMAGE_FILENAME}`; - const scriptletArgs = [IMG_TARGET_NODE, TEST_IMAGE_FILENAME]; - runScriptlet(name, scriptletArgs); - - window.elem = createTagWithSetAttr(assert, IMG_TARGET_NODE, SOURCE_PATH); - assert.strictEqual(window.elem.src, srcMockData[IMG_TARGET_NODE], 'src was mocked'); - assert.strictEqual(window.hit, 'FIRED', 'hit fired'); - }); - - test('setAttribute, matching iframe element', (assert) => { - const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_IFRAME_FILENAME}`; - const scriptletArgs = [IFRAME_TARGET_NODE, 'empty.html']; - runScriptlet(name, scriptletArgs); - - window.elem = createTagWithSetAttr(assert, IFRAME_TARGET_NODE, SOURCE_PATH); - assert.ok( - window.elem.src === srcMockData[IFRAME_TARGET_NODE] - // there is no space in Firefox 52 - || window.elem.src === srcMockData[IFRAME_TARGET_NODE].split(' ').join(''), - 'src was mocked', - ); - assert.strictEqual(window.hit, 'FIRED', 'hit fired'); - }); - - test('src prop, matching script element', (assert) => { - const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT01_FILENAME}`; - const scriptletArgs = [SCRIPT_TARGET_NODE, TEST_SCRIPT01_FILENAME]; - runScriptlet(name, scriptletArgs); - - var elem = createTagWithSrcProp(assert, SCRIPT_TARGET_NODE, SOURCE_PATH); - assert.strictEqual(elem.src, srcMockData[SCRIPT_TARGET_NODE], 'src was mocked'); - assert.strictEqual(window.hit, 'FIRED', 'hit fired'); - }); - - test('src prop, matching image element', (assert) => { - const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_IMAGE_FILENAME}`; - const scriptletArgs = [IMG_TARGET_NODE, TEST_IMAGE_FILENAME]; - runScriptlet(name, scriptletArgs); - - window.elem = createTagWithSrcProp(assert, IMG_TARGET_NODE, SOURCE_PATH); - assert.strictEqual(window.elem.src, srcMockData[IMG_TARGET_NODE], 'src was mocked'); - assert.strictEqual(window.hit, 'FIRED', 'hit fired'); - }); - - test('src prop, matching iframe element', (assert) => { - const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_IFRAME_FILENAME}`; - const scriptletArgs = [IFRAME_TARGET_NODE, 'empty.html']; - runScriptlet(name, scriptletArgs); - - window.elem = createTagWithSrcProp(assert, IFRAME_TARGET_NODE, SOURCE_PATH); - assert.ok( - window.elem.src === srcMockData[IFRAME_TARGET_NODE] - // there is no space in Firefox 52 - || window.elem.src === srcMockData[IFRAME_TARGET_NODE].split(' ').join(''), - 'src was mocked', - ); - assert.strictEqual(window.hit, 'FIRED', 'hit fired'); - }); - - test('setAttribute, mismatching element', (assert) => { - const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT02_FILENAME}`; - const scriptletArgs = [SCRIPT_TARGET_NODE, 'not-test-script.js']; - runScriptlet(name, scriptletArgs); - - window.elem = createTagWithSetAttr(assert, SCRIPT_TARGET_NODE, SOURCE_PATH); - assert.ok(window.elem.src.indexOf(TEST_SCRIPT02_FILENAME) !== -1, 'src was NOT mocked'); - assert.strictEqual(window.hit, undefined, 'hit should NOT fire'); - }); - - test('src prop, mismatching element', (assert) => { - const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT02_FILENAME}`; - const scriptletArgs = [SCRIPT_TARGET_NODE, 'not-test-script.js']; - runScriptlet(name, scriptletArgs); - - window.elem = createTagWithSrcProp(assert, SCRIPT_TARGET_NODE, SOURCE_PATH); - assert.ok(window.elem.src.indexOf(TEST_SCRIPT02_FILENAME) !== -1, 'src was NOT mocked'); - assert.strictEqual(window.hit, undefined, 'hit should NOT fire'); - }); - - test('setAttribute, falsy arguments', (assert) => { - const scriptletArgs = [SCRIPT_TARGET_NODE, 'test-string']; - runScriptlet(name, scriptletArgs); - - const node = document.createElement(SCRIPT_TARGET_NODE); - node.setAttribute(null, undefined); - document.body.append(node); - assert.strictEqual(node.getAttribute('null'), 'undefined', 'falsy attr value passed'); - node.remove(); - - const node2 = document.createElement(SCRIPT_TARGET_NODE); - node2.setAttribute(null, 0); - document.body.append(node2); - assert.strictEqual(node2.getAttribute('null'), '0', 'falsy attr value passed'); - node2.remove(); - - assert.strictEqual(window.hit, undefined, 'hit should NOT fire'); - }); -} +test('setAttribute, matching script element', (assert) => { + const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT01_FILENAME}`; + const scriptletArgs = [SCRIPT_TARGET_NODE, TEST_SCRIPT01_FILENAME]; + runScriptlet(name, scriptletArgs); + + var elem = createTestTag( + assert, + SCRIPT_TARGET_NODE, + SOURCE_PATH, + SET_SRC_ATTRIBUTE, ONERROR_PROP, + ); + assert.strictEqual(elem.src, srcMockData[SCRIPT_TARGET_NODE], 'src was mocked'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('setAttribute, matching image element', (assert) => { + const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_IMAGE_FILENAME}`; + const scriptletArgs = [IMG_TARGET_NODE, TEST_IMAGE_FILENAME]; + runScriptlet(name, scriptletArgs); + + window.elem = createTestTag( + assert, + IMG_TARGET_NODE, + SOURCE_PATH, + SET_SRC_ATTRIBUTE, + ONERROR_PROP, + ); + assert.strictEqual(window.elem.src, srcMockData[IMG_TARGET_NODE], 'src was mocked'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('setAttribute, matching iframe element', (assert) => { + const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_IFRAME_FILENAME}`; + const scriptletArgs = [IFRAME_TARGET_NODE, 'empty.html']; + runScriptlet(name, scriptletArgs); + + window.elem = createTestTag( + assert, + IFRAME_TARGET_NODE, + SOURCE_PATH, + SET_SRC_ATTRIBUTE, + ONERROR_PROP, + ); + assert.ok( + window.elem.src === srcMockData[IFRAME_TARGET_NODE] + // there is no space in Firefox 52 + || window.elem.src === srcMockData[IFRAME_TARGET_NODE].split(' ').join(''), + 'src was mocked', + ); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('src prop, matching script element', (assert) => { + const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT01_FILENAME}`; + const scriptletArgs = [SCRIPT_TARGET_NODE, TEST_SCRIPT01_FILENAME]; + runScriptlet(name, scriptletArgs); + + var elem = createTestTag( + assert, + SCRIPT_TARGET_NODE, + SOURCE_PATH, + SET_SRC_PROP, + ONERROR_PROP, + ); + assert.strictEqual(elem.src, srcMockData[SCRIPT_TARGET_NODE], 'src was mocked'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('src prop, matching image element', (assert) => { + const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_IMAGE_FILENAME}`; + const scriptletArgs = [IMG_TARGET_NODE, TEST_IMAGE_FILENAME]; + runScriptlet(name, scriptletArgs); + + window.elem = createTestTag( + assert, + IMG_TARGET_NODE, + SOURCE_PATH, + SET_SRC_PROP, + ONERROR_PROP, + ); + assert.strictEqual(window.elem.src, srcMockData[IMG_TARGET_NODE], 'src was mocked'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('src prop, matching iframe element', (assert) => { + const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_IFRAME_FILENAME}`; + const scriptletArgs = [IFRAME_TARGET_NODE, 'empty.html']; + runScriptlet(name, scriptletArgs); + + window.elem = createTestTag( + assert, + IFRAME_TARGET_NODE, + SOURCE_PATH, + SET_SRC_PROP, + ONERROR_PROP, + ); + assert.ok( + window.elem.src === srcMockData[IFRAME_TARGET_NODE] + // there is no space in Firefox 52 + || window.elem.src === srcMockData[IFRAME_TARGET_NODE].split(' ').join(''), + 'src was mocked', + ); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('setAttribute, mismatching element', (assert) => { + const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT02_FILENAME}`; + const scriptletArgs = [SCRIPT_TARGET_NODE, 'not-test-script.js']; + runScriptlet(name, scriptletArgs); + + window.elem = createTestTag( + assert, + SCRIPT_TARGET_NODE, + SOURCE_PATH, + SET_SRC_ATTRIBUTE, + ONERROR_PROP, + ); + assert.ok(window.elem.src.indexOf(TEST_SCRIPT02_FILENAME) !== -1, 'src was NOT mocked'); + assert.strictEqual(window.hit, undefined, 'hit should NOT fire'); +}); + +test('src prop, mismatching element', (assert) => { + const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT02_FILENAME}`; + const scriptletArgs = [SCRIPT_TARGET_NODE, 'not-test-script.js']; + runScriptlet(name, scriptletArgs); + + window.elem = createTestTag( + assert, + SCRIPT_TARGET_NODE, + SOURCE_PATH, + SET_SRC_PROP, + ONERROR_PROP, + ); + assert.ok(window.elem.src.indexOf(TEST_SCRIPT02_FILENAME) !== -1, 'src was NOT mocked'); + assert.strictEqual(window.hit, undefined, 'hit should NOT fire'); +}); + +test('setAttribute, falsy arguments', (assert) => { + const scriptletArgs = [SCRIPT_TARGET_NODE, 'test-string']; + runScriptlet(name, scriptletArgs); + + const node = document.createElement(SCRIPT_TARGET_NODE); + node.setAttribute(null, undefined); + document.body.appendChild(node); + assert.strictEqual(node.getAttribute('null'), 'undefined', 'falsy attr value passed'); + node.remove(); + + const node2 = document.createElement(SCRIPT_TARGET_NODE); + node2.setAttribute(null, 0); + document.body.appendChild(node2); + assert.strictEqual(node2.getAttribute('null'), '0', 'falsy attr value passed'); + node2.remove(); + + assert.strictEqual(window.hit, undefined, 'hit should NOT fire'); +}); + +test('setAttribute, prevent error trigger, srcProp & onerrorProp', (assert) => { + const BAD_URL = 'bad_url'; + const scriptletArgs = [SCRIPT_TARGET_NODE, BAD_URL]; + runScriptlet(name, scriptletArgs); + + var elem = createTestTag(assert, SCRIPT_TARGET_NODE, BAD_URL, SET_SRC_PROP, ONERROR_PROP); + elem.onerror(); + + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('setAttribute, prevent error trigger, setSrcAttribute & onerrorProp', (assert) => { + const BAD_URL = 'bad_url'; + const scriptletArgs = [SCRIPT_TARGET_NODE, BAD_URL]; + runScriptlet(name, scriptletArgs); + + var elem = createTestTag( + assert, + SCRIPT_TARGET_NODE, + BAD_URL, + SET_SRC_ATTRIBUTE, + ONERROR_PROP, + ); + elem.onerror(); + + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('setAttribute, prevent error trigger, srcProp & addErrorListener', (assert) => { + const BAD_URL = 'bad_url'; + const scriptletArgs = [SCRIPT_TARGET_NODE, BAD_URL]; + runScriptlet(name, scriptletArgs); + + var elem = createTestTag(assert, SCRIPT_TARGET_NODE, BAD_URL, SET_SRC_PROP, ERROR_LISTENER); + dispatchEvent.call(elem, new Event('error')); + + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + +test('setAttribute, prevent error trigger, setSrcAttribute & addErrorListener', (assert) => { + const BAD_URL = 'bad_url'; + const scriptletArgs = [SCRIPT_TARGET_NODE, BAD_URL]; + runScriptlet(name, scriptletArgs); + + var elem = createTestTag( + assert, + SCRIPT_TARGET_NODE, + BAD_URL, + SET_SRC_ATTRIBUTE, + ERROR_LISTENER, + ); + dispatchEvent.call(elem, new Event('error')); + + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +});