diff options
Diffstat (limited to 'background')
-rw-r--r-- | background/patterns_query_manager.js | 27 | ||||
-rw-r--r-- | background/policy_injector.js | 12 | ||||
-rw-r--r-- | background/webrequest.js | 189 |
3 files changed, 220 insertions, 8 deletions
diff --git a/background/patterns_query_manager.js b/background/patterns_query_manager.js index cb14cb1..e364668 100644 --- a/background/patterns_query_manager.js +++ b/background/patterns_query_manager.js @@ -45,13 +45,18 @@ #IMPORT common/patterns_query_tree.js AS pqt #IMPORT common/indexeddb.js AS haketilodb +#IF MOZILLA || MV3 #FROM common/browser.js IMPORT browser +#ENDIF + +let secret; const tree = pqt.make(); #EXPORT tree const current_mappings = new Map(); +#IF MOZILLA || MV3 let registered_script = null; let script_update_occuring = false; let script_update_needed; @@ -67,6 +72,7 @@ async function update_content_script() script_update_needed = false; const code = `\ +this.haketilo_secret = ${secret}; this.haketilo_pattern_tree = ${JSON.stringify(tree)}; if (this.haketilo_content_script_main) haketilo_content_script_main();`; @@ -89,36 +95,43 @@ if (this.haketilo_content_script_main) function register_mapping(mapping) { - for (const pattern in mapping.payloads) - pqt.register(tree, pattern, mapping.identifier, mapping); + for (const [pattern, resource] of Object.entries(mapping.payloads)) + pqt.register(tree, pattern, mapping.identifier, resource); current_mappings.set(mapping.identifier, mapping); } +#ENDIF function mapping_changed(change) { console.log('mapping changes!', arguments); - const old_version = current_mappings.get(change.identifier); + const old_version = current_mappings.get(change.key); if (old_version !== undefined) { for (const pattern in old_version.payloads) - pqt.deregister(tree, pattern, change.identifier); + pqt.deregister(tree, pattern, change.key); - current_mappings.delete(change.identifier); + current_mappings.delete(change.key); } if (change.new_val !== undefined) register_mapping(change.new_val); +#IF MOZILLA || MV3 script_update_needed = true; setTimeout(update_content_script, 0); +#ENDIF } -async function start() +async function start(secret_) { + secret = secret_; + const [tracking, initial_mappings] = - await haketilodb.track_mappings(mapping_changed); + await haketilodb.track.mappings(mapping_changed); initial_mappings.forEach(register_mapping); +#IF MOZILLA || MV3 script_update_needed = true; await update_content_script(); +#ENDIF } #EXPORT start diff --git a/background/policy_injector.js b/background/policy_injector.js index 2544e8e..b1fc733 100644 --- a/background/policy_injector.js +++ b/background/policy_injector.js @@ -43,13 +43,23 @@ * proprietary program, I am not going to enforce this in court. */ -#FROM common/misc.js IMPORT make_csp_rule, csp_header_regex +#FROM common/misc.js IMPORT csp_header_regex /* Re-enable the import below once nonce stuff here is ready */ #IF NEVER #FROM common/misc.js IMPORT gen_nonce #ENDIF +/* CSP rule that blocks scripts according to policy's needs. */ +function make_csp_rule(policy) +{ + let rule = "prefetch-src 'none'; script-src-attr 'none';"; + const script_src = policy.nonce !== undefined ? + `'nonce-${policy.nonce}'` : "'none'"; + rule += ` script-src ${script_src}; script-src-elem ${script_src};`; + return rule; +} + function inject_csp_headers(headers, policy) { let csp_headers; diff --git a/background/webrequest.js b/background/webrequest.js new file mode 100644 index 0000000..e32947a --- /dev/null +++ b/background/webrequest.js @@ -0,0 +1,189 @@ +/** + * This file is part of Haketilo. + * + * Function: Modify HTTP traffic usng webRequest API. + * + * Copyright (C) 2021 Wojtek Kosior <koszko@koszko.org> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * As additional permission under GNU GPL version 3 section 7, you + * may distribute forms of that code without the copy of the GNU + * GPL normally required by section 4, provided you include this + * license notice and, in case of non-source distribution, a URL + * through which recipients can access the Corresponding Source. + * If you modify file(s) with this exception, you may extend this + * exception to your version of the file(s), but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * As a special exception to the GPL, any HTML file which merely + * makes function calls to this code, and for that purpose + * includes it by reference shall be deemed a separate work for + * copyright law purposes. If you modify this code, you may extend + * this exception to your version of the code, but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * I, Wojtek Kosior, thereby promise not to sue for violation of this file's + * license. Although I request that you do not make use this code in a + * proprietary program, I am not going to enforce this in court. + */ + +#IMPORT common/indexeddb.js AS haketilodb +#IF MOZILLA +#IMPORT background/stream_filter.js +#ENDIF + +#FROM common/browser.js IMPORT browser +#FROM common/misc.js IMPORT is_privileged_url, csp_header_regex +#FROM common/policy.js IMPORT decide_policy + +#FROM background/patterns_query_manager.js IMPORT tree + +let secret; + +let default_allow = false; + +async function track_default_allow() +{ + const set_val = ch => default_allow = (ch.new_val || {}).value; + const [tracking, settings] = await haketilodb.track.settings(set_val); + for (const setting of settings) { + if (setting.name === "default_allow") + default_allow = setting.value; + } +} + +function on_headers_received(details) +{ + const url = details.url; + if (is_privileged_url(details.url)) + return; + + let headers = details.responseHeaders; + + const policy = decide_policy(tree, details.url, default_allow, secret); + if (policy.allow) + return; + + if (policy.payload) + headers = headers.filter(h => !csp_header_regex.test(h.name)); + + headers.push({name: "Content-Security-Policy", value: policy.csp}); + +#IF MOZILLA + let skip = false; + for (const header of headers) { + if (header.name.toLowerCase().trim() !== "content-disposition") + continue; + + if (/^\s*attachment\s*(;.*)$/i.test(header.value)) { + skip = true; + } else { + skip = false; + break; + } + } + skip = skip || (details.statusCode >= 300 && details.statusCode < 400); + + if (!skip) + headers = stream_filter.apply(details, headers, policy); +#ENDIF + + return {responseHeaders: headers}; +} + +#IF CHROMIUM && MV2 +const request_url_regex = /^[^?]*\?url=(.*)$/; +const redirect_url_template = browser.runtime.getURL("dummy") + "?settings="; + +function on_before_request(details) +{ + /* + * Content script will make a synchronous XmlHttpRequest to extension's + * `dummy` file to query settings for given URL. We smuggle that + * information in query parameter of the URL we redirect to. + * A risk of fingerprinting arises if a page with script execution allowed + * guesses the dummy file URL and makes an AJAX call to it. It is currently + * a problem in ManifestV2 Chromium-family port of Haketilo because Chromium + * uses predictable URLs for web-accessible resources. We plan to fix it in + * the future ManifestV3 port. + */ + if (details.type !== "xmlhttprequest") + return {cancel: true}; + +#IF DEBUG + console.debug(`Settings queried using XHR for '${details.url}'.`); +#ENDIF + + /* + * request_url should be of the following format: + * <url_for_extension's_dummy_file>?url=<valid_urlencoded_url> + */ + const match = request_url_regex.exec(details.url); + if (match) { + const queried_url = decodeURIComponent(match[1]); + + if (details.initiator && !queried_url.startsWith(details.initiator)) { + console.warn(`Blocked suspicious query of '${url}' by '${details.initiator}'. This might be the result of page fingerprinting the browser.`); + return {cancel: true}; + } + + const policy = decide_policy(tree, details.url, default_allow, secret); + if (!policy.error) { + const encoded_policy = encodeURIComponent(JSON.stringify(policy)); + return {redirectUrl: redirect_url_template + encoded_policy}; + } + } + + 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.`); + + return {cancel: true}; +} + +const all_types = [ + "main_frame", "sub_frame", "stylesheet", "script", "image", "font", + "object", "xmlhttprequest", "ping", "csp_report", "media", "websocket", + "other", "main_frame", "sub_frame" +]; +#ENDIF + +async function start(secret_) +{ + secret = secret_; + +#IF CHROMIUM + const extra_opts = ["blocking", "extraHeaders"]; +#ELSE + const extra_opts = ["blocking"]; +#ENDIF + + browser.webRequest.onHeadersReceived.addListener( + on_headers_received, + {urls: ["<all_urls>"], types: ["main_frame", "sub_frame"]}, + extra_opts.concat("responseHeaders") + ); + +#IF CHROMIUM && MV2 + browser.webRequest.onBeforeRequest.addListener( + on_before_request, + {urls: [browser.runtime.getURL("dummy") + "*"], types: all_types}, + extra_opts + ); +#ENDIF + + await track_default_allow(); +} +#EXPORT start |