aboutsummaryrefslogtreecommitdiff
/**
 * This file is part of Haketilo.
 *
 * Function: Main background script.
 *
 * 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 TYPE_PREFIX
 * IMPORT get_storage
 * IMPORT light_storage
 * IMPORT start_storage_server
 * IMPORT start_page_actions_server
 * IMPORT browser
 * IMPORT is_privileged_url
 * IMPORT query_best
 * IMPORT inject_csp_headers
 * IMPORT apply_stream_filter
 * IMPORT is_chrome
 * IMPORT is_mozilla
 * IMPORTS_END
 */

start_storage_server();
start_page_actions_server();

async function init_ext(install_details)
{
    if (install_details.reason != "install")
	return;

    let storage = await get_storage();

    await storage.clear();

    /*
     * Below we add sample settings to the extension.
     */

    for (let setting of // The next line is replaced with the contents of /default_settings.json by the build script
        `DEFAULT SETTINGS`
    ) {
	let [key, value] = Object.entries(setting)[0];
	storage.set(key[0], key.substring(1), value);
    }
}

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 sanitize_web_page(details)
{
    const url = details.url;
    if (is_privileged_url(details.url))
	return;

    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;
    }
    skip = skip || (details.statusCode >= 300 && details.statusCode < 400);

    if (!skip) {
	/* Check for API availability. */
	if (browser.webRequest.filterResponseData)
	    headers = apply_stream_filter(details, headers, policy);
    }

    return {responseHeaders: headers};
}

const request_url_regex = /^[^?]*\?url=(.*)$/;
const redirect_url_template = browser.runtime.getURL("dummy") + "?settings=";

function synchronously_smuggle_policy(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};

    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 = [
    "main_frame", "sub_frame", "stylesheet", "script", "image", "font",
    "object", "xmlhttprequest", "ping", "csp_report", "media", "websocket",
    "other", "main_frame", "sub_frame"
];

async function start_webRequest_operations()
{
    storage = await get_storage();

    const extra_opts = ["blocking"];
    if (is_chrome)
	extra_opts.push("extraHeaders");

    browser.webRequest.onHeadersReceived.addListener(
	sanitize_web_page,
	{urls: ["<all_urls>"], types: ["main_frame", "sub_frame"]},
	extra_opts.concat("responseHeaders")
    );

    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.killtheweb looks like now");

console.log("killtheweb", window.killtheweb);
`

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();