Skip to content

Commit

Permalink
AG-18125 add inject-css-in-shadow-dom scriplet #267
Browse files Browse the repository at this point in the history
Merge in ADGUARD-FILTERS/scriptlets from feature/AG-18125 to release/v1.8

Squashed commit of the following:

commit 0e8aa2f
Merge: 25eaaca d6d4662
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 21:14:26 2023 +0300

    Merge branch 'release/v1.8' into feature/AG-18125

commit 25eaaca
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 19:20:20 2023 +0300

    fix description

commit 0b78ff2
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 19:19:14 2023 +0300

    fix test and improve hostSelector arg description

commit 5443317
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 17:47:49 2023 +0300

    improve logging invalid rule and fix  description

commit 9191fc8
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 17:09:27 2023 +0300

    gaurd CSSStyleSheet constructor

commit 65f1b08
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 16:44:06 2023 +0300

    guard Proxy and Reflect

commit 3d351b6
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 16:08:40 2023 +0300

    add image-set to disallowed css functions

commit b283b30
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 15:14:18 2023 +0300

    update changelog

commit 95ec59b
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 14:47:59 2023 +0300

    add testcase for complex layout and open root

commit ca7b9d0
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Wed Jan 18 14:09:43 2023 +0300

    disallow url func in styles and add testcases

commit ccf8928
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Tue Jan 17 20:43:10 2023 +0300

    add hostSelector, limit selectors to 1 rule and 1 host selectors, add testcase

commit ed308a9
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Tue Jan 17 16:02:14 2023 +0300

    add css injection via style tag and add testcase

commit f1a6ff8
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Tue Jan 17 04:50:56 2023 +0300

    remove legacy leftovers

commit c8808ba
Author: Stanislav A <s.atroschenko@adguard.com>
Date:   Tue Jan 17 04:45:18 2023 +0300

    add inject-css-in-shadow-dom scriplet
  • Loading branch information
stanislav-atr committed Jan 18, 2023
1 parent d6d4662 commit 35e0e5a
Show file tree
Hide file tree
Showing 7 changed files with 330 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added
- 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)

Expand Down
1 change: 1 addition & 0 deletions src/helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export * from './parse-flags';
export * from './parse-keyword-value';
export * from './random-id';
export * from './throttle';
export * from './shadow-dom-utils';
34 changes: 34 additions & 0 deletions src/helpers/shadow-dom-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Makes arbitrary operations on shadow root element,
* to be passed as callback to hijackAttachShadow
*
* @callback attachShadowCallback
* @param {HTMLElement} shadowRoot
* @returns {void}
*/

/**
* Overrides attachShadow method of Element API on a given context
* to pass retrieved shadowRoots to callback
*
* @param {Object} context e.g global window object or contentWindow of an iframe
* @param {string} hostSelector selector to determine if callback should be called on current shadow subtree
* @param {attachShadowCallback} callback callback to call on shadow root
*/
export const hijackAttachShadow = (context, hostSelector, callback) => {
const handlerWrapper = (target, thisArg, args) => {
const shadowRoot = Reflect.apply(target, thisArg, args);

if (thisArg && thisArg.matches(hostSelector || '*')) {
callback(shadowRoot);
}

return shadowRoot;
};

const attachShadowHandler = {
apply: handlerWrapper,
};

context.Element.prototype.attachShadow = new Proxy(context.Element.prototype.attachShadow, attachShadowHandler);
};
81 changes: 81 additions & 0 deletions src/scriptlets/inject-css-in-shadow-dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
hit,
logMessage,
hijackAttachShadow,
} from '../helpers/index';

/* eslint-disable max-len */
/**
* @scriptlet inject-css-in-shadow-dom
* @description
* Injects CSS rule into selected Shadow DOM subtrees on a page
*
* **Syntax**
* ```
* example.org#%#//scriptlet('inject-css-in-shadow-dom', cssRule[, hostSelector])
* ```
*
* - `cssRule` - required, string representing a single css rule
* - `hostSelector` - optional, string, selector to match shadow host elements. CSS rule will be only applied to shadow roots inside these elements.
* Defaults to injecting css rule into all available roots.
*
* **Examples**
* 1. Apply style to all shadow dom subtrees
* ```
* example.org#%#//scriptlet('inject-css-in-shadow-dom', '#advertisement { display: none !important; }')
* ```
*
* 2. Apply style to a specific shadow dom subtree
* ```
* example.org#%#//scriptlet('inject-css-in-shadow-dom', '#content { margin-top: 0 !important; }', '.row > #hidden')
* ```
*/
/* eslint-enable max-len */

export function injectCssInShadowDom(source, cssRule, hostSelector = '') {
// do nothing if browser does not support ShadowRoot, Proxy or Reflect
// https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot
if (!Element.prototype.attachShadow || typeof Proxy === 'undefined' || typeof Reflect === 'undefined') {
return;
}

// Prevent url() and image-set() styles from being applied
if (cssRule.match(/(url|image-set)\(.*\)/i)) {
logMessage(source, '"url()" function is not allowed for css rules');
return;
}

const callback = (shadowRoot) => {
try {
// adoptedStyleSheets and CSSStyleSheet constructor are not yet supported by Safari
// https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
// https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet
const stylesheet = new CSSStyleSheet();
try {
stylesheet.insertRule(cssRule);
} catch (e) {
logMessage(source, `Unable to apply the rule '${cssRule}' due to: \n'${e.message}'`);
return;
}
shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, stylesheet];
} catch {
const styleTag = document.createElement('style');
styleTag.innerText = cssRule;
shadowRoot.appendChild(styleTag);
}

hit(source);
};

hijackAttachShadow(window, hostSelector, callback);
}

injectCssInShadowDom.names = [
'inject-css-in-shadow-dom',
];

injectCssInShadowDom.injections = [
hit,
logMessage,
hijackAttachShadow,
];
1 change: 1 addition & 0 deletions src/scriptlets/scriptlets-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ export * from './trusted-set-cookie-reload';
export * from './trusted-replace-fetch-response';
export * from './trusted-set-local-storage-item';
export * from './trusted-set-constant';
export * from './inject-css-in-shadow-dom';
1 change: 1 addition & 0 deletions tests/scriptlets/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ import './trusted-set-cookie.test';
import './trusted-replace-fetch-response.test';
import './trusted-set-local-storage-item.test';
import './trusted-set-constant.test';
import './inject-css-in-shadow-dom.test';
211 changes: 211 additions & 0 deletions tests/scriptlets/inject-css-in-shadow-dom.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/* eslint-disable no-underscore-dangle, no-console */
import { runScriptlet, clearGlobalProps } from '../helpers';

const { test, module } = QUnit;
const name = 'inject-css-in-shadow-dom';

const nativeAttachShadow = window.Element.prototype.attachShadow;
const nativeConsole = console.log;

const TARGET_ID1 = 'target1';
const CSS_TEXT1 = `#${TARGET_ID1} { color: rgb(255, 0, 0) !important }`;
const CSS_TEXT2 = `#${TARGET_ID1} { background: lightblue url("https://www.w3schools.com/cssref/img_tree.gif"); } !important }`;
const CSS_TEXT3 = `#${TARGET_ID1} { background:image-set("https://www.w3schools.com/cssref/img_tree.gif") !important }`;
const INVALID_CSS_TEXT = `#${TARGET_ID1} { color: rgb(255, 0, 0) } !important`;
const HOST_ID1 = 'host1';
const HOST_ID2 = 'host2';

const appendTarget = (parent, id) => {
const target = document.createElement('div');
target.id = id;
target.innerText = id;
return parent.appendChild(target);
};

const appendHost = (id) => {
const host = document.createElement('div');
host.id = id;
return document.body.appendChild(host);
};

const removeHosts = () => {
const hostIds = [HOST_ID1, HOST_ID2];
hostIds.forEach((id) => {
const host = document.getElementById(id);
if (host) {
host.remove();
}
});
};

const beforeEach = () => {
window.__debug = () => {
window.hit = 'FIRED';
};
};

const afterEach = () => {
clearGlobalProps('hit', '__debug');
removeHosts();
window.Element.prototype.attachShadow = nativeAttachShadow;
console.log = nativeConsole;
};

module(name, { beforeEach, afterEach });

// some browsers do not support ShadowRoot
// for example, Firefox 52 which is used for browserstack tests
// https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot
const isSupported = typeof Element.prototype.attachShadow !== 'undefined';

if (!isSupported) {
test('unsupported', (assert) => {
assert.ok(true, 'Browser does not support it');
});
} else {
test('apply style to all shadow dom subtrees', (assert) => {
runScriptlet(name, [CSS_TEXT1]);

const host1 = appendHost(HOST_ID1);
const shadowRoot1 = host1.attachShadow({ mode: 'closed' });
appendTarget(shadowRoot1, TARGET_ID1);

const host2 = appendHost(HOST_ID2);
const shadowRoot2 = host2.attachShadow({ mode: 'closed' });
appendTarget(shadowRoot2, TARGET_ID1);

// First shadow root, style applied
const target1 = shadowRoot1.getElementById(TARGET_ID1);
assert.strictEqual(getComputedStyle(target1).color, 'rgb(255, 0, 0)', 'style was applied to shadowRoot #1');
// Second shadow root, style applied
const target2 = shadowRoot2.getElementById(TARGET_ID1);
assert.strictEqual(getComputedStyle(target2).color, 'rgb(255, 0, 0)', 'style was applied to shadowRoot #2');

assert.strictEqual(window.hit, 'FIRED', 'hit function was executed');
});

test('apply style to specific shadow dom subtree', (assert) => {
runScriptlet(name, [CSS_TEXT1, `#${HOST_ID1}`]);

const host1 = appendHost(HOST_ID1);
const shadowRoot1 = host1.attachShadow({ mode: 'closed' });
appendTarget(shadowRoot1, TARGET_ID1);

const host2 = appendHost(HOST_ID2);
const shadowRoot2 = host2.attachShadow({ mode: 'closed' });
appendTarget(shadowRoot2, TARGET_ID1);

// First shadow root, style applied
const target1 = shadowRoot1.getElementById(TARGET_ID1);
assert.strictEqual(getComputedStyle(target1).color, 'rgb(255, 0, 0)', 'style was applied to shadowRoot #1');
// Second shadow root, style should no be applied
const target2 = shadowRoot2.getElementById(TARGET_ID1);
assert.strictEqual(getComputedStyle(target2).color, 'rgb(0, 0, 0)', 'style was not applied to shadowRoot #2');

assert.strictEqual(window.hit, 'FIRED', 'hit function was executed');
});

test('do not apply style with url function, logged correctly', (assert) => {
assert.expect(3);
console.log = function log(input) {
if (input.indexOf('trace') > -1) {
return;
}
assert.strictEqual(
input,
`${name}: "url()" function is not allowed for css rules`,
'message logged correctly',
);
};

runScriptlet(name, [CSS_TEXT2]);

const host1 = appendHost(HOST_ID1);
const shadowRoot1 = host1.attachShadow({ mode: 'closed' });
appendTarget(shadowRoot1, TARGET_ID1);

// style with url() function should not be applied
const target1 = shadowRoot1.getElementById(TARGET_ID1);
const target1Background = getComputedStyle(target1).background;

assert.ok(!target1Background.match(/url\(.*\)/i), 'url() style was not applied to shadowRoot #1');
assert.strictEqual(window.hit, undefined, 'hit should NOT fire');
});

test('do not apply style with image-set function, logged correctly', (assert) => {
assert.expect(3);
console.log = function log(input) {
if (input.indexOf('trace') > -1) {
return;
}
assert.strictEqual(
input,
`${name}: "url()" function is not allowed for css rules`,
'message logged correctly',
);
};

runScriptlet(name, [CSS_TEXT3]);

const host1 = appendHost(HOST_ID1);
const shadowRoot1 = host1.attachShadow({ mode: 'closed' });
appendTarget(shadowRoot1, TARGET_ID1);

// style with url() function should not be applied
const target1 = shadowRoot1.getElementById(TARGET_ID1);
const target1Background = getComputedStyle(target1).background;

assert.ok(!target1Background.match(/image-set\(.*\)/i), 'image-set() style was not applied to shadowRoot #1');
assert.strictEqual(window.hit, undefined, 'hit should NOT fire');
});

test('do not apply invalid style, logged correctly', (assert) => {
assert.expect(3);
console.log = function log(input) {
if (typeof input !== 'string' || input.indexOf('trace') > -1) {
return;
}

const logMessage = `${name}: Unable to apply the rule '${INVALID_CSS_TEXT}' due to:`;
assert.ok(input.startsWith(logMessage), 'message logged correctly');
};

runScriptlet(name, [INVALID_CSS_TEXT]);

const host1 = appendHost(HOST_ID1);
const shadowRoot1 = host1.attachShadow({ mode: 'closed' });
appendTarget(shadowRoot1, TARGET_ID1);

// style with url() function should not be applied
const target1 = shadowRoot1.getElementById(TARGET_ID1);
const target1Color = getComputedStyle(target1).color;

assert.strictEqual(target1Color, 'rgb(0, 0, 0)', 'style was not applied to shadowRoot #1');
assert.strictEqual(window.hit, undefined, 'hit should NOT fire');
});

test('test complex layouts', (assert) => {
// <body>
// <div#host1>
// | #shadow-root (closed)
// | | <div#shadowInner>
// | | | #shadow-root (open)
// | | | | <p#target1></p>
// | | </div>
// </div>
// </body>
runScriptlet(name, [CSS_TEXT1]);

const host1 = appendHost(HOST_ID1);
const shadowRoot1 = host1.attachShadow({ mode: 'closed' });

const shadowInner = document.createElement('div');
shadowRoot1.append(shadowInner);
const shadowRoot2 = shadowInner.attachShadow({ mode: 'open' });
appendTarget(shadowRoot2, TARGET_ID1);

const target1 = shadowRoot2.getElementById(TARGET_ID1);
assert.strictEqual(getComputedStyle(target1).color, 'rgb(255, 0, 0)', 'style was applied to shadowRoot #1');
assert.strictEqual(window.hit, 'FIRED', 'hit function was executed');
});
}

0 comments on commit 35e0e5a

Please sign in to comment.