aboutsummaryrefslogtreecommitdiff
/**
 * This file is part of Haketilo.
 *
 * Function: Modify HTTP traffic usng webRequest API.
 *
 * Copyright (C) 2021, 2022 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 of 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, \
                               sha256_async AS sha256
#FROM common/policy.js  IMPORT decide_policy

#FROM background/patterns_query_manager.js IMPORT tree, default_allow

let secret;

#IF MOZILLA
/*
 * Under Mozilla-based browsers, responses are cached together with headers as
 * they appear *after* modifications by Haketilo. This means Haketilo's CSP
 * script-blocking headers might be present in responses loaded from cache. In
 * the meantime the user might have changes Haketilo settings to instead allow
 * the scripts on the page in question. This causes a problem and creates the
 * need to somehow restore the response headers to the state in which they
 * arrived from the server.
 * To cope with this, Haketilo will inject some additional headers with private
 * data. Those will include a hard-to-guess value derived from extension's
 * internal ID. It is assumed the internal ID has a longer lifetime than cached
 * responses.
 */

const settings_page_url = browser.runtime.getURL("html/settings.html");
const header_prefix_prom = sha256(settings_page_url)
      .then(hash => `X-Haketilo-${hash}`);

/*
 * Mozilla, unlike Chrome, allows webRequest callbacks to return promises. Here
 * we leverage that to be able to use asynchronous sha256 computation.
 */
async function on_headers_received(details) {
#IF NEVER
} /* Help auto-indent in editors. */
#ENDIF
#ELSE
function on_headers_received(details) {
#ENDIF
    const url = details.url;
    if (is_privileged_url(details.url))
	return;

    let headers = details.responseHeaders;

#IF MOZILLA
    const prefix = await header_prefix_prom;

    /*
     * We assume that the original CSP headers of a response are always
     * preserved under names of the form:
     *     X-Haketilo-<some_secret>-<original_name>
     * In some cases the original response may contain no CSP headers. To still
     * be able to tell whether the headers we were provided were modified by
     * Haketilo in the past, all modifications are accompanied by addition of an
     * extra header with name:
     *     X-Haketilo-<some_secret>
     */

    const restore_old_headers = details.fromCache &&
	  !!headers.filter(h => h.name === prefix).length;

    if (restore_old_headers) {
	const restored_headers = [];

	for (const h of headers) {
	    if (csp_header_regex.test(h.name) || h.name === prefix)
		continue;

	    if (h.name.startsWith(prefix)) {
		restored_headers.push({
		    name:  h.name.substring(prefix.length + 1),
		    value: h.value
		});
	    } else {
		restored_headers.push(h);
	    }
	}

	headers = restored_headers;
    }
#ENDIF

    const policy =
	  decide_policy(tree, details.url, !!default_allow.value, secret);

    if (!policy.allow) {
#IF MOZILLA
	const to_append = [{name: prefix, value: ":)"}];

	for (const h of headers.filter(h => csp_header_regex.test(h.name))) {
	    if (!policy.payload)
		to_append.push(Object.assign({}, h));

	    h.name = `${prefix}-${h.name}`;
	}

	headers.push(...to_append);
#ELSE
	if (policy.payload)
	    headers = headers.filter(h => !csp_header_regex.test(h.name));
#ENDIF

	headers.push({name: "Content-Security-Policy", value: policy.csp});
    }

#IF MOZILLA
    /*
     * When page is meant to be viewed in the browser, use streamFilter to
     * inject a dummy <script> at the very beginning of it. This <script>
     * will cause extension's content scripts to run before page's first <meta>
     * tag is rendered so that they can prevent CSP rules inside <meta> tags
     * from blocking the payload we want to inject.
     */

    let use_stream_filter = !!policy.payload;
    if (use_stream_filter) {
	for (const header of headers) {
	    if (header.name.toLowerCase().trim() !== "content-disposition")
		continue;

	    if (/^\s*attachment\s*(;.*)$/i.test(header.value)) {
		use_stream_filter = false;
	    } else {
		use_stream_filter = true;
		break;
	    }
	}
    }
    use_stream_filter = use_stream_filter &&
	(details.statusCode < 300 || details.statusCode >= 400);

    if (use_stream_filter)
	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 (details.url.startsWith(redirect_url_template))
	return;

#IF DEBUG
    console.debug(`Haketilo: 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 && details.initiator !== "null" &&
	    !queried_url.startsWith(details.initiator)) {
	    console.warn(`Haketilo: Blocked suspicious query of '${queried_url}' by '${details.initiator}'. This might be the result of page fingerprinting the browser.`);
	    return {cancel: true};
	}

	const policy =
	      decide_policy(tree, queried_url, !!default_allow.value, secret);
	if (!policy.error) {
	    const encoded_policy = encodeURIComponent(JSON.stringify(policy));
	    return {redirectUrl: redirect_url_template + encoded_policy};
	}
    }

    console.warn(`Haketilo: Bad request! Expected ${browser.runtime.getURL("dummy")}?url=<valid_urlencoded_url>. Got ${details.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
}
#EXPORT start