Revision 96068ada
Added by koszko almost 2 years ago
background/cookie_filter.js | ||
---|---|---|
1 |
/** |
|
2 |
* This file is part of Haketilo. |
|
3 |
* |
|
4 |
* Function: Filtering request headers to remove haketilo cookies that might |
|
5 |
* have slipped through. |
|
6 |
* |
|
7 |
* Copyright (C) 2021 Wojtek Kosior |
|
8 |
* Redistribution terms are gathered in the `copyright' file. |
|
9 |
*/ |
|
10 |
|
|
11 |
/* |
|
12 |
* IMPORTS_START |
|
13 |
* IMPORT extract_signed |
|
14 |
* IMPORTS_END |
|
15 |
*/ |
|
16 |
|
|
17 |
function is_valid_haketilo_cookie(cookie) |
|
18 |
{ |
|
19 |
const match = /^haketilo-(\w*)=(.*)$/.exec(cookie); |
|
20 |
if (!match) |
|
21 |
return false; |
|
22 |
|
|
23 |
return !extract_signed(match.slice(1, 3)).fail; |
|
24 |
} |
|
25 |
|
|
26 |
function remove_haketilo_cookies(header) |
|
27 |
{ |
|
28 |
if (header.name !== "Cookie") |
|
29 |
return header; |
|
30 |
|
|
31 |
const cookies = header.value.split("; "); |
|
32 |
const value = cookies.filter(c => !is_valid_haketilo_cookie(c)).join("; "); |
|
33 |
|
|
34 |
return value ? {name: "Cookie", value} : null; |
|
35 |
} |
|
36 |
|
|
37 |
function filter_cookie_headers(headers) |
|
38 |
{ |
|
39 |
return headers.map(remove_haketilo_cookies).filter(h => h); |
|
40 |
} |
|
41 |
|
|
42 |
/* |
|
43 |
* EXPORTS_START |
|
44 |
* EXPORT filter_cookie_headers |
|
45 |
* EXPORTS_END |
|
46 |
*/ |
background/main.js | ||
---|---|---|
17 | 17 |
* IMPORT browser |
18 | 18 |
* IMPORT is_privileged_url |
19 | 19 |
* IMPORT query_best |
20 |
* IMPORT gen_nonce |
|
21 | 20 |
* IMPORT inject_csp_headers |
22 | 21 |
* IMPORT apply_stream_filter |
23 |
* IMPORT filter_cookie_headers |
|
24 | 22 |
* IMPORT is_chrome |
23 |
* IMPORT is_mozilla |
|
25 | 24 |
* IMPORTS_END |
26 | 25 |
*/ |
27 | 26 |
|
... | ... | |
51 | 50 |
|
52 | 51 |
browser.runtime.onInstalled.addListener(init_ext); |
53 | 52 |
|
53 |
/* |
|
54 |
* The function below implements a more practical interface for what it does by |
|
55 |
* wrapping the old query_best() function. |
|
56 |
*/ |
|
57 |
function decide_policy_for_url(storage, policy_observable, url) |
|
58 |
{ |
|
59 |
if (storage === undefined) |
|
60 |
return {allow: false}; |
|
61 |
|
|
62 |
const settings = |
|
63 |
{allow: policy_observable !== undefined && policy_observable.value}; |
|
64 |
|
|
65 |
const [pattern, queried_settings] = query_best(storage, url); |
|
66 |
|
|
67 |
if (queried_settings) { |
|
68 |
settings.payload = queried_settings.components; |
|
69 |
settings.allow = !!queried_settings.allow && !settings.payload; |
|
70 |
settings.pattern = pattern; |
|
71 |
} |
|
72 |
|
|
73 |
return settings; |
|
74 |
} |
|
54 | 75 |
|
55 | 76 |
let storage; |
56 | 77 |
let policy_observable = {}; |
57 | 78 |
|
58 |
function on_headers_received(details)
|
|
79 |
function sanitize_web_page(details)
|
|
59 | 80 |
{ |
60 | 81 |
const url = details.url; |
61 | 82 |
if (is_privileged_url(details.url)) |
62 | 83 |
return; |
63 | 84 |
|
64 |
const [pattern, settings] = query_best(storage, details.url); |
|
65 |
const has_payload = !!(settings && settings.components); |
|
66 |
const allow = !has_payload && |
|
67 |
!!(settings ? settings.allow : policy_observable.value); |
|
68 |
const nonce = gen_nonce(); |
|
69 |
const policy = {allow, url, nonce, has_payload}; |
|
85 |
const policy = |
|
86 |
decide_policy_for_url(storage, policy_observable, details.url); |
|
70 | 87 |
|
71 | 88 |
let headers = details.responseHeaders; |
89 |
|
|
90 |
headers = inject_csp_headers(headers, policy); |
|
91 |
|
|
72 | 92 |
let skip = false; |
73 | 93 |
for (const header of headers) { |
74 | 94 |
if ((header.name.toLowerCase().trim() === "content-disposition" && |
75 | 95 |
/^\s*attachment\s*(;.*)$/i.test(header.value))) |
76 | 96 |
skip = true; |
77 | 97 |
} |
78 |
|
|
79 |
headers = inject_csp_headers(headers, policy); |
|
80 |
|
|
81 | 98 |
skip = skip || (details.statusCode >= 300 && details.statusCode < 400); |
99 |
|
|
82 | 100 |
if (!skip) { |
83 | 101 |
/* Check for API availability. */ |
84 | 102 |
if (browser.webRequest.filterResponseData) |
... | ... | |
88 | 106 |
return {responseHeaders: headers}; |
89 | 107 |
} |
90 | 108 |
|
91 |
function on_before_send_headers(details) |
|
109 |
const request_url_regex = /^[^?]*\?url=(.*)$/; |
|
110 |
const redirect_url_template = browser.runtime.getURL("dummy") + "?settings="; |
|
111 |
|
|
112 |
function synchronously_smuggle_policy(details) |
|
92 | 113 |
{ |
93 |
let headers = details.requestHeaders; |
|
94 |
headers = filter_cookie_headers(headers); |
|
95 |
return {requestHeaders: headers}; |
|
114 |
/* |
|
115 |
* Content script will make a synchronous XmlHttpRequest to extension's |
|
116 |
* `dummy` file to query settings for given URL. We smuggle that |
|
117 |
* information in query parameter of the URL we redirect to. |
|
118 |
* A risk of fingerprinting arises if a page with script execution allowed |
|
119 |
* guesses the dummy file URL and makes an AJAX call to it. It is currently |
|
120 |
* a problem in ManifestV2 Chromium-family port of Haketilo because Chromium |
|
121 |
* uses predictable URLs for web-accessible resources. We plan to fix it in |
|
122 |
* the future ManifestV3 port. |
|
123 |
*/ |
|
124 |
if (details.type !== "xmlhttprequest") |
|
125 |
return {cancel: true}; |
|
126 |
|
|
127 |
console.debug(`Settings queried using XHR for '${details.url}'.`); |
|
128 |
|
|
129 |
let policy = {allow: false}; |
|
130 |
|
|
131 |
try { |
|
132 |
/* |
|
133 |
* request_url should be of the following format: |
|
134 |
* <url_for_extension's_dummy_file>?url=<valid_urlencoded_url> |
|
135 |
*/ |
|
136 |
const match = request_url_regex.exec(details.url); |
|
137 |
const queried_url = decodeURIComponent(match[1]); |
|
138 |
|
|
139 |
if (details.initiator && !queried_url.startsWith(details.initiator)) { |
|
140 |
console.warn(`Blocked suspicious query of '${url}' by '${details.initiator}'. This might be the result of page fingerprinting the browser.`); |
|
141 |
return {cancel: true}; |
|
142 |
} |
|
143 |
|
|
144 |
policy = decide_policy_for_url(storage, policy_observable, queried_url); |
|
145 |
} catch (e) { |
|
146 |
console.warn(`Bad request! Expected ${browser.runtime.getURL("dummy")}?url=<valid_urlencoded_url>. Got ${request_url}. This might be the result of page fingerprinting the browser.`); |
|
147 |
} |
|
148 |
|
|
149 |
const encoded_policy = encodeURIComponent(JSON.stringify(policy)); |
|
150 |
|
|
151 |
return {redirectUrl: redirect_url_template + encoded_policy}; |
|
96 | 152 |
} |
97 | 153 |
|
98 | 154 |
const all_types = [ |
... | ... | |
110 | 166 |
extra_opts.push("extraHeaders"); |
111 | 167 |
|
112 | 168 |
browser.webRequest.onHeadersReceived.addListener( |
113 |
on_headers_received,
|
|
169 |
sanitize_web_page,
|
|
114 | 170 |
{urls: ["<all_urls>"], types: ["main_frame", "sub_frame"]}, |
115 | 171 |
extra_opts.concat("responseHeaders") |
116 | 172 |
); |
117 | 173 |
|
118 |
browser.webRequest.onBeforeSendHeaders.addListener( |
|
119 |
on_before_send_headers, |
|
120 |
{urls: ["<all_urls>"], types: all_types}, |
|
121 |
extra_opts.concat("requestHeaders") |
|
174 |
const dummy_url_pattern = browser.runtime.getURL("dummy") + "?url=*"; |
|
175 |
browser.webRequest.onBeforeRequest.addListener( |
|
176 |
synchronously_smuggle_policy, |
|
177 |
{urls: [dummy_url_pattern], types: ["xmlhttprequest"]}, |
|
178 |
extra_opts |
|
122 | 179 |
); |
123 | 180 |
|
124 | 181 |
policy_observable = await light_storage.observe_var("default_allow"); |
125 | 182 |
} |
126 | 183 |
|
127 | 184 |
start_webRequest_operations(); |
185 |
|
|
186 |
const code = `\ |
|
187 |
console.warn("Hi, I'm Mr Dynamic!"); |
|
188 |
|
|
189 |
console.debug("let's see how window.killtheweb looks like now"); |
|
190 |
|
|
191 |
console.log("killtheweb", window.killtheweb); |
|
192 |
` |
|
193 |
|
|
194 |
async function test_dynamic_content_scripts() |
|
195 |
{ |
|
196 |
browser.contentScripts.register({ |
|
197 |
"js": [{code}], |
|
198 |
"matches": ["<all_urls>"], |
|
199 |
"allFrames": true, |
|
200 |
"runAt": "document_start" |
|
201 |
}); |
|
202 |
} |
|
203 |
|
|
204 |
if (is_mozilla) |
|
205 |
test_dynamic_content_scripts(); |
background/page_actions_server.js | ||
---|---|---|
16 | 16 |
* IMPORT browser |
17 | 17 |
* IMPORT listen_for_connection |
18 | 18 |
* IMPORT sha256 |
19 |
* IMPORT query_best |
|
20 | 19 |
* IMPORT make_ajax_request |
21 | 20 |
* IMPORTS_END |
22 | 21 |
*/ |
23 | 22 |
|
24 | 23 |
var storage; |
25 | 24 |
var handler; |
26 |
let policy_observable; |
|
27 |
|
|
28 |
function send_actions(url, port) |
|
29 |
{ |
|
30 |
const [pattern, queried_settings] = query_best(storage, url); |
|
31 |
|
|
32 |
const settings = {allow: policy_observable && policy_observable.value}; |
|
33 |
Object.assign(settings, queried_settings); |
|
34 |
if (settings.components) |
|
35 |
settings.allow = false; |
|
36 |
|
|
37 |
const repos = storage.get_all(TYPE_PREFIX.REPO); |
|
38 |
|
|
39 |
port.postMessage(["settings", [pattern, settings, repos]]); |
|
40 |
|
|
41 |
const components = settings.components; |
|
42 |
const processed_bags = new Set(); |
|
43 |
|
|
44 |
if (components !== undefined) |
|
45 |
send_scripts([components], port, processed_bags); |
|
46 |
} |
|
47 | 25 |
|
48 | 26 |
// TODO: parallelize script fetching |
49 | 27 |
async function send_scripts(components, port, processed_bags) |
... | ... | |
116 | 94 |
function handle_message(port, message, handler) |
117 | 95 |
{ |
118 | 96 |
port.onMessage.removeListener(handler[0]); |
119 |
let url = message.url; |
|
120 |
console.log({url}); |
|
121 |
send_actions(url, port); |
|
97 |
console.debug(`Loading payload '${message.payload}'.`); |
|
98 |
|
|
99 |
const processed_bags = new Set(); |
|
100 |
|
|
101 |
send_scripts([message.payload], port, processed_bags); |
|
122 | 102 |
} |
123 | 103 |
|
124 | 104 |
function new_connection(port) |
... | ... | |
134 | 114 |
storage = await get_storage(); |
135 | 115 |
|
136 | 116 |
listen_for_connection(CONNECTION_TYPE.PAGE_ACTIONS, new_connection); |
137 |
|
|
138 |
policy_observable = await light_storage.observe_var("default_allow"); |
|
139 | 117 |
} |
140 | 118 |
|
141 | 119 |
/* |
background/policy_injector.js | ||
---|---|---|
10 | 10 |
|
11 | 11 |
/* |
12 | 12 |
* IMPORTS_START |
13 |
* IMPORT sign_data |
|
14 |
* IMPORT extract_signed |
|
15 | 13 |
* IMPORT make_csp_rule |
16 | 14 |
* IMPORT csp_header_regex |
15 |
* Re-enable the import below once nonce stuff here is ready |
|
16 |
* !mport gen_nonce |
|
17 | 17 |
* IMPORTS_END |
18 | 18 |
*/ |
19 | 19 |
|
20 | 20 |
function inject_csp_headers(headers, policy) |
21 | 21 |
{ |
22 | 22 |
let csp_headers; |
23 |
let old_signature; |
|
24 |
let haketilo_header; |
|
25 | 23 |
|
26 |
for (const header of headers.filter(h => h.name === "x-haketilo")) { |
|
27 |
/* x-haketilo header has format: <signature>_0_<data> */ |
|
28 |
const match = /^([^_]+)_(0_.*)$/.exec(header.value); |
|
29 |
if (!match) |
|
30 |
continue; |
|
24 |
if (policy.payload) { |
|
25 |
headers = headers.filter(h => !csp_header_regex.test(h.name)); |
|
31 | 26 |
|
32 |
const result = extract_signed(...match.slice(1, 3));
|
|
33 |
if (result.fail)
|
|
34 |
continue;
|
|
27 |
// TODO: make CSP rules with nonces and facilitate passing them to
|
|
28 |
// content scripts via dynamic content script registration or
|
|
29 |
// synchronous XHRs
|
|
35 | 30 |
|
36 |
/* This should succeed - it's our self-produced valid JSON. */ |
|
37 |
const old_data = JSON.parse(decodeURIComponent(result.data)); |
|
38 |
|
|
39 |
/* Confirmed- it's the originals, smuggled in! */ |
|
40 |
csp_headers = old_data.csp_headers; |
|
41 |
old_signature = old_data.policy_sig; |
|
42 |
|
|
43 |
haketilo_header = header; |
|
44 |
break; |
|
31 |
// policy.nonce = gen_nonce(); |
|
45 | 32 |
} |
46 | 33 |
|
47 |
if (policy.has_payload) { |
|
48 |
csp_headers = []; |
|
49 |
const non_csp_headers = []; |
|
50 |
const header_list = |
|
51 |
h => csp_header_regex.test(h) ? csp_headers : non_csp_headers; |
|
52 |
headers.forEach(h => header_list(h.name).push(h)); |
|
53 |
headers = non_csp_headers; |
|
54 |
} else { |
|
55 |
headers.push(...csp_headers || []); |
|
56 |
} |
|
57 |
|
|
58 |
if (!haketilo_header) { |
|
59 |
haketilo_header = {name: "x-haketilo"}; |
|
60 |
headers.push(haketilo_header); |
|
61 |
} |
|
62 |
|
|
63 |
if (old_signature) |
|
64 |
headers = headers.filter(h => h.value.search(old_signature) === -1); |
|
65 |
|
|
66 |
const policy_str = encodeURIComponent(JSON.stringify(policy)); |
|
67 |
const signed_policy = sign_data(policy_str, new Date().getTime()); |
|
68 |
const later_30sec = new Date(new Date().getTime() + 30000).toGMTString(); |
|
69 |
headers.push({ |
|
70 |
name: "Set-Cookie", |
|
71 |
value: `haketilo-${signed_policy.join("=")}; Expires=${later_30sec};` |
|
72 |
}); |
|
73 |
|
|
74 |
/* |
|
75 |
* Smuggle in the signature and the original CSP headers for future use. |
|
76 |
* These are signed with a time of 0, as it's not clear there is a limit on |
|
77 |
* how long Firefox might retain headers in the cache. |
|
78 |
*/ |
|
79 |
let haketilo_data = {csp_headers, policy_sig: signed_policy[0]}; |
|
80 |
haketilo_data = encodeURIComponent(JSON.stringify(haketilo_data)); |
|
81 |
haketilo_header.value = sign_data(haketilo_data, 0).join("_"); |
|
82 |
|
|
83 |
if (!policy.allow) { |
|
34 |
if (!policy.allow && (policy.nonce || !policy.payload)) { |
|
84 | 35 |
headers.push({ |
85 | 36 |
name: "content-security-policy", |
86 | 37 |
value: make_csp_rule(policy) |
background/stream_filter.js | ||
---|---|---|
174 | 174 |
* as harmless anyway). |
175 | 175 |
*/ |
176 | 176 |
|
177 |
const dummy_script = |
|
178 |
`<script data-haketilo-deleteme="${properties.policy.nonce}" nonce="${properties.policy.nonce}">null</script>`; |
|
177 |
const dummy_script = `<script>null</script>`; |
|
179 | 178 |
const doctype_decl = /^(\s*<!doctype[^<>"']*>)?/i.exec(decoded)[0]; |
180 | 179 |
decoded = doctype_decl + dummy_script + |
181 | 180 |
decoded.substring(doctype_decl.length); |
... | ... | |
189 | 188 |
|
190 | 189 |
function apply_stream_filter(details, headers, policy) |
191 | 190 |
{ |
192 |
if (!policy.has_payload)
|
|
191 |
if (!policy.payload) |
|
193 | 192 |
return headers; |
194 | 193 |
|
195 | 194 |
const properties = properties_from_headers(headers); |
196 |
properties.policy = policy; |
|
197 | 195 |
|
198 | 196 |
properties.filter = |
199 | 197 |
browser.webRequest.filterResponseData(details.requestId); |
build.sh | ||
---|---|---|
180 | 180 |
mkdir -p "$BUILDDIR"/$DIR |
181 | 181 |
done |
182 | 182 |
|
183 |
CHROMIUM_KEY='' |
|
184 | 183 |
CHROMIUM_UPDATE_URL='' |
185 | 184 |
GECKO_APPLICATIONS='' |
186 | 185 |
|
... | ... | |
189 | 188 |
fi |
190 | 189 |
|
191 | 190 |
if [ "$BROWSER" = "chromium" ]; then |
192 |
CHROMIUM_KEY="$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64)" |
|
193 |
CHROMIUM_KEY=$(echo chromium-key-dummy-file-$CHROMIUM_KEY | tr / -) |
|
194 |
touch "$BUILDDIR"/$CHROMIUM_KEY |
|
195 |
|
|
196 | 191 |
CHROMIUM_UPDATE_URL="$UPDATE_URL" |
197 |
|
|
198 |
CHROMIUM_KEY="\n\ |
|
199 |
// WARNING!!!\n\ |
|
200 |
// EACH USER SHOULD REPLACE DUMMY FILE's VALUE WITH A UNIQUE ONE!!!\n\ |
|
201 |
// OTHERWISE, SECURITY CAN BE TRIVIALLY COMPROMISED!\n\ |
|
202 |
// Only relevant to users of chrome-based browsers.\n\ |
|
203 |
// Users of Firefox forks are safe.\n\ |
|
204 |
\"$CHROMIUM_KEY\"\ |
|
205 |
" |
|
206 | 192 |
else |
207 | 193 |
GECKO_APPLICATIONS="\n\ |
208 | 194 |
\"applications\": {\n\ |
... | ... | |
215 | 201 |
|
216 | 202 |
sed "\ |
217 | 203 |
s^_GECKO_APPLICATIONS_^$GECKO_APPLICATIONS^ |
218 |
s^_CHROMIUM_KEY_^$CHROMIUM_KEY^ |
|
219 | 204 |
s^_CHROMIUM_UPDATE_URL_^$CHROMIUM_UPDATE_URL^ |
220 | 205 |
s^_BGSCRIPTS_^$BGSCRIPTS^ |
221 | 206 |
s^_CONTENTSCRIPTS_^$CONTENTSCRIPTS^" \ |
... | ... | |
279 | 264 |
fi |
280 | 265 |
|
281 | 266 |
cp -r copyright licenses/ "$BUILDDIR" |
267 |
cp dummy "$BUILDDIR" |
|
282 | 268 |
cp html/*.css "$BUILDDIR"/html |
283 | 269 |
mkdir "$BUILDDIR"/icons |
284 | 270 |
cp icons/*.png "$BUILDDIR"/icons |
common/misc.js | ||
---|---|---|
49 | 49 |
function make_csp_rule(policy) |
50 | 50 |
{ |
51 | 51 |
let rule = "prefetch-src 'none'; script-src-attr 'none';"; |
52 |
const script_src = policy.has_payload ?
|
|
52 |
const script_src = policy.nonce !== undefined ?
|
|
53 | 53 |
`'nonce-${policy.nonce}'` : "'none'"; |
54 | 54 |
rule += ` script-src ${script_src}; script-src-elem ${script_src};`; |
55 | 55 |
return rule; |
common/signing.js | ||
---|---|---|
1 |
/** |
|
2 |
* This file is part of Haketilo. |
|
3 |
* |
|
4 |
* Functions: Operations related to "signing" of data. |
|
5 |
* |
|
6 |
* Copyright (C) 2021 Wojtek Kosior |
|
7 |
* Redistribution terms are gathered in the `copyright' file. |
|
8 |
*/ |
|
9 |
|
|
10 |
/* |
|
11 |
* IMPORTS_START |
|
12 |
* IMPORT sha256 |
|
13 |
* IMPORT browser |
|
14 |
* IMPORT is_mozilla |
|
15 |
* IMPORTS_END |
|
16 |
*/ |
|
17 |
|
|
18 |
/* |
|
19 |
* In order to make certain data synchronously accessible in certain contexts, |
|
20 |
* Haketilo smuggles it in string form in places like cookies, URLs and headers. |
|
21 |
* When using the smuggled data, we first need to make sure it isn't spoofed. |
|
22 |
* For that, we use this pseudo-signing mechanism. |
|
23 |
* |
|
24 |
* Despite what name suggests, no assymetric cryptography is involved, as it |
|
25 |
* would bring no additional benefits and would incur bigger performance |
|
26 |
* overhead. Instead, we hash the string data together with some secret value |
|
27 |
* that is supposed to be known only by this browser instance. Resulting hash |
|
28 |
* sum plays the role of the signature. In the hash we also include current |
|
29 |
* time. This way, even if signed data leaks (which shouldn't happen in the |
|
30 |
* first place), an attacker won't be able to re-use it indefinitely. |
|
31 |
* |
|
32 |
* The secret shared between execution contexts has to be available |
|
33 |
* synchronously. Under Mozilla, this is the extension's per-session id. Under |
|
34 |
* Chromium, this is a dummy web-accessible-resource name that resides in the |
|
35 |
* manifest and is supposed to be constructed by each user using a unique value |
|
36 |
* (this is done automatically by `build.sh'). |
|
37 |
*/ |
|
38 |
|
|
39 |
function get_secret() |
|
40 |
{ |
|
41 |
if (is_mozilla) |
|
42 |
return browser.runtime.getURL("dummy"); |
|
43 |
|
|
44 |
return chrome.runtime.getManifest().web_accessible_resources |
|
45 |
.map(r => /^chromium-key-dummy-file-(.*)/.exec(r)).filter(r => r)[0][1]; |
|
46 |
} |
|
47 |
|
|
48 |
function extract_signed(signature, signed_data) |
|
49 |
{ |
|
50 |
const match = /^([1-9][0-9]{12}|0)_(.*)$/.exec(signed_data); |
|
51 |
if (!match) |
|
52 |
return {fail: "bad format"}; |
|
53 |
|
|
54 |
const result = {time: parseInt(match[1]), data: match[2]}; |
|
55 |
if (sign_data(result.data, result.time)[0] !== signature) |
|
56 |
result.fail = "bad signature"; |
|
57 |
|
|
58 |
return result; |
|
59 |
} |
|
60 |
|
|
61 |
/* |
|
62 |
* Sign a given string for a given time. Time should be either 0 or in the range |
|
63 |
* 10^12 <= time < 10^13. |
|
64 |
*/ |
|
65 |
function sign_data(data, time) { |
|
66 |
return [sha256(get_secret() + time + data), `${time}_${data}`]; |
|
67 |
} |
|
68 |
|
|
69 |
/* |
|
70 |
* EXPORTS_START |
|
71 |
* EXPORT extract_signed |
|
72 |
* EXPORT sign_data |
|
73 |
* EXPORTS_END |
|
74 |
*/ |
content/activity_info_server.js | ||
---|---|---|
42 | 42 |
|
43 | 43 |
function report_settings(settings) |
44 | 44 |
{ |
45 |
report_activity("settings", settings); |
|
45 |
const settings_clone = {}; |
|
46 |
Object.assign(settings_clone, settings) |
|
47 |
report_activity("settings", settings_clone); |
|
46 | 48 |
} |
47 | 49 |
|
48 | 50 |
function report_document_type(is_html) |
content/main.js | ||
---|---|---|
11 | 11 |
/* |
12 | 12 |
* IMPORTS_START |
13 | 13 |
* IMPORT handle_page_actions |
14 |
* IMPORT extract_signed |
|
15 |
* IMPORT sign_data |
|
16 | 14 |
* IMPORT gen_nonce |
17 | 15 |
* IMPORT is_privileged_url |
16 |
* IMPORT browser |
|
18 | 17 |
* IMPORT is_chrome |
19 | 18 |
* IMPORT is_mozilla |
20 | 19 |
* IMPORT start_activity_info_server |
21 | 20 |
* IMPORT make_csp_rule |
22 | 21 |
* IMPORT csp_header_regex |
22 |
* IMPORT report_settings |
|
23 | 23 |
* IMPORTS_END |
24 | 24 |
*/ |
25 | 25 |
|
... | ... | |
29 | 29 |
|
30 | 30 |
wait_loaded(document).then(() => document.content_loaded = true); |
31 | 31 |
|
32 |
function extract_cookie_policy(cookie, min_time) |
|
33 |
{ |
|
34 |
let best_result = {time: -1}; |
|
35 |
let policy = null; |
|
36 |
const extracted_signatures = []; |
|
37 |
|
|
38 |
for (const match of cookie.matchAll(/haketilo-(\w*)=([^;]*)/g)) { |
|
39 |
const new_result = extract_signed(...match.slice(1, 3)); |
|
40 |
if (new_result.fail) |
|
41 |
continue; |
|
42 |
|
|
43 |
extracted_signatures.push(match[1]); |
|
44 |
|
|
45 |
if (new_result.time < Math.max(min_time, best_result.time)) |
|
46 |
continue; |
|
47 |
|
|
48 |
/* This should succeed - it's our self-produced valid JSON. */ |
|
49 |
const new_policy = JSON.parse(decodeURIComponent(new_result.data)); |
|
50 |
if (new_policy.url !== document.URL) |
|
51 |
continue; |
|
52 |
|
|
53 |
best_result = new_result; |
|
54 |
policy = new_policy; |
|
55 |
} |
|
56 |
|
|
57 |
return [policy, extracted_signatures]; |
|
58 |
} |
|
59 |
|
|
60 |
function extract_url_policy(url, min_time) |
|
61 |
{ |
|
62 |
const [base_url, payload, anchor] = |
|
63 |
/^([^#]*)#?([^#]*)(#?.*)$/.exec(url).splice(1, 4); |
|
64 |
|
|
65 |
const match = /^haketilo_([^_]+)_(.*)$/.exec(payload); |
|
66 |
if (!match) |
|
67 |
return [null, url]; |
|
68 |
|
|
69 |
const result = extract_signed(...match.slice(1, 3)); |
|
70 |
if (result.fail) |
|
71 |
return [null, url]; |
|
72 |
|
|
73 |
const original_url = base_url + anchor; |
|
74 |
const policy = result.time < min_time ? null : |
|
75 |
JSON.parse(decodeURIComponent(result.data)); |
|
76 |
|
|
77 |
return [policy.url === original_url ? policy : null, original_url]; |
|
78 |
} |
|
79 |
|
|
80 |
function employ_nonhttp_policy(policy) |
|
81 |
{ |
|
82 |
if (!policy.allow) |
|
83 |
return; |
|
84 |
|
|
85 |
policy.nonce = gen_nonce(); |
|
86 |
const [base_url, target] = /^([^#]*)(#?.*)$/.exec(policy.url).slice(1, 3); |
|
87 |
const encoded_policy = encodeURIComponent(JSON.stringify(policy)); |
|
88 |
const payload = "haketilo_" + |
|
89 |
sign_data(encoded_policy, new Date().getTime()).join("_"); |
|
90 |
const resulting_url = `${base_url}#${payload}${target}`; |
|
91 |
location.href = resulting_url; |
|
92 |
location.reload(); |
|
93 |
} |
|
94 |
|
|
95 | 32 |
/* |
96 | 33 |
* In the case of HTML documents: |
97 | 34 |
* 1. When injecting some payload we need to sanitize <meta> CSP tags before |
... | ... | |
306 | 243 |
start_data_urls_sanitizing(doc); |
307 | 244 |
} |
308 | 245 |
|
309 |
async function disable_service_workers() |
|
246 |
async function _disable_service_workers()
|
|
310 | 247 |
{ |
311 | 248 |
if (!navigator.serviceWorker) |
312 | 249 |
return; |
... | ... | |
315 | 252 |
if (registrations.length === 0) |
316 | 253 |
return; |
317 | 254 |
|
318 |
console.warn("Service Workers detected on this page! Unregistering and reloading"); |
|
255 |
console.warn("Service Workers detected on this page! Unregistering and reloading.");
|
|
319 | 256 |
|
320 | 257 |
try { |
321 | 258 |
await Promise.all(registrations.map(r => r.unregister())); |
... | ... | |
327 | 264 |
return new Promise(() => 0); |
328 | 265 |
} |
329 | 266 |
|
330 |
if (!is_privileged_url(document.URL)) { |
|
331 |
let policy_received_callback = () => undefined; |
|
332 |
let policy; |
|
333 |
|
|
334 |
/* Signature valid for half an hour. */ |
|
335 |
const min_time = new Date().getTime() - 1800 * 1000; |
|
336 |
|
|
337 |
if (/^https?:/.test(document.URL)) { |
|
338 |
let signatures; |
|
339 |
[policy, signatures] = extract_cookie_policy(document.cookie, min_time); |
|
340 |
for (const signature of signatures) |
|
341 |
document.cookie = `haketilo-${signature}=; Max-Age=-1;`; |
|
342 |
} else { |
|
343 |
const scheme = /^([^:]*)/.exec(document.URL)[1]; |
|
344 |
const known_scheme = ["file", "ftp"].includes(scheme); |
|
345 |
|
|
346 |
if (!known_scheme) |
|
347 |
console.warn(`Unknown url scheme: \`${scheme}'!`); |
|
348 |
|
|
349 |
let original_url; |
|
350 |
[policy, original_url] = extract_url_policy(document.URL, min_time); |
|
351 |
history.replaceState(null, "", original_url); |
|
352 |
|
|
353 |
if (known_scheme && !policy) |
|
354 |
policy_received_callback = employ_nonhttp_policy; |
|
267 |
/* |
|
268 |
* Trying to use servce workers APIs might result in exceptions, for example |
|
269 |
* when in a non-HTML document. Because of this, we wrap the function that does |
|
270 |
* the actual work in a try {} block. |
|
271 |
*/ |
|
272 |
async function disable_service_workers() |
|
273 |
{ |
|
274 |
try { |
|
275 |
await _disable_service_workers() |
|
276 |
} catch (e) { |
|
277 |
console.debug("Exception thrown during an attempt to detect and disable service workers.", e); |
|
355 | 278 |
} |
279 |
} |
|
356 | 280 |
|
357 |
if (!policy) { |
|
358 |
console.debug("Using fallback policy!"); |
|
359 |
policy = {allow: false, nonce: gen_nonce()}; |
|
281 |
function synchronously_get_policy(url) |
|
282 |
{ |
|
283 |
const encoded_url = encodeURIComponent(url); |
|
284 |
const request_url = `${browser.runtime.getURL("dummy")}?url=${encoded_url}`; |
|
285 |
|
|
286 |
try { |
|
287 |
var xhttp = new XMLHttpRequest(); |
|
288 |
xhttp.open("GET", request_url, false); |
|
289 |
xhttp.send(); |
|
290 |
} catch(e) { |
|
291 |
console.error("Failure to synchronously fetch policy for url.", e); |
|
292 |
return {allow: false}; |
|
360 | 293 |
} |
361 | 294 |
|
295 |
const policy = /^[^?]*\?settings=(.*)$/.exec(xhttp.responseURL)[1]; |
|
296 |
return JSON.parse(decodeURIComponent(policy)); |
|
297 |
} |
|
298 |
|
|
299 |
if (!is_privileged_url(document.URL)) { |
|
300 |
const policy = synchronously_get_policy(document.URL); |
|
301 |
|
|
362 | 302 |
if (!(document instanceof HTMLDocument)) |
363 |
policy.has_payload = false;
|
|
303 |
delete policy.payload;
|
|
364 | 304 |
|
365 | 305 |
console.debug("current policy", policy); |
366 | 306 |
|
307 |
report_settings(policy); |
|
308 |
|
|
309 |
policy.nonce = gen_nonce(); |
|
310 |
|
|
367 | 311 |
const doc_ready = Promise.all([ |
368 | 312 |
policy.allow ? Promise.resolve() : sanitize_document(document, policy), |
369 | 313 |
policy.allow ? Promise.resolve() : disable_service_workers(), |
370 | 314 |
wait_loaded(document) |
371 | 315 |
]); |
372 | 316 |
|
373 |
handle_page_actions(policy.nonce, policy_received_callback, doc_ready);
|
|
317 |
handle_page_actions(policy, doc_ready); |
|
374 | 318 |
|
375 | 319 |
start_activity_info_server(); |
376 | 320 |
} |
content/page_actions.js | ||
---|---|---|
12 | 12 |
* IMPORT CONNECTION_TYPE |
13 | 13 |
* IMPORT browser |
14 | 14 |
* IMPORT report_script |
15 |
* IMPORT report_settings |
|
16 | 15 |
* IMPORT report_document_type |
17 | 16 |
* IMPORTS_END |
18 | 17 |
*/ |
19 | 18 |
|
20 |
let policy_received_callback;
|
|
19 |
let policy; |
|
21 | 20 |
/* Snapshot url and content type early; these can be changed by other code. */ |
22 | 21 |
let url; |
23 | 22 |
let is_html; |
24 | 23 |
let port; |
25 | 24 |
let loaded = false; |
26 | 25 |
let scripts_awaiting = []; |
27 |
let nonce; |
|
28 | 26 |
|
29 | 27 |
function handle_message(message) |
30 | 28 |
{ |
... | ... | |
38 | 36 |
scripts_awaiting.push(script_text); |
39 | 37 |
} |
40 | 38 |
} |
41 |
if (action === "settings") { |
|
42 |
report_settings(data); |
|
43 |
policy_received_callback({url, allow: data[1].allow}); |
|
39 |
else { |
|
40 |
console.error(`Bad page action '${action}'.`); |
|
44 | 41 |
} |
45 | 42 |
} |
46 | 43 |
|
... | ... | |
61 | 58 |
|
62 | 59 |
let script = document.createElement("script"); |
63 | 60 |
script.textContent = script_text; |
64 |
script.setAttribute("nonce", nonce); |
|
61 |
script.setAttribute("nonce", policy.nonce);
|
|
65 | 62 |
script.haketilo_payload = true; |
66 | 63 |
document.body.appendChild(script); |
67 | 64 |
|
68 | 65 |
report_script(script_text); |
69 | 66 |
} |
70 | 67 |
|
71 |
function handle_page_actions(script_nonce, policy_received_cb,
|
|
72 |
doc_ready_promise) {
|
|
73 |
policy_received_callback = policy_received_cb; |
|
68 |
function handle_page_actions(_policy, doc_ready_promise) {
|
|
69 |
policy = _policy;
|
|
70 |
|
|
74 | 71 |
url = document.URL; |
75 | 72 |
is_html = document instanceof HTMLDocument; |
76 | 73 |
report_document_type(is_html); |
77 | 74 |
|
78 | 75 |
doc_ready_promise.then(document_ready); |
79 | 76 |
|
80 |
port = browser.runtime.connect({name : CONNECTION_TYPE.PAGE_ACTIONS});
|
|
81 |
port.onMessage.addListener(handle_message);
|
|
82 |
port.postMessage({url});
|
|
83 |
|
|
84 |
nonce = script_nonce;
|
|
77 |
if (policy.payload) {
|
|
78 |
port = browser.runtime.connect({name : CONNECTION_TYPE.PAGE_ACTIONS});
|
|
79 |
port.onMessage.addListener(handle_message);
|
|
80 |
port.postMessage({payload: policy.payload}); |
|
81 |
}
|
|
85 | 82 |
} |
86 | 83 |
|
87 | 84 |
/* |
html/display-panel.js | ||
---|---|---|
229 | 229 |
const [type, data] = message; |
230 | 230 |
|
231 | 231 |
if (type === "settings") { |
232 |
let [pattern, settings] = data;
|
|
232 |
const settings = data;
|
|
233 | 233 |
|
234 | 234 |
blocked_span.textContent = settings.allow ? "no" : "yes"; |
235 | 235 |
|
236 |
if (pattern) { |
|
236 |
if (settings.pattern) {
|
|
237 | 237 |
pattern_span.textContent = pattern; |
238 | 238 |
const settings_opener = |
239 |
() => open_in_settings(TYPE_PREFIX.PAGE, pattern); |
|
239 |
() => open_in_settings(TYPE_PREFIX.PAGE, settings.pattern);
|
|
240 | 240 |
view_pattern_but.classList.remove("hide"); |
241 | 241 |
view_pattern_but.addEventListener("click", settings_opener); |
242 | 242 |
} else { |
... | ... | |
244 | 244 |
blocked_span.textContent = blocked_span.textContent + " (default)"; |
245 | 245 |
} |
246 | 246 |
|
247 |
const components = settings.components; |
|
248 |
if (components) { |
|
249 |
payload_span.textContent = nice_name(...components); |
|
247 |
if (settings.payload) { |
|
248 |
payload_span.textContent = nice_name(...settings.payload); |
|
250 | 249 |
payload_buttons_div.classList.remove("hide"); |
251 |
const settings_opener = () => open_in_settings(...components);
|
|
250 |
const settings_opener = () => open_in_settings(...settings.payload);
|
|
252 | 251 |
view_payload_but.addEventListener("click", settings_opener); |
253 | 252 |
} else { |
254 | 253 |
payload_span.textContent = "none"; |
manifest.json | ||
---|---|---|
44 | 44 |
"page": "html/options.html", |
45 | 45 |
"open_in_tab": true |
46 | 46 |
}_CHROMIUM_UPDATE_URL_, |
47 |
"web_accessible_resources": [_CHROMIUM_KEY_ |
|
48 |
], |
|
47 |
"web_accessible_resources": ["dummy"], |
|
49 | 48 |
"background": { |
50 | 49 |
"persistent": true, |
51 | 50 |
"scripts": [_BGSCRIPTS_] |
Also available in: Unified diff
replace cookies with synchronous XmlHttpRequest as policy smuggling method.
Note: this breaks Mozilla port of Haketilo. Synchronous XmlHttpRequest doesn't work as well there. This will be fixed with dynamically-registered content scripts later.