diff options
Diffstat (limited to 'background')
-rw-r--r-- | background/cookie_filter.js | 80 | ||||
-rw-r--r-- | background/main.js | 120 | ||||
-rw-r--r-- | background/page_actions_server.js | 32 | ||||
-rw-r--r-- | background/policy_injector.js | 67 | ||||
-rw-r--r-- | background/stream_filter.js | 6 |
5 files changed, 115 insertions, 190 deletions
diff --git a/background/cookie_filter.js b/background/cookie_filter.js deleted file mode 100644 index e3d0c77..0000000 --- a/background/cookie_filter.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * This file is part of Haketilo. - * - * Function: Filtering request headers to remove haketilo cookies that might - * have slipped through. - * - * Copyright (C) 2021 Wojtek Kosior - * - * 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. - */ - -/* - * IMPORTS_START - * IMPORT extract_signed - * IMPORTS_END - */ - -function is_valid_haketilo_cookie(cookie) -{ - const match = /^haketilo-(\w*)=(.*)$/.exec(cookie); - if (!match) - return false; - - return !extract_signed(match.slice(1, 3)).fail; -} - -function remove_haketilo_cookies(header) -{ - if (header.name !== "Cookie") - return header; - - const cookies = header.value.split("; "); - const value = cookies.filter(c => !is_valid_haketilo_cookie(c)).join("; "); - - return value ? {name: "Cookie", value} : null; -} - -function filter_cookie_headers(headers) -{ - return headers.map(remove_haketilo_cookies).filter(h => h); -} - -/* - * EXPORTS_START - * EXPORT filter_cookie_headers - * EXPORTS_END - */ diff --git a/background/main.js b/background/main.js index 489b6b1..a4d3f0e 100644 --- a/background/main.js +++ b/background/main.js @@ -51,11 +51,10 @@ * IMPORT browser * IMPORT is_privileged_url * IMPORT query_best - * IMPORT gen_nonce * IMPORT inject_csp_headers * IMPORT apply_stream_filter - * IMPORT filter_cookie_headers * IMPORT is_chrome + * IMPORT is_mozilla * IMPORTS_END */ @@ -85,34 +84,53 @@ async function init_ext(install_details) browser.runtime.onInstalled.addListener(init_ext); +/* + * The function below implements a more practical interface for what it does by + * wrapping the old query_best() function. + */ +function decide_policy_for_url(storage, policy_observable, url) +{ + if (storage === undefined) + return {allow: false}; + + const settings = + {allow: policy_observable !== undefined && policy_observable.value}; + + const [pattern, queried_settings] = query_best(storage, url); + + if (queried_settings) { + settings.payload = queried_settings.components; + settings.allow = !!queried_settings.allow && !settings.payload; + settings.pattern = pattern; + } + + return settings; +} let storage; let policy_observable = {}; -function on_headers_received(details) +function sanitize_web_page(details) { const url = details.url; if (is_privileged_url(details.url)) return; - const [pattern, settings] = query_best(storage, details.url); - const has_payload = !!(settings && settings.components); - const allow = !has_payload && - !!(settings ? settings.allow : policy_observable.value); - const nonce = gen_nonce(); - const policy = {allow, url, nonce, has_payload}; + const policy = + decide_policy_for_url(storage, policy_observable, details.url); let headers = details.responseHeaders; + + headers = inject_csp_headers(headers, policy); + let skip = false; for (const header of headers) { if ((header.name.toLowerCase().trim() === "content-disposition" && /^\s*attachment\s*(;.*)$/i.test(header.value))) skip = true; } - - headers = inject_csp_headers(headers, policy); - skip = skip || (details.statusCode >= 300 && details.statusCode < 400); + if (!skip) { /* Check for API availability. */ if (browser.webRequest.filterResponseData) @@ -122,11 +140,49 @@ function on_headers_received(details) return {responseHeaders: headers}; } -function on_before_send_headers(details) +const request_url_regex = /^[^?]*\?url=(.*)$/; +const redirect_url_template = browser.runtime.getURL("dummy") + "?settings="; + +function synchronously_smuggle_policy(details) { - let headers = details.requestHeaders; - headers = filter_cookie_headers(headers); - return {requestHeaders: headers}; + /* + * 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}; + + console.debug(`Settings queried using XHR for '${details.url}'.`); + + let policy = {allow: false}; + + try { + /* + * 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); + 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}; + } + + policy = decide_policy_for_url(storage, policy_observable, queried_url); + } catch (e) { + 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.`); + } + + const encoded_policy = encodeURIComponent(JSON.stringify(policy)); + + return {redirectUrl: redirect_url_template + encoded_policy}; } const all_types = [ @@ -144,18 +200,40 @@ async function start_webRequest_operations() extra_opts.push("extraHeaders"); browser.webRequest.onHeadersReceived.addListener( - on_headers_received, + sanitize_web_page, {urls: ["<all_urls>"], types: ["main_frame", "sub_frame"]}, extra_opts.concat("responseHeaders") ); - browser.webRequest.onBeforeSendHeaders.addListener( - on_before_send_headers, - {urls: ["<all_urls>"], types: all_types}, - extra_opts.concat("requestHeaders") + const dummy_url_pattern = browser.runtime.getURL("dummy") + "?url=*"; + browser.webRequest.onBeforeRequest.addListener( + synchronously_smuggle_policy, + {urls: [dummy_url_pattern], types: ["xmlhttprequest"]}, + extra_opts ); policy_observable = await light_storage.observe_var("default_allow"); } start_webRequest_operations(); + +const code = `\ +console.warn("Hi, I'm Mr Dynamic!"); + +console.debug("let's see how window.haketilo_exports looks like now"); + +console.log("haketilo_exports", window.haketilo_exports); +` + +async function test_dynamic_content_scripts() +{ + browser.contentScripts.register({ + "js": [{code}], + "matches": ["<all_urls>"], + "allFrames": true, + "runAt": "document_start" +}); +} + +if (is_mozilla) + test_dynamic_content_scripts(); diff --git a/background/page_actions_server.js b/background/page_actions_server.js index 77183fd..bb4c34f 100644 --- a/background/page_actions_server.js +++ b/background/page_actions_server.js @@ -50,34 +50,12 @@ * IMPORT browser * IMPORT listen_for_connection * IMPORT sha256 - * IMPORT query_best * IMPORT make_ajax_request * IMPORTS_END */ var storage; var handler; -let policy_observable; - -function send_actions(url, port) -{ - const [pattern, queried_settings] = query_best(storage, url); - - const settings = {allow: policy_observable && policy_observable.value}; - Object.assign(settings, queried_settings); - if (settings.components) - settings.allow = false; - - const repos = storage.get_all(TYPE_PREFIX.REPO); - - port.postMessage(["settings", [pattern, settings, repos]]); - - const components = settings.components; - const processed_bags = new Set(); - - if (components !== undefined) - send_scripts([components], port, processed_bags); -} // TODO: parallelize script fetching async function send_scripts(components, port, processed_bags) @@ -150,9 +128,11 @@ async function fetch_remote_script(script_data) function handle_message(port, message, handler) { port.onMessage.removeListener(handler[0]); - let url = message.url; - console.log({url}); - send_actions(url, port); + console.debug(`Loading payload '${message.payload}'.`); + + const processed_bags = new Set(); + + send_scripts([message.payload], port, processed_bags); } function new_connection(port) @@ -168,8 +148,6 @@ async function start_page_actions_server() storage = await get_storage(); listen_for_connection(CONNECTION_TYPE.PAGE_ACTIONS, new_connection); - - policy_observable = await light_storage.observe_var("default_allow"); } /* diff --git a/background/policy_injector.js b/background/policy_injector.js index e04b4f8..787f1f0 100644 --- a/background/policy_injector.js +++ b/background/policy_injector.js @@ -45,77 +45,28 @@ /* * IMPORTS_START - * IMPORT sign_data - * IMPORT extract_signed * IMPORT make_csp_rule * IMPORT csp_header_regex + * Re-enable the import below once nonce stuff here is ready + * !mport gen_nonce * IMPORTS_END */ function inject_csp_headers(headers, policy) { let csp_headers; - let old_signature; - let haketilo_header; - for (const header of headers.filter(h => h.name === "x-haketilo")) { - /* x-haketilo header has format: <signature>_0_<data> */ - const match = /^([^_]+)_(0_.*)$/.exec(header.value); - if (!match) - continue; + if (policy.payload) { + headers = headers.filter(h => !csp_header_regex.test(h.name)); - const result = extract_signed(...match.slice(1, 3)); - if (result.fail) - continue; + // TODO: make CSP rules with nonces and facilitate passing them to + // content scripts via dynamic content script registration or + // synchronous XHRs - /* This should succeed - it's our self-produced valid JSON. */ - const old_data = JSON.parse(decodeURIComponent(result.data)); - - /* Confirmed- it's the originals, smuggled in! */ - csp_headers = old_data.csp_headers; - old_signature = old_data.policy_sig; - - haketilo_header = header; - break; + // policy.nonce = gen_nonce(); } - if (policy.has_payload) { - csp_headers = []; - const non_csp_headers = []; - const header_list = - h => csp_header_regex.test(h) ? csp_headers : non_csp_headers; - headers.forEach(h => header_list(h.name).push(h)); - headers = non_csp_headers; - } else { - headers.push(...csp_headers || []); - } - - if (!haketilo_header) { - haketilo_header = {name: "x-haketilo"}; - headers.push(haketilo_header); - } - - if (old_signature) - headers = headers.filter(h => h.value.search(old_signature) === -1); - - const policy_str = encodeURIComponent(JSON.stringify(policy)); - const signed_policy = sign_data(policy_str, new Date().getTime()); - const later_30sec = new Date(new Date().getTime() + 30000).toGMTString(); - headers.push({ - name: "Set-Cookie", - value: `haketilo-${signed_policy.join("=")}; Expires=${later_30sec};` - }); - - /* - * Smuggle in the signature and the original CSP headers for future use. - * These are signed with a time of 0, as it's not clear there is a limit on - * how long Firefox might retain headers in the cache. - */ - let haketilo_data = {csp_headers, policy_sig: signed_policy[0]}; - haketilo_data = encodeURIComponent(JSON.stringify(haketilo_data)); - haketilo_header.value = sign_data(haketilo_data, 0).join("_"); - - if (!policy.allow) { + if (!policy.allow && (policy.nonce || !policy.payload)) { headers.push({ name: "content-security-policy", value: make_csp_rule(policy) diff --git a/background/stream_filter.js b/background/stream_filter.js index 1f30f09..9d8e1e5 100644 --- a/background/stream_filter.js +++ b/background/stream_filter.js @@ -192,8 +192,7 @@ function filter_data(properties, event) * as harmless anyway). */ - const dummy_script = - `<script data-haketilo-deleteme="${properties.policy.nonce}" nonce="${properties.policy.nonce}">null</script>`; + const dummy_script = `<script>null</script>`; const doctype_decl = /^(\s*<!doctype[^<>"']*>)?/i.exec(decoded)[0]; decoded = doctype_decl + dummy_script + decoded.substring(doctype_decl.length); @@ -207,11 +206,10 @@ function filter_data(properties, event) function apply_stream_filter(details, headers, policy) { - if (!policy.has_payload) + if (!policy.payload) return headers; const properties = properties_from_headers(headers); - properties.policy = policy; properties.filter = browser.webRequest.filterResponseData(details.requestId); |