Skip to content

Commit

Permalink
Improve prevent-element-src-loading
Browse files Browse the repository at this point in the history
Add ability to prevent link tag
Add ability to prevent inline onerror
  • Loading branch information
AdamWr committed May 10, 2023
1 parent e60fdd1 commit 3e9f850
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 5 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- ability for `prevent-element-src-loading` scriptlet to prevent inline `onerror` [#276](https://github.com/AdguardTeam/Scriptlets/issues/276)
- ability for `prevent-element-src-loading` scriptlet to prevent `link` tag [#276](https://github.com/AdguardTeam/Scriptlets/issues/276)

### 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)
Expand Down
28 changes: 27 additions & 1 deletion src/scriptlets/prevent-element-src-loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
* - `script`
* - `img`
* - `iframe`
* - `link`
* - `match` — required, string or regular expression for matching the element's URL;
*
* **Examples**
Expand All @@ -42,6 +43,8 @@ export function preventElementSrcLoading(source, tagName, match) {
img: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
// Empty h1 tag
iframe: 'data:text/html;base64, PGRpdj48L2Rpdj4=',
// Empty data
link: 'data:text/plain;base64,',
};

let instance;
Expand All @@ -51,6 +54,8 @@ export function preventElementSrcLoading(source, tagName, match) {
instance = HTMLImageElement;
} else if (tagName === 'iframe') {
instance = HTMLIFrameElement;
} else if (tagName === 'link') {
instance = HTMLLinkElement;
} else {
return;
}
Expand All @@ -71,7 +76,7 @@ export function preventElementSrcLoading(source, tagName, match) {
});
}

const SOURCE_PROPERTY_NAME = 'src';
const SOURCE_PROPERTY_NAME = tagName === 'link' ? 'href' : 'src';
const ONERROR_PROPERTY_NAME = 'onerror';
const searchRegexp = toRegExp(match);

Expand Down Expand Up @@ -191,6 +196,27 @@ export function preventElementSrcLoading(source, tagName, match) {
};
// eslint-disable-next-line max-len
EventTarget.prototype.addEventListener = new Proxy(EventTarget.prototype.addEventListener, addEventListenerHandler);

const preventInlineOnerror = (tagName, src) => {
window.addEventListener('error', (event) => {
if (
!event.target
|| !event.target.nodeName
|| event.target.nodeName.toLowerCase() !== tagName
|| !event.target.src
|| !src.test(event.target.src)
) {
return;
}
hit(source);
if (typeof event.target.onload === 'function') {
event.target.onerror = event.target.onload;
return;
}
event.target.onerror = noopFunc;
}, true);
};
preventInlineOnerror(tagName, searchRegexp);
}

preventElementSrcLoading.names = [
Expand Down
124 changes: 120 additions & 4 deletions tests/scriptlets/prevent-element-src-loading.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ const afterEach = () => {
if (window.elem) {
window.elem.remove();
}
clearGlobalProps('hit', '__debug', 'elem');
clearGlobalProps('hit', '__debug', 'elem', 'scriptLoaded', 'scriptBlocked');
};

const SET_SRC_ATTRIBUTE = 'setSrcAttribute';
const SET_SRC_PROP = 'srcProp';
const SET_LINK_HREF_ATTRIBUTE = 'linkHrefAttribute';
const SET_LINK_HREF_PROP = 'linkHrefProp';
const ONERROR_PROP = 'onerrorProp';
const ERROR_LISTENER = 'addErrorListener';

Expand All @@ -37,6 +39,18 @@ const createTestTag = (assert, nodeName, url, srcMethod, onerrorMethod) => {
node.setAttribute('src', url);
break;
}
case SET_LINK_HREF_PROP: {
node.href = url;
node.rel = 'preload';
node.as = 'script';
break;
}
case SET_LINK_HREF_ATTRIBUTE: {
node.setAttribute('href', url);
node.setAttribute('rel', 'preload');
node.setAttribute('as', 'script');
break;
}
default:
// do nothing
}
Expand Down Expand Up @@ -65,10 +79,42 @@ const createTestTag = (assert, nodeName, url, srcMethod, onerrorMethod) => {
return node;
};

const onErrorTestTag = (assert, url, testPassed, shouldLoad) => {
const done = assert.async();
// Used in onload event
window.scriptLoaded = () => {
if (shouldLoad) {
testPassed = true;
assert.strictEqual(testPassed, true, 'onload event fired');
assert.strictEqual(window.hit, 'FIRED', 'hit fired');
} else {
assert.strictEqual(window.hit, undefined, 'hit should NOT fire');
}
done();
};
// Used in onerror event
window.scriptBlocked = () => {
if (shouldLoad) {
assert.strictEqual(testPassed, true, 'onload event fired');
assert.strictEqual(window.hit, 'FIRED', 'hit fired');
} else {
assert.strictEqual(window.hit, undefined, 'hit should NOT fire');
}
done();
};
// It's necessary to use the slash like this,
// otherwise the tests will be displayed incorrectly after the build
const slash = '/';
const html = `<script src="${url}" onload="scriptLoaded()" onerror="scriptBlocked()"><${slash}script>`;
const scriptEl = document.createRange().createContextualFragment(html);
document.body.appendChild(scriptEl);
};

const srcMockData = {
script: 'data:text/javascript;base64,KCk9Pnt9',
img: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
iframe: 'data:text/html;base64, PGRpdj48L2Rpdj4=',
link: 'data:text/plain;base64,',
};
const TEST_FILES_DIR = './test-files/';
const TEST_SCRIPT01_FILENAME = 'test-script01.js';
Expand All @@ -78,6 +124,7 @@ const TEST_IFRAME_FILENAME = 'empty.html';
const SCRIPT_TARGET_NODE = 'script';
const IMG_TARGET_NODE = 'img';
const IFRAME_TARGET_NODE = 'iframe';
const LINK_TARGET_NODE = 'link';

module(name, { beforeEach, afterEach });

Expand All @@ -90,7 +137,8 @@ test('setAttribute, matching script element', (assert) => {
assert,
SCRIPT_TARGET_NODE,
SOURCE_PATH,
SET_SRC_ATTRIBUTE, ONERROR_PROP,
SET_SRC_ATTRIBUTE,
ONERROR_PROP,
);
assert.strictEqual(elem.src, srcMockData[SCRIPT_TARGET_NODE], 'src was mocked');
assert.strictEqual(window.hit, 'FIRED', 'hit fired');
Expand Down Expand Up @@ -133,6 +181,22 @@ test('setAttribute, matching iframe element', (assert) => {
assert.strictEqual(window.hit, 'FIRED', 'hit fired');
});

test('setAttribute, matching link element', (assert) => {
const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT01_FILENAME}`;
const scriptletArgs = [LINK_TARGET_NODE, TEST_SCRIPT01_FILENAME];
runScriptlet(name, scriptletArgs);

var elem = createTestTag(
assert,
LINK_TARGET_NODE,
SOURCE_PATH,
SET_LINK_HREF_ATTRIBUTE,
ONERROR_PROP,
);
assert.strictEqual(elem.href, srcMockData[LINK_TARGET_NODE], '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];
Expand Down Expand Up @@ -186,6 +250,22 @@ test('src prop, matching iframe element', (assert) => {
assert.strictEqual(window.hit, 'FIRED', 'hit fired');
});

test('src prop, matching link element', (assert) => {
const SOURCE_PATH = `${TEST_FILES_DIR}${TEST_SCRIPT01_FILENAME}`;
const scriptletArgs = [LINK_TARGET_NODE, TEST_SCRIPT01_FILENAME];
runScriptlet(name, scriptletArgs);

var elem = createTestTag(
assert,
LINK_TARGET_NODE,
SOURCE_PATH,
SET_LINK_HREF_PROP,
ONERROR_PROP,
);
assert.strictEqual(elem.href, srcMockData[LINK_TARGET_NODE], 'href 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'];
Expand Down Expand Up @@ -302,12 +382,48 @@ test('setAttribute, matching script element, test addEventListener', (assert) =>
assert,
SCRIPT_TARGET_NODE,
SOURCE_PATH,
SET_SRC_ATTRIBUTE, ONERROR_PROP,
SET_SRC_ATTRIBUTE,
ONERROR_PROP,
);
// It's intentionally used without a specific target (like window/document) before addEventListener
// because in such case, thisArg in the addEventListenerWrapper is undefined
// https://github.com/AdguardTeam/Scriptlets/issues/270
addEventListener('visibilitychange', () => {});
addEventListener('visibilitychange', () => { });
assert.strictEqual(elem.src, srcMockData[SCRIPT_TARGET_NODE], 'src was mocked');
assert.strictEqual(window.hit, 'FIRED', 'hit fired');
});

test('onerror inline, matching node and source', (assert) => {
const SOURCE_PATH = '/adscript1.js';
const scriptletArgs = [SCRIPT_TARGET_NODE, SOURCE_PATH];
var testPassed = false;
var shouldLoad = true;
runScriptlet(name, scriptletArgs);

// onload event should be fired
onErrorTestTag(assert, SOURCE_PATH, testPassed, shouldLoad);
});

test('onerror inline, do not match node', (assert) => {
const SOURCE_PATH = '/adscript2.js';
const scriptletArgs = [LINK_TARGET_NODE, SOURCE_PATH];
var testPassed = false;
var shouldLoad = false;
runScriptlet(name, scriptletArgs);

// onerror event should be fired
// and window.hit should not be fired
onErrorTestTag(assert, SOURCE_PATH, testPassed, shouldLoad);
});

test('onerror inline, do not match source', (assert) => {
const SOURCE_PATH = '/not-existing-script.js';
const scriptletArgs = [SCRIPT_TARGET_NODE, 'test.js'];
var testPassed = false;
var shouldLoad = false;
runScriptlet(name, scriptletArgs);

// onerror event should be fired
// and window.hit should not be fired
onErrorTestTag(assert, SOURCE_PATH, testPassed, shouldLoad);
});

0 comments on commit 3e9f850

Please sign in to comment.