Revision 96efcc33
Added by koszko over 1 year ago
content/policy_enforcing.js | ||
---|---|---|
45 | 45 |
|
46 | 46 |
#FROM common/misc.js IMPORT gen_nonce, csp_header_regex |
47 | 47 |
|
48 |
const html_ns = "http://www.w3.org/1999/xhtml"; |
|
49 |
const svg_ns = "http://www.w3.org/2000/svg"; |
|
50 |
|
|
48 | 51 |
document.content_loaded = document.readyState === "complete"; |
49 | 52 |
const wait_loaded = e => e.content_loaded ? Promise.resolve() : |
50 | 53 |
new Promise(c => e.addEventListener("DOMContentLoaded", c, {once: true})); |
... | ... | |
203 | 206 |
*/ |
204 | 207 |
if (some_attr_blocked) { |
205 | 208 |
const replacement_elem = document.createElement("a"); |
209 |
|
|
210 |
/* Prevent this node from being processed by our observer. */ |
|
211 |
replacement_elem.haketilo_trusted_node = true; |
|
212 |
|
|
206 | 213 |
element.replaceWith(replacement_elem); |
207 | 214 |
replacement_elem.replaceWith(element); |
208 | 215 |
} |
... | ... | |
221 | 228 |
element.haketilo_sanitized_onevent = true; |
222 | 229 |
|
223 | 230 |
for (const attribute_node of [...(element.attributes || [])]) { |
224 |
const attr = attribute_node.localName, attr_lo = attr.toLowerCase();;
|
|
225 |
if (!/^on/.test(attr_lo) || !(attr_lo in element.wrappedJSObject))
|
|
231 |
const attr = attribute_node.localName, attr_lo = attr.toLowerCase(); |
|
232 |
if (!/^on/.test(attr_lo) || !(attr_lo in element)) |
|
226 | 233 |
continue; |
227 | 234 |
|
228 | 235 |
/* |
... | ... | |
246 | 253 |
} |
247 | 254 |
#ENDIF |
248 | 255 |
|
249 |
function start_mo_sanitizing(doc) { |
|
250 |
if (!doc.content_loaded) { |
|
251 |
function mutation_handler(mutation) { |
|
252 |
mutation.addedNodes.forEach(sanitize_element_urls); |
|
256 |
/* |
|
257 |
* Sanitize elements on-the-fly as they appear using MutationObserver. |
|
258 |
* |
|
259 |
* Under Abrowser 97 it was observed that MutationObserver does not always work |
|
260 |
* as is should. When trying to observe nodes of an XMLDocument the behavior was |
|
261 |
* as if the "subtree" option to MutationObserver.observe() was ignored. To work |
|
262 |
* around this we avoid using the "subtree" option altogether and have the same |
|
263 |
* code work in all scenarios. |
|
264 |
*/ |
|
265 |
function MOSanitizer(root) { |
|
266 |
this.root = root; |
|
267 |
|
|
268 |
this.recursively_sanitize(root); |
|
269 |
|
|
270 |
this.mo = new MutationObserver(ms => this.handle_mutations(ms)); |
|
271 |
} |
|
272 |
|
|
273 |
MOSanitizer.prototype.observe = function() { |
|
274 |
let elem = this.root; |
|
275 |
while (elem && !elem.haketilo_trusted_node) { |
|
276 |
this.mo.observe(elem, {childList: true}); |
|
277 |
elem = elem.lastElementChild; |
|
278 |
} |
|
279 |
} |
|
280 |
|
|
281 |
MOSanitizer.prototype.handle_mutations = function(mutations) { |
|
282 |
for (const mut of mutations) { |
|
283 |
for (const new_node of mut.addedNodes) |
|
284 |
this.recursively_sanitize(new_node); |
|
285 |
} |
|
286 |
|
|
287 |
this.mo.disconnect(); |
|
288 |
this.observe(); |
|
289 |
} |
|
290 |
|
|
291 |
MOSanitizer.prototype.recursively_sanitize = function(elem) { |
|
292 |
const to_process = [elem]; |
|
293 |
|
|
294 |
while (to_process.length > 0) { |
|
295 |
const current_elem = to_process.pop(); |
|
296 |
|
|
297 |
if (current_elem.haketilo_trusted_node || |
|
298 |
current_elem.nodeType !== this.root.ELEMENT_NODE) |
|
299 |
continue; |
|
300 |
|
|
301 |
to_process.push(...current_elem.children); |
|
302 |
|
|
303 |
sanitize_element_urls(current_elem); |
|
253 | 304 |
#IF MOZILLA |
254 |
mutation.addedNodes.forEach(sanitize_element_onevent);
|
|
305 |
sanitize_element_onevent(current_elem);
|
|
255 | 306 |
#ENDIF |
256 |
} |
|
257 |
const mo = new MutationObserver(ms => ms.forEach(mutation_handler)); |
|
258 |
mo.observe(doc, {childList: true, subtree: true}); |
|
259 |
wait_loaded(doc).then(() => mo.disconnect()); |
|
260 | 307 |
} |
261 | 308 |
} |
262 | 309 |
|
310 |
MOSanitizer.prototype.start = function() { |
|
311 |
this.recursively_sanitize(this.root); |
|
312 |
this.observe(); |
|
313 |
} |
|
314 |
|
|
315 |
MOSanitizer.prototype.stop = function() { |
|
316 |
this.mo.disconnect(); |
|
317 |
} |
|
318 |
|
|
263 | 319 |
#IF MOZILLA |
264 | 320 |
/* |
265 | 321 |
* Normally, we block scripts with CSP. However, Mozilla does optimizations that |
... | ... | |
270 | 326 |
* applying this CSP to non-inline `<scripts>' in certain scenarios. |
271 | 327 |
*/ |
272 | 328 |
function prevent_script_execution(event) { |
273 |
if (!event.target.haketilo_payload) |
|
274 |
event.preventDefault(); |
|
329 |
event.preventDefault(); |
|
275 | 330 |
} |
276 | 331 |
#ENDIF |
277 | 332 |
|
... | ... | |
285 | 340 |
* javascript execution. |
286 | 341 |
*/ |
287 | 342 |
async function sanitize_document(doc, policy) { |
343 |
const root = doc.documentElement; |
|
344 |
const substitute_doc = |
|
345 |
new DOMParser().parseFromString("<!DOCTYPE html>", "text/html"); |
|
346 |
|
|
288 | 347 |
#IF MOZILLA |
289 | 348 |
/* |
290 | 349 |
* Blocking of scripts that are in the DOM from the beginning. Needed for |
291 | 350 |
* Mozilla. |
292 | 351 |
*/ |
293 | 352 |
const listener_args = ["beforescriptexecute", prevent_script_execution]; |
353 |
|
|
294 | 354 |
doc.addEventListener(...listener_args); |
355 |
substitute_doc.addEventListener(...listener_args); |
|
356 |
|
|
295 | 357 |
wait_loaded(doc).then(() => doc.removeEventListener(...listener_args)); |
296 | 358 |
|
297 | 359 |
sanitize_tree_urls(doc.documentElement); |
298 | 360 |
sanitize_tree_onevent(doc.documentElement); |
299 | 361 |
#ENDIF |
300 | 362 |
|
363 |
if (!doc.content_loaded) { |
|
364 |
const sanitizer = new MOSanitizer(doc.documentElement); |
|
365 |
sanitizer.start(); |
|
366 |
wait_loaded(doc).then(() => sanitizer.stop()); |
|
367 |
} |
|
368 |
|
|
301 | 369 |
/* |
302 | 370 |
* Ensure our CSP rules are employed from the beginning. This CSP injection |
303 | 371 |
* method is, when possible, going to be applied together with CSP rules |
... | ... | |
322 | 390 |
* Root node gets hijacked now, to be re-attached after <head> is loaded |
323 | 391 |
* and sanitized. |
324 | 392 |
*/ |
325 |
const root = doc.documentElement; |
|
326 | 393 |
root.replaceWith(temporary_html); |
394 |
#IF MOZILLA |
|
395 |
/* |
|
396 |
* To be able to handle the onbeforescriptexecute event for scripts that |
|
397 |
* appear under detached document. |
|
398 |
*/ |
|
399 |
substitute_doc.documentElement.replaceWith(root); |
|
400 |
#ENDIF |
|
327 | 401 |
|
328 | 402 |
/* |
329 | 403 |
* When we don't inject payload, we neither block document's CSP `<meta>' |
... | ... | |
336 | 410 |
.forEach(m => sanitize_meta(m, policy)); |
337 | 411 |
} |
338 | 412 |
|
339 |
sanitize_tree_urls(root); |
|
340 |
root.querySelectorAll("script").forEach(s => sanitize_script(s, policy)); |
|
413 |
const scripts = [...root.getElementsByTagNameNS(html_ns, "script"), |
|
414 |
...root.getElementsByTagNameNS(svg_ns, "svg")]; |
|
415 |
scripts.forEach(s => sanitize_script(s, policy)); |
|
341 | 416 |
temporary_html.replaceWith(root); |
342 |
root.querySelectorAll("script").forEach(s => desanitize_script(s, policy)); |
|
343 |
#IF MOZILLA |
|
344 |
sanitize_tree_onevent(root); |
|
345 |
#ENDIF |
|
346 |
|
|
347 |
start_mo_sanitizing(doc); |
|
417 |
scripts.forEach(s => desanitize_script(s, policy)); |
|
348 | 418 |
} |
349 | 419 |
|
350 | 420 |
async function _disable_service_workers() { |
test/haketilo_test/data/pages/scripts_to_block_1.html | ||
---|---|---|
29 | 29 |
</script> |
30 | 30 |
</head> |
31 | 31 |
<body> |
32 |
<button id="clickme1" |
|
33 |
onclick="window.__run = [...(window.__run || []), 'on'];" |
|
34 |
blocked-onclick="some useful data"> |
|
35 |
Click Meee! |
|
36 |
</button> |
|
37 |
<a id="clickme2" |
|
38 |
href="javascript:window.__run = [...(window.__run || []), 'href'];void(0);"> |
|
39 |
Click Meee! |
|
40 |
</a> |
|
41 |
<iframe src="javascript:void(window.parent.__run = [...(window.parent.__run || []), 'src']);"> |
|
42 |
</iframe> |
|
43 |
<object data="javascript:window.__run = [...(window.__run || []), 'data'];"> |
|
44 |
</object> |
|
32 |
<!-- |
|
33 |
Put all objects under a <div> to make sure the Mutation Observer does |
|
34 |
indeed correctly report changes in subtrees (there are problems with |
|
35 |
this in XML documents). |
|
36 |
--> |
|
37 |
<div> |
|
38 |
<button id="clickme1" |
|
39 |
onclick="window.__run = [...(window.__run || []), 'on'];" |
|
40 |
blocked-onclick="some useful data"> |
|
41 |
Click Meee! |
|
42 |
</button> |
|
43 |
<a id="clickme2" |
|
44 |
href="javascript:window.__run = [...(window.__run || []), 'href'];void(0);"> |
|
45 |
Click Meee! |
|
46 |
</a> |
|
47 |
<iframe src="javascript:void(window.parent.__run = [...(window.parent.__run || []), 'src']);"> |
|
48 |
</iframe> |
|
49 |
<object data="javascript:window.__run = [...(window.__run || []), 'data'];"> |
|
50 |
</object> |
|
51 |
</div> |
|
45 | 52 |
</body> |
46 | 53 |
</html> |
test/haketilo_test/data/pages/scripts_to_block_2.xml | ||
---|---|---|
1 |
<?xml version="1.0" encoding="UTF-8"?> |
|
2 |
<!-- |
|
3 |
SPDX-License-Identifier: CC0-1.0 |
|
4 |
|
|
5 |
A testing XML document with various scripts that need to get blocked. |
|
6 |
|
|
7 |
This file is part of Haketilo. |
|
8 |
|
|
9 |
Copyright (C) 2021, 2022 Wojtek Kosior <koszko@koszko.org> |
|
10 |
|
|
11 |
This program is free software: you can redistribute it and/or modify |
|
12 |
it under the terms of the CC0 1.0 Universal License as published by |
|
13 |
the Creative Commons Corporation. |
|
14 |
|
|
15 |
This program is distributed in the hope that it will be useful, |
|
16 |
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
17 |
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
18 |
CC0 1.0 Universal License for more details. |
|
19 |
--> |
|
20 |
|
|
21 |
<fruits> |
|
22 |
|
|
23 |
<!-- |
|
24 |
The following will not execute since it is not recognized as either HTML |
|
25 |
or SVG script. |
|
26 |
--> |
|
27 |
<script> |
|
28 |
window.__run = [...(window.__run || []), 'banana']; |
|
29 |
</script> |
|
30 |
|
|
31 |
<html:img xmlns:html="http://www.w3.org/1999/xhtml" |
|
32 |
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==" |
|
33 |
onload="window.__run = [...(window.__run || []), 'melon'];console.log('delme melon')"> |
|
34 |
</html:img> |
|
35 |
|
|
36 |
<!-- Will execute --> |
|
37 |
<html:script xmlns:html="http://www.w3.org/1999/xhtml"> |
|
38 |
window.__run = [...(window.__run || []), 'grape']; |
|
39 |
</html:script> |
|
40 |
|
|
41 |
<!-- Will also execute --> |
|
42 |
<vector-graphics:script xmlns:vector-graphics="http://www.w3.org/2000/svg"> |
|
43 |
window.__run = [...(window.__run || []), 'raspberry']; |
|
44 |
</vector-graphics:script> |
|
45 |
|
|
46 |
<apple> |
|
47 |
<svg viewBox="0 0 10 14" xmlns="http://www.w3.org/2000/svg"> |
|
48 |
<!-- Will run when clicked --> |
|
49 |
<circle id="idaret_circle" cx="5" cy="5" r="4" |
|
50 |
onclick="window.__run = [...(window.__run || []), 'idaret'];" /> |
|
51 |
<!-- Will *NOT* run when clicked --> |
|
52 |
<circle id="nowamak_circle" cx="5" cy="13" r="4" |
|
53 |
some-unknown:onclick="window.__run = [...(window.__run || []), 'nowamak'];" |
|
54 |
xmlns:some-unknown="https://example.org/blah/blah" /> |
|
55 |
</svg> |
|
56 |
</apple> |
|
57 |
<!-- |
|
58 |
In case of wrong namespace URI (or lack thereof), svg subtree will not |
|
59 |
be recognized as SVG at all |
|
60 |
--> |
|
61 |
<svg> |
|
62 |
<!-- Will neither run nor be drawn by the browser --> |
|
63 |
<circle id="mango_circle" cx="5" cy="5" r="4" |
|
64 |
onclick="window.__run = [...(window.__run || []), 'mango'];" /> |
|
65 |
</svg> |
|
66 |
<svg viewBox="0 0 10" xmlns="http://www.w3.org/2000/sv"> |
|
67 |
<!-- Will neither run nor be drawn by the browser --> |
|
68 |
<circle id="annoying_circle" cx="5" cy="5" r="4" |
|
69 |
onclick="window.__run = [...(window.__run || []), 'orange'];" /> |
|
70 |
</svg> |
|
71 |
</fruits> |
test/haketilo_test/unit/test_policy_enforcing.py | ||
---|---|---|
73 | 73 |
@pytest.mark.parametrize('csp_off_setting', [{}, {'csp_off': True}]) |
74 | 74 |
def test_policy_enforcing_html(driver, execute_in_page, csp_off_setting): |
75 | 75 |
""" |
76 |
A test case of sanitizing <script>s and intrinsic javascript in pages.
|
|
76 |
A test case of sanitizing <script>s and intrinsic JavaScript in HTML pages.
|
|
77 | 77 |
""" |
78 |
def assert_properly_blocked():
|
|
78 |
def click_all():
|
|
79 | 79 |
for i in range(1, 3): |
80 | 80 |
driver.find_element_by_id(f'clickme{i}').click() |
81 | 81 |
|
82 |
def assert_properly_blocked(): |
|
83 |
click_all() |
|
84 |
|
|
82 | 85 |
assert set(driver.execute_script('return window.__run || [];')) == set() |
83 | 86 |
assert bool(csp_off_setting) == are_scripts_allowed(driver) |
84 | 87 |
|
... | ... | |
98 | 101 |
**csp_off_setting |
99 | 102 |
}) |
100 | 103 |
|
101 |
for i in range(1, 3): |
|
102 |
driver.find_element_by_id(f'clickme{i}').click() |
|
104 |
click_all() |
|
103 | 105 |
|
104 | 106 |
assert set(driver.execute_script('return window.__run || [];')) == \ |
105 | 107 |
{'inline', 'on', 'href', 'src', 'data'} |
... | ... | |
121 | 123 |
|
122 | 124 |
assert_properly_blocked() |
123 | 125 |
assert are_scripts_allowed(driver, nonce) |
126 |
|
|
127 |
# Test function analogous to that for HTML page. |
|
128 |
@pytest.mark.ext_data({'content_script': content_script}) |
|
129 |
@pytest.mark.usefixtures('webextension') |
|
130 |
@pytest.mark.parametrize('csp_off_setting', [{}, {'csp_off': True}]) |
|
131 |
def test_policy_enforcing_xml(driver, execute_in_page, csp_off_setting): |
|
132 |
""" |
|
133 |
A test case of sanitizing <script>s and intrinsic JavaScript in XML |
|
134 |
documents. |
|
135 |
""" |
|
136 |
def click_all(): |
|
137 |
for name in ('idaret', 'nowamak', 'mango', 'annoying'): |
|
138 |
elem = driver.find_element_by_id(f'{name}_circle') |
|
139 |
try: |
|
140 |
elem.click() |
|
141 |
except: |
|
142 |
pass |
|
143 |
|
|
144 |
def assert_properly_blocked(): |
|
145 |
click_all() |
|
146 |
|
|
147 |
try: |
|
148 |
assert set(driver.execute_script('return window.__run || [];')) == set() |
|
149 |
except: |
|
150 |
from time import sleep |
|
151 |
sleep(100000) |
|
152 |
assert bool(csp_off_setting) == are_scripts_allowed(driver) |
|
153 |
|
|
154 |
# First, see if scripts run when not blocked. |
|
155 |
get(driver, 'https://gotmyowndoma.in/scripts_to_block_2.xml', { |
|
156 |
'policy': allow_policy, |
|
157 |
**csp_off_setting |
|
158 |
}) |
|
159 |
|
|
160 |
click_all() |
|
161 |
|
|
162 |
assert set(driver.execute_script('return window.__run || [];')) == \ |
|
163 |
{'grape', 'raspberry', 'idaret', 'melon'} |
|
164 |
assert are_scripts_allowed(driver) |
|
165 |
|
|
166 |
# Now, verify scripts don't run when blocked. |
|
167 |
get(driver, 'https://gotmyowndoma.in/scripts_to_block_2.xml', { |
|
168 |
'policy': block_policy, |
|
169 |
**csp_off_setting |
|
170 |
}) |
|
171 |
|
|
172 |
assert_properly_blocked() |
|
173 |
|
|
174 |
# Now, verify only scripts with nonce can run when payload is injected. |
|
175 |
get(driver, 'https://gotmyowndoma.in/scripts_to_block_2.xml', { |
|
176 |
'policy': payload_policy, |
|
177 |
**csp_off_setting |
|
178 |
}) |
|
179 |
|
|
180 |
assert_properly_blocked() |
|
181 |
assert are_scripts_allowed(driver, nonce) |
test/haketilo_test/unit/utils.py | ||
---|---|---|
228 | 228 |
return driver.execute_script( |
229 | 229 |
''' |
230 | 230 |
document.haketilo_scripts_allowed = false; |
231 |
const script = document.createElement("script"); |
|
231 |
const html_ns = "http://www.w3.org/1999/xhtml"; |
|
232 |
const script = document.createElementNS(html_ns, "script"); |
|
232 | 233 |
script.innerHTML = "document.haketilo_scripts_allowed = true;"; |
233 | 234 |
if (arguments[0]) |
234 | 235 |
script.setAttribute("nonce", arguments[0]); |
235 |
document.head.append(script);
|
|
236 |
(document.head || document.documentElement).append(script);
|
|
236 | 237 |
return document.haketilo_scripts_allowed; |
237 | 238 |
''', |
238 | 239 |
nonce) |
test/haketilo_test/world_wide_library.py | ||
---|---|---|
234 | 234 |
|
235 | 235 |
'https://gotmyowndoma.in/scripts_to_block_1.html': |
236 | 236 |
(200, {}, here / 'data' / 'pages' / 'scripts_to_block_1.html'), |
237 |
'https://gotmyowndoma.in/scripts_to_block_2.xml': |
|
238 |
(200, {}, here / 'data' / 'pages' / 'scripts_to_block_2.xml'), |
|
237 | 239 |
|
238 | 240 |
'https://anotherdoma.in/resource/blocked/by/CORS.json': |
239 | 241 |
lambda command, get_params, post_params: (200, {}, some_data), |
Also available in: Unified diff
improve script blocking in non-HTML documents (XML)