Revision d09b7ee1
Added by koszko about 2 years ago
background/policy_injector.js | ||
---|---|---|
18 | 18 |
* IMPORT query_best |
19 | 19 |
* IMPORT sanitize_csp_header |
20 | 20 |
* IMPORT csp_rule |
21 |
* IMPORT is_csp_header_name |
|
21 | 22 |
* IMPORTS_END |
22 | 23 |
*/ |
23 | 24 |
|
24 | 25 |
var storage; |
25 | 26 |
|
26 |
const csp_header_names = new Set([ |
|
27 |
"content-security-policy", |
|
28 |
"x-webkit-csp", |
|
29 |
"x-content-security-policy" |
|
30 |
]); |
|
31 |
|
|
32 |
const report_only = "content-security-policy-report-only"; |
|
33 |
|
|
34 | 27 |
function headers_inject(details) |
35 | 28 |
{ |
36 | 29 |
const url = details.url; |
... | ... | |
40 | 33 |
const [pattern, settings] = query_best(storage, url); |
41 | 34 |
const allow = !!(settings && settings.allow); |
42 | 35 |
const nonce = gen_nonce(); |
43 |
const rule = `'nonce-${nonce}'`; |
|
44 | 36 |
|
45 | 37 |
let orig_csp_headers; |
46 | 38 |
let old_signature; |
... | ... | |
70 | 62 |
} |
71 | 63 |
|
72 | 64 |
orig_csp_headers = orig_csp_headers || |
73 |
headers.filter(h => csp_header_names.has(h.name.toLowerCase())); |
|
74 |
headers = headers.filter(h => !csp_header_names.has(h.name.toLowerCase())); |
|
65 |
headers.filter(h => is_csp_header_name(h.name)); |
|
75 | 66 |
|
76 |
/* Remove headers that only snitch on us */ |
|
77 |
if (!allow) |
|
78 |
headers = headers.filter(h => h.name.toLowerCase() !== report_only); |
|
67 |
/* When blocking remove report-only CSP headers that snitch on us. */ |
|
68 |
headers = headers.filter(h => !is_csp_header_name(h.name, !allow)); |
|
79 | 69 |
|
80 | 70 |
if (old_signature) |
81 | 71 |
headers = headers.filter(h => h.name.search(old_signature) === -1); |
82 | 72 |
|
83 |
const sanitizer = h => sanitize_csp_header(h, rule, allow); |
|
73 |
const policy_object = {allow, nonce, url}; |
|
74 |
const sanitizer = h => sanitize_csp_header(h, policy_object); |
|
84 | 75 |
headers.push(...orig_csp_headers.map(sanitizer)); |
85 | 76 |
|
86 |
const policy = encodeURIComponent(JSON.stringify({allow, nonce, url}));
|
|
77 |
const policy = encodeURIComponent(JSON.stringify(policy_object));
|
|
87 | 78 |
const policy_signature = sign_data(policy, new Date()); |
88 | 79 |
const later_30sec = new Date(new Date().getTime() + 30000).toGMTString(); |
89 | 80 |
headers.push({ |
common/misc.js | ||
---|---|---|
78 | 78 |
return `script-src ${rule}; script-src-elem ${rule}; script-src-attr 'none'; prefetch-src 'none';`; |
79 | 79 |
} |
80 | 80 |
|
81 |
/* Check if some HTTP header might define CSP rules. */ |
|
82 |
const csp_header_names = new Set([ |
|
83 |
"content-security-policy", |
|
84 |
"x-webkit-csp", |
|
85 |
"x-content-security-policy" |
|
86 |
]); |
|
87 |
|
|
88 |
const report_only_header_name = "content-security-policy-report-only"; |
|
89 |
|
|
90 |
function is_csp_header_name(string, include_report_only) |
|
91 |
{ |
|
92 |
string = string && string.toLowerCase() || ""; |
|
93 |
|
|
94 |
return (include_report_only && string === report_only_header_name) || |
|
95 |
csp_header_names.has(string); |
|
96 |
} |
|
97 |
|
|
81 | 98 |
/* |
82 | 99 |
* Print item together with type, e.g. |
83 | 100 |
* nice_name("s", "hello") → "hello (script)" |
... | ... | |
127 | 144 |
} |
128 | 145 |
|
129 | 146 |
/* Make CSP headers do our bidding, not interfere */ |
130 |
function sanitize_csp_header(header, rule, allow)
|
|
147 |
function sanitize_csp_header(header, policy)
|
|
131 | 148 |
{ |
149 |
const rule = `'nonce-${policy.nonce}'`; |
|
132 | 150 |
const csp = parse_csp(header.value); |
133 | 151 |
|
134 |
if (!allow) { |
|
152 |
if (!policy.allow) {
|
|
135 | 153 |
/* No snitching */ |
136 | 154 |
delete csp['report-to']; |
137 | 155 |
delete csp['report-uri']; |
... | ... | |
153 | 171 |
else |
154 | 172 |
csp['script-src-elem'] = [rule]; |
155 | 173 |
|
156 |
const new_policy = Object.entries(csp).map(
|
|
174 |
const new_csp = Object.entries(csp).map(
|
|
157 | 175 |
i => `${i[0]} ${i[1].join(' ')};` |
158 | 176 |
); |
159 | 177 |
|
160 |
return {name: header.name, value: new_policy.join('')};
|
|
178 |
return {name: header.name, value: new_csp.join('')};
|
|
161 | 179 |
} |
162 | 180 |
|
163 | 181 |
/* Regexes and objest to use as/in schemas for parse_json_with_schema(). */ |
... | ... | |
178 | 196 |
* EXPORT extract_signed |
179 | 197 |
* EXPORT sign_data |
180 | 198 |
* EXPORT csp_rule |
199 |
* EXPORT is_csp_header_name |
|
181 | 200 |
* EXPORT nice_name |
182 | 201 |
* EXPORT open_in_settings |
183 | 202 |
* EXPORT is_privileged_url |
content/main.js | ||
---|---|---|
11 | 11 |
* IMPORT handle_page_actions |
12 | 12 |
* IMPORT extract_signed |
13 | 13 |
* IMPORT gen_nonce |
14 |
* IMPORT csp_rule |
|
15 | 14 |
* IMPORT is_privileged_url |
16 |
* IMPORT sanitize_attributes |
|
17 | 15 |
* IMPORT mozilla_suppress_scripts |
18 | 16 |
* IMPORT is_chrome |
19 | 17 |
* IMPORT is_mozilla |
20 | 18 |
* IMPORT start_activity_info_server |
19 |
* IMPORT modify_on_the_fly |
|
21 | 20 |
* IMPORTS_END |
22 | 21 |
*/ |
23 | 22 |
|
24 |
/* |
|
25 |
* Due to some technical limitations the chosen method of whitelisting sites |
|
26 |
* is to smuggle whitelist indicator in page's url as a "magical" string |
|
27 |
* after '#'. Right now this is only supplemental in HTTP(s) pages where |
|
28 |
* blocking of native scripts also happens through CSP header injection but is |
|
29 |
* necessary for protocols like ftp:// and file://. |
|
30 |
* |
|
31 |
* The code that actually injects the magical string into ftp:// and file:// |
|
32 |
* urls has not yet been added to the extension. |
|
33 |
*/ |
|
34 |
|
|
35 |
var nonce = undefined; |
|
36 |
|
|
37 |
function handle_mutation(mutations, observer) |
|
38 |
{ |
|
39 |
if (document.readyState === 'complete') { |
|
40 |
console.log("mutation handling complete"); |
|
41 |
observer.disconnect(); |
|
42 |
return; |
|
43 |
} |
|
44 |
for (const mutation of mutations) { |
|
45 |
for (const node of mutation.addedNodes) |
|
46 |
block_node(node); |
|
47 |
} |
|
48 |
} |
|
49 |
|
|
50 |
function block_nodes_recursively(node) |
|
51 |
{ |
|
52 |
block_node(node); |
|
53 |
for (const child of node.children) |
|
54 |
block_nodes_recursively(child); |
|
55 |
} |
|
56 |
|
|
57 |
function block_node(node) |
|
23 |
function accept_node(node, parent) |
|
58 | 24 |
{ |
25 |
const clone = document.importNode(node, false); |
|
26 |
node.hachette_corresponding = clone; |
|
59 | 27 |
/* |
60 |
* Modifying <script> element doesn't always prevent its execution in some
|
|
61 |
* Mozilla browsers. This is Chromium-specific code.
|
|
28 |
* TODO: Stop page's own issues like "Error parsing a meta element's
|
|
29 |
* content:" from appearing as extension's errors.
|
|
62 | 30 |
*/ |
63 |
if (node.tagName === "SCRIPT") { |
|
64 |
block_script(node); |
|
65 |
return; |
|
66 |
} |
|
67 |
|
|
68 |
sanitize_attributes(node); |
|
69 |
|
|
70 |
if (node.tagName === "HEAD") |
|
71 |
inject_csp(node); |
|
72 |
} |
|
73 |
|
|
74 |
function block_script(node) |
|
75 |
{ |
|
76 |
/* |
|
77 |
* Disabling scripts this way allows them to still be relatively |
|
78 |
* easily accessed in case they contain some useful data. |
|
79 |
*/ |
|
80 |
if (node.hasAttribute("type")) |
|
81 |
node.setAttribute("blocked-type", node.getAttribute("type")); |
|
82 |
node.setAttribute("type", "application/json"); |
|
83 |
} |
|
84 |
|
|
85 |
function inject_csp(head) |
|
86 |
{ |
|
87 |
let meta = document.createElement("meta"); |
|
88 |
meta.setAttribute("http-equiv", "Content-Security-Policy"); |
|
89 |
meta.setAttribute("content", csp_rule(nonce)); |
|
90 |
|
|
91 |
if (head.firstElementChild === null) |
|
92 |
head.appendChild(meta); |
|
93 |
else |
|
94 |
head.insertBefore(meta, head.firstElementChild); |
|
31 |
parent.hachette_corresponding.appendChild(clone); |
|
95 | 32 |
} |
96 | 33 |
|
97 | 34 |
if (!is_privileged_url(document.URL)) { |
... | ... | |
110 | 47 |
|
111 | 48 |
handle_page_actions(policy.nonce); |
112 | 49 |
|
113 |
if (!policy.allow) {
|
|
114 |
block_nodes_recursively(document.documentElement);
|
|
50 |
if (!policy.allow && is_mozilla)
|
|
51 |
addEventListener('beforescriptexecute', mozilla_suppress_scripts, true);
|
|
115 | 52 |
|
116 |
if (is_chrome) { |
|
117 |
var observer = new MutationObserver(handle_mutation); |
|
118 |
observer.observe(document.documentElement, { |
|
119 |
attributes: true, |
|
120 |
childList: true, |
|
121 |
subtree: true |
|
122 |
}); |
|
123 |
} |
|
53 |
if (!policy.allow && is_chrome) { |
|
54 |
const old_html = document.documentElement; |
|
55 |
const new_html = document.createElement("html"); |
|
56 |
old_html.replaceWith(new_html); |
|
57 |
old_html.hachette_corresponding = new_html; |
|
124 | 58 |
|
125 |
if (is_mozilla) |
|
126 |
addEventListener('beforescriptexecute', mozilla_suppress_scripts, true); |
|
59 |
const modify_end = |
|
60 |
modify_on_the_fly(old_html, policy, {node_eater: accept_node}); |
|
61 |
document.addEventListener("DOMContentLoaded", modify_end); |
|
127 | 62 |
} |
128 | 63 |
|
129 | 64 |
start_activity_info_server(); |
content/sanitize_document.js | ||
---|---|---|
1 |
/** |
|
2 |
* Hachette modify HTML document as it loads and reconstruct HTML code from it |
|
3 |
* |
|
4 |
* Copyright (C) 2021 Wojtek Kosior |
|
5 |
* Redistribution terms are gathered in the `copyright' file. |
|
6 |
*/ |
|
7 |
|
|
8 |
/* |
|
9 |
* IMPORTS_START |
|
10 |
* IMPORT gen_nonce |
|
11 |
* IMPORT csp_rule |
|
12 |
* IMPORT is_csp_header_name |
|
13 |
* IMPORT sanitize_csp_header |
|
14 |
* IMPORT sanitize_attributes |
|
15 |
* IMPORTS_END |
|
16 |
*/ |
|
17 |
|
|
18 |
/* |
|
19 |
* Functions that sanitize elements. The script blocking measures are, when |
|
20 |
* possible, going to be applied together with CSP rules injected using |
|
21 |
* webRequest. |
|
22 |
*/ |
|
23 |
|
|
24 |
const blocked = "blocked"; |
|
25 |
|
|
26 |
function block_attribute(node, attr) |
|
27 |
{ |
|
28 |
/* |
|
29 |
* Disabling attributed this way allows them to still be relatively |
|
30 |
* easily accessed in case they contain some useful data. |
|
31 |
*/ |
|
32 |
|
|
33 |
const construct_name = [attr]; |
|
34 |
while (node.hasAttribute(construct_name.join(""))) |
|
35 |
construct_name.unshift(blocked); |
|
36 |
|
|
37 |
while (construct_name.length > 1) { |
|
38 |
construct_name.shift(); |
|
39 |
const name = construct_name.join(""); |
|
40 |
node.setAttribute(`${blocked}-${name}`, node.getAttribute(name)); |
|
41 |
} |
|
42 |
|
|
43 |
node.removeAttribute(attr); |
|
44 |
} |
|
45 |
|
|
46 |
function sanitize_script(script, policy) |
|
47 |
{ |
|
48 |
if (policy.allow) |
|
49 |
return; |
|
50 |
|
|
51 |
block_attribute(script, "type"); |
|
52 |
script.setAttribute("type", "application/json"); |
|
53 |
} |
|
54 |
|
|
55 |
function inject_csp(head, policy) |
|
56 |
{ |
|
57 |
if (policy.allow) |
|
58 |
return; |
|
59 |
|
|
60 |
const meta = document.createElement("meta"); |
|
61 |
meta.setAttribute("http-equiv", "Content-Security-Policy"); |
|
62 |
meta.setAttribute("content", csp_rule(policy.nonce)); |
|
63 |
meta.hachette_ignore = true; |
|
64 |
head.prepend(meta); |
|
65 |
} |
|
66 |
|
|
67 |
function sanitize_http_equiv_csp_rule(meta, policy) |
|
68 |
{ |
|
69 |
const http_equiv = meta.getAttribute("http-equiv"); |
|
70 |
|
|
71 |
if (!is_csp_header_name(http_equiv, !policy.allow)) |
|
72 |
return; |
|
73 |
|
|
74 |
if (policy.allow || is_csp_header_name(http_equiv, false)) { |
|
75 |
let value = meta.getAttribute("content"); |
|
76 |
block_attribute(meta, "content"); |
|
77 |
if (value) { |
|
78 |
value = sanitize_csp_header({value}, policy).value; |
|
79 |
meta.setAttribute("content", value); |
|
80 |
} |
|
81 |
return; |
|
82 |
} |
|
83 |
|
|
84 |
block_attribute(meta, "http-equiv"); |
|
85 |
} |
|
86 |
|
|
87 |
function sanitize_node(node, policy) |
|
88 |
{ |
|
89 |
if (node.tagName === "SCRIPT") |
|
90 |
sanitize_script(node, policy); |
|
91 |
|
|
92 |
if (node.tagName === "HEAD") |
|
93 |
inject_csp(node, policy); |
|
94 |
|
|
95 |
if (node.tagName === "META") |
|
96 |
sanitize_http_equiv_csp_rule(node, policy); |
|
97 |
|
|
98 |
if (!policy.allow) |
|
99 |
sanitize_attributes(node, policy); |
|
100 |
} |
|
101 |
|
|
102 |
const serializer = new XMLSerializer(); |
|
103 |
|
|
104 |
function start_node(node, data) |
|
105 |
{ |
|
106 |
if (!data.writer) |
|
107 |
return; |
|
108 |
|
|
109 |
node.hachette_started = true; |
|
110 |
const clone = node.cloneNode(false); |
|
111 |
clone.textContent = data.uniq; |
|
112 |
data.writer(data.uniq_reg.exec(clone.outerHTML)[1]); |
|
113 |
} |
|
114 |
|
|
115 |
function finish_node(node, data) |
|
116 |
{ |
|
117 |
const nodes_to_process = [node]; |
|
118 |
|
|
119 |
while (true) { |
|
120 |
node = nodes_to_process.pop(); |
|
121 |
if (!node) |
|
122 |
break; |
|
123 |
|
|
124 |
nodes_to_process.push(node, node.hachette_last_added); |
|
125 |
} |
|
126 |
|
|
127 |
while (nodes_to_process.length > 0) { |
|
128 |
const node = nodes_to_process.pop(); |
|
129 |
node.remove(); |
|
130 |
|
|
131 |
if (!data.writer) |
|
132 |
continue; |
|
133 |
|
|
134 |
if (node.hachette_started) { |
|
135 |
node.textContent = data.uniq; |
|
136 |
data.writer(data.uniq_reg.exec(node.outerHTML)[2]); |
|
137 |
continue; |
|
138 |
} |
|
139 |
|
|
140 |
data.writer(node.outerHTML || serializer.serializeToString(node)); |
|
141 |
} |
|
142 |
} |
|
143 |
|
|
144 |
/* |
|
145 |
* Important! Due to some weirdness node.parentElement is not alway correct |
|
146 |
* under Chromium. Track node relations manually. |
|
147 |
*/ |
|
148 |
function handle_added_node(node, true_parent, data) |
|
149 |
{ |
|
150 |
if (node.hachette_ignore || true_parent.hachette_ignore) |
|
151 |
return; |
|
152 |
|
|
153 |
if (!true_parent.hachette_started) |
|
154 |
start_node(true_parent, data) |
|
155 |
|
|
156 |
sanitize_node(node, data.policy); |
|
157 |
|
|
158 |
if (data.node_eater) |
|
159 |
data.node_eater(node, true_parent); |
|
160 |
|
|
161 |
finish_node(true_parent.hachette_last_added, data); |
|
162 |
|
|
163 |
true_parent.hachette_last_added = node; |
|
164 |
} |
|
165 |
|
|
166 |
function handle_mutation(mutations, data) |
|
167 |
{ |
|
168 |
/* |
|
169 |
* Chromium: for an unknown reason mutation.target is not always the same as |
|
170 |
* node.parentElement. The former is the correct one. |
|
171 |
*/ |
|
172 |
for (const mutation of mutations) { |
|
173 |
for (const node of mutation.addedNodes) |
|
174 |
handle_added_node(node, mutation.target, data); |
|
175 |
} |
|
176 |
} |
|
177 |
|
|
178 |
function finish_processing(data) |
|
179 |
{ |
|
180 |
handle_mutation(data.observer.takeRecords(), data); |
|
181 |
finish_node(data.html_element, data); |
|
182 |
data.observer.disconnect(); |
|
183 |
} |
|
184 |
|
|
185 |
function modify_on_the_fly(html_element, policy, consumers) |
|
186 |
{ |
|
187 |
const uniq = gen_nonce(); |
|
188 |
const uniq_reg = new RegExp(`^(.*)${uniq}(.*)$`); |
|
189 |
const data = {policy, html_element, uniq, uniq_reg, ...consumers}; |
|
190 |
|
|
191 |
start_node(data.html_element, data); |
|
192 |
|
|
193 |
var observer = new MutationObserver(m => handle_mutation(m, data)); |
|
194 |
observer.observe(data.html_element, { |
|
195 |
attributes: true, |
|
196 |
childList: true, |
|
197 |
subtree: true |
|
198 |
}); |
|
199 |
|
|
200 |
data.observer = observer; |
|
201 |
|
|
202 |
return () => finish_processing(data); |
|
203 |
} |
|
204 |
|
|
205 |
/* |
|
206 |
* EXPORTS_START |
|
207 |
* EXPORT modify_on_the_fly |
|
208 |
* EXPORTS_END |
|
209 |
*/ |
Also available in: Unified diff
sanitize `' tags containing CSP rules under Chromium
This commit adds a mechanism of hijacking document when it loads and injecting sanitized nodes to the DOM from the level of content script.