Project

General

Profile

Download (5.57 KB) Statistics
| Branch: | Tag: | Revision:

haketilo / content / main.js @ 44958e6a

1
/**
2
 * Hachette main content script run in all frames
3
 *
4
 * Copyright (C) 2021 Wojtek Kosior
5
 * Copyright (C) 2021 jahoti
6
 * Redistribution terms are gathered in the `copyright' file.
7
 */
8

    
9
/*
10
 * IMPORTS_START
11
 * IMPORT handle_page_actions
12
 * IMPORT extract_signed
13
 * IMPORT gen_nonce
14
 * IMPORT is_privileged_url
15
 * IMPORT mozilla_suppress_scripts
16
 * IMPORT is_chrome
17
 * IMPORT is_mozilla
18
 * IMPORT start_activity_info_server
19
 * IMPORT csp_rule
20
 * IMPORT is_csp_header_name
21
 * IMPORT sanitize_csp_header
22
 * IMPORTS_END
23
 */
24

    
25
function accept_node(node, parent)
26
{
27
    const clone = document.importNode(node, false);
28
    node.hachette_corresponding = clone;
29
    /*
30
     * TODO: Stop page's own issues like "Error parsing a meta element's
31
     * content:" from appearing as extension's errors.
32
     */
33
    parent.hachette_corresponding.appendChild(clone);
34
}
35

    
36
/*
37
 * 1. When injecting some payload we need to sanitize <meta> CSP tags before
38
 *    they reach the document.
39
 * 2. Only <meta> tags inside <head> are considered valid by the browser and
40
 *    need to be considered.
41
 * 3. We want to detach <html> from document, wait until its <head> completes
42
 *    loading, sanitize it and re-attach <html>.
43
 * 4. Browsers are eager to add <meta>'s that appear after `</head>' but before
44
 *    `<body>'. Due to this behavior the `DOMContentLoaded' event is considered
45
 *    unreliable (although it could still work properly, it is just problematic
46
 *    to verify).
47
 * 5. We shall wait for anything to appear in or after <body> and take that as
48
 *    a sign <head> has _really_ finished loading.
49
 */
50

    
51
function make_body_start_observer(DOM_element, waiting)
52
{
53
    const observer = new MutationObserver(() => try_body_started(waiting));
54
    observer.observe(DOM_element, {childList: true});
55
    return observer;
56
}
57

    
58
function try_body_started(waiting)
59
{
60
    const body = waiting.detached_html.querySelector("body");
61

    
62
    if ((body && (body.firstChild || body.nextSibling)) ||
63
	waiting.doc.documentElement.nextSibling) {
64
	finish_waiting(waiting);
65
	return true;
66
    }
67

    
68
    if (body && waiting.observers.length < 2)
69
	waiting.observers.push(make_body_start_observer(body, waiting));
70
}
71

    
72
function finish_waiting(waiting)
73
{
74
    waiting.observers.forEach(observer => observer.disconnect());
75
    waiting.doc.removeEventListener("DOMContentLoaded", waiting.loaded_cb);
76
    setTimeout(waiting.callback, 0);
77
}
78

    
79
function _wait_for_head(doc, detached_html, callback)
80
{
81
    const waiting = {doc, detached_html, callback, observers: []};
82
    if (try_body_started(waiting))
83
	return;
84

    
85
    waiting.observers = [make_body_start_observer(detached_html, waiting)];
86
    waiting.loaded_cb = () => finish_waiting(waiting);
87
    doc.addEventListener("DOMContentLoaded", waiting.loaded_cb);
88
}
89

    
90
function wait_for_head(doc, detached_html)
91
{
92
    return new Promise(cb => _wait_for_head(doc, detached_html, cb));
93
}
94

    
95
const blocked_str = "blocked";
96

    
97
function block_attribute(node, attr)
98
{
99
    /*
100
     * Disabling attributes this way allows them to still be relatively
101
     * easily accessed in case they contain some useful data.
102
     */
103
    const construct_name = [attr];
104
    while (node.hasAttribute(construct_name.join("")))
105
	construct_name.unshift(blocked_str);
106

    
107
    while (construct_name.length > 1) {
108
	construct_name.shift();
109
	const name = construct_name.join("");
110
	node.setAttribute(`${blocked_str}-${name}`, node.getAttribute(name));
111
    }
112

    
113
    node.removeAttribute(attr);
114
}
115

    
116
function sanitize_meta(meta, policy)
117
{
118
    const http_equiv = meta.getAttribute("http-equiv");
119
    const value = meta.content;
120

    
121
    if (!value || !is_csp_header_name(http_equiv, true))
122
	return;
123

    
124
    block_attribute(meta, "content");
125

    
126
    if (is_csp_header_name(http_equiv, false))
127
	meta.content = sanitize_csp_header({value}, policy).value;
128
}
129

    
130
function apply_hachette_csp_rules(doc, policy)
131
{
132
    const meta = doc.createElement("meta");
133
    meta.setAttribute("http-equiv", "Content-Security-Policy");
134
    meta.setAttribute("content", csp_rule(policy.nonce));
135
    doc.head.append(meta);
136
    /* CSP is already in effect, we can remove the <meta> now. */
137
    meta.remove();
138
}
139

    
140
async function sanitize_document(doc, policy)
141
{
142
    /*
143
     * Ensure our CSP rules are employed from the beginning. This CSP injection
144
     * method is, when possible, going to be applied together with CSP rules
145
     * injected using webRequest.
146
     */
147
    const has_own_head = doc.head;
148
    if (!has_own_head)
149
	doc.documentElement.prepend(doc.createElement("head"));
150

    
151
    apply_hachette_csp_rules(doc, policy);
152

    
153
    /* Probably not needed, but...: proceed with DOM in its initial state. */
154
    if (!has_own_head)
155
	doc.head.remove();
156

    
157
    /*
158
     * <html> node gets hijacked now, to be re-attached after <head> is loaded
159
     * and sanitized.
160
     */
161
    const old_html = doc.documentElement;
162
    const new_html = doc.createElement("html");
163
    old_html.replaceWith(new_html);
164

    
165
    await wait_for_head(doc, old_html);
166

    
167
    for (const meta of old_html.querySelectorAll("head meta"))
168
	sanitize_meta(meta, policy);
169

    
170
    new_html.replaceWith(old_html);
171
}
172

    
173
if (!is_privileged_url(document.URL)) {
174
    const reductor =
175
	  (ac, [_, sig, pol]) => ac[0] && ac || [extract_signed(sig, pol), sig];
176
    const matches = [...document.cookie.matchAll(/hachette-(\w*)=([^;]*)/g)];
177
    let [policy, signature] = matches.reduce(reductor, []);
178

    
179
    if (!policy || policy.url !== document.URL) {
180
	console.log("WARNING! Using default policy!!!");
181
	policy = {allow: false, nonce: gen_nonce()};
182
    }
183

    
184
    if (signature)
185
	document.cookie = `hachette-${signature}=; Max-Age=-1;`;
186

    
187
    if (!policy.allow)
188
	sanitize_document(document, policy);
189

    
190
    handle_page_actions(policy.nonce);
191

    
192
    start_activity_info_server();
193
}
(3-3/5)