Project

General

Profile

« Previous | Next » 

Revision d09b7ee1

Added by koszko about 2 years ago

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.

View differences:

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