-
Notifications
You must be signed in to change notification settings - Fork 30
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
AG-18125 add inject-css-in-shadow-dom scriplet #267
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
1 parent
d6d4662
commit 35e0e5a
Showing
7 changed files
with
330 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
} |