summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorWojtek Kosior <koszko@koszko.org>2022-03-11 14:13:41 +0100
committerWojtek Kosior <koszko@koszko.org>2022-03-11 16:41:07 +0100
commit7f0b5ded1256355a8ec5ad7bfefcacfabb7ac97e (patch)
tree25b98571d924fd60b508c7e519f0aa5223d2e5ea
parentaa34ed466abc6ccb4aa577caf81a828d753233a0 (diff)
downloadbrowser-extension-7f0b5ded1256355a8ec5ad7bfefcacfabb7ac97e.tar.gz
browser-extension-7f0b5ded1256355a8ec5ad7bfefcacfabb7ac97e.zip
don't double-modify response headers retrieved from cache
-rw-r--r--background/webrequest.js136
-rw-r--r--test/haketilo_test/unit/test_webrequest.py120
2 files changed, 223 insertions, 33 deletions
diff --git a/background/webrequest.js b/background/webrequest.js
index a523772..5ec7b7f 100644
--- a/background/webrequest.js
+++ b/background/webrequest.js
@@ -3,7 +3,7 @@
*
* Function: Modify HTTP traffic usng webRequest API.
*
- * Copyright (C) 2021 Wojtek Kosior <koszko@koszko.org>
+ * 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
@@ -41,53 +41,145 @@
* proprietary program, I am not going to enforce this in court.
*/
-#IMPORT common/indexeddb.js AS haketilodb
+#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/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;
-function on_headers_received(details)
-{
+#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)
- return;
- if (policy.payload)
- headers = headers.filter(h => !csp_header_regex.test(h.name));
+ 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({name: "Content-Security-Policy", value: policy.csp});
+ 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
- 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;
+ /*
+ * 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;
+ }
}
}
- skip = skip || (details.statusCode >= 300 && details.statusCode < 400);
+ use_stream_filter = use_stream_filter &&
+ (details.statusCode < 300 || details.statusCode >= 400);
- if (!skip)
+ if (use_stream_filter)
headers = stream_filter.apply(details, headers, policy);
#ENDIF
diff --git a/test/haketilo_test/unit/test_webrequest.py b/test/haketilo_test/unit/test_webrequest.py
index fb24b3d..1244117 100644
--- a/test/haketilo_test/unit/test_webrequest.py
+++ b/test/haketilo_test/unit/test_webrequest.py
@@ -24,6 +24,10 @@ import pytest
from ..script_loader import load_script
from .utils import are_scripts_allowed
+allowed_url = 'https://site.with.scripts.allow.ed/'
+blocked_url = 'https://site.with.scripts.block.ed/'
+payload_url = 'https://site.with.paylo.ad/'
+
def webrequest_js():
return (load_script('background/webrequest.js',
'#IMPORT common/patterns_query_tree.js AS pqt') +
@@ -34,24 +38,118 @@ def webrequest_js():
default_allow = {name: "default_allow", value: true};
// Rule to block scripts.
- pqt.register(tree, "https://site.with.scripts.block.ed/***",
+ pqt.register(tree, "%(blocked)s***",
"~allow", 0);
// Rule to allow scripts, but overridden by payload assignment.
- pqt.register(tree, "https://site.with.paylo.ad/***", "~allow", 1);
- pqt.register(tree, "https://site.with.paylo.ad/***",
- "somemapping", {identifier: "someresource"});
+ pqt.register(tree, "%(payload)s***", "~allow", 1);
+ pqt.register(tree, "%(payload)s***", "somemapping",
+ {identifier: "someresource"});
// Mock stream_filter.
stream_filter.apply = (details, headers, policy) => headers;
+ ''' % {'blocked': blocked_url, 'payload': payload_url})
+
+def webrequest_js_start_called():
+ return webrequest_js() + ';\nstart("somesecret");'
+
+ext_url = 'moz-extension://49de6ce9-49fc-49e1-8102-7ef35286389c/html/settings.html'
+prefix = 'X-Haketilo-' + sha256(ext_url.encode()).digest().hex()
+
+# Prepare a list of headers as could be sent by a website.
+sample_csp_header = {
+ 'name': 'Content-Security-Policy',
+ 'value': "script-src 'self';"
+}
+sample_csp_header_idx = 7
+
+sample_headers = [
+ {'name': 'Content-Type', 'value': 'text/html;charset=utf-8'},
+ {'name': 'Content-Length', 'value': '61954'},
+ {'name': 'Content-Language', 'value': 'en'},
+ {'name': 'Expires', 'value': 'Mon, 12 Mar 2012 11:04...'},
+ {'name': 'Last-Modified', 'value': 'Fri, 26 Jul 2013 22:50...'},
+ {'name': 'Cache-Control', 'value': 'max-age=0, s-maxage=86...'},
+ {'name': 'Age', 'value': '224'},
+ {'name': 'Server', 'value': 'nginx/1.1.19'},
+ {'name': 'Date', 'value': 'Thu, 10 Mar 2022 12:09...'}
+]
+
+sample_headers.insert(sample_csp_header_idx, sample_csp_header)
+
+# Prepare a list of headers as would be crafted by Haketilo when there is a
+# payload to inject.
+nonce_source = f'somemapping:someresource:{payload_url}:somesecret'.encode()
+nonce = f'nonce-{sha256(nonce_source).digest().hex()}'
+
+payload_csp_header = {
+ 'name': f'Content-Security-Policy',
+ 'value': ("prefetch-src 'none'; script-src-attr 'none'; "
+ f"script-src '{nonce}'; script-src-elem '{nonce}';")
+}
+
+sample_payload_headers = [
+ *sample_headers,
+ {'name': prefix, 'value': ':)'},
+ payload_csp_header
+]
+
+sample_payload_headers[sample_csp_header_idx] = {
+ **sample_csp_header,
+ 'name': f'{prefix}-{sample_csp_header["name"]}',
+}
+
+# Prepare a list of headers as would be crafted by Haketilo when scripts are
+# blocked.
+sample_blocked_headers = [*sample_payload_headers]
+sample_blocked_headers.pop()
+sample_blocked_headers.append(sample_csp_header)
+sample_blocked_headers.append({
+ 'name': f'Content-Security-Policy',
+ 'value': ("prefetch-src 'none'; script-src-attr 'none'; "
+ f"script-src 'none'; script-src-elem 'none';")
+})
+
+@pytest.mark.get_page('https://gotmyowndoma.in')
+@pytest.mark.parametrize('params', [
+ (sample_headers, allowed_url),
+ (sample_blocked_headers, blocked_url),
+ (sample_payload_headers, payload_url),
+])
+def test_webrequest_on_headers_received(driver, execute_in_page, params):
+ """Unit-test the on_headers_received() function."""
+ headers_out, url = params
+
+ execute_in_page(
+ '''{
+ // Mock browser object.
+ const url = arguments[0];
+ this.browser = {runtime: {getURL: () => url}};
+ }''',
+ ext_url)
+
+ execute_in_page(webrequest_js())
+
+ execute_in_page('secret = "somesecret";')
+
+ for headers_in in [
+ sample_headers,
+ sample_blocked_headers,
+ sample_payload_headers
+ ]:
+ details = {'url': url, 'responseHeaders': headers_in, 'fromCache': True}
+ res = execute_in_page('returnval(on_headers_received(arguments[0]));',
+ details)
- // Mock secret and start webrequest operations.
- start("somesecret");
- ''')
+ assert res == {'responseHeaders': headers_out}
-@pytest.mark.ext_data({'background_script': webrequest_js})
+@pytest.mark.ext_data({'background_script': webrequest_js_start_called})
@pytest.mark.usefixtures('webextension')
-def test_on_headers_received(driver, execute_in_page):
+def test_webrequest_real_pages(driver, execute_in_page):
+ """
+ Test webRequest-based header modifications by loading actual pages and
+ attempting to run scripts within them.
+ """
for attempt in range(10):
driver.get('https://site.with.scripts.block.ed/')
@@ -59,10 +157,10 @@ def test_on_headers_received(driver, execute_in_page):
break
assert attempt != 9
- driver.get('https://site.with.scripts.allow.ed/')
+ driver.get(allowed_url)
assert are_scripts_allowed(driver)
- driver.get('https://site.with.paylo.ad/')
+ driver.get(payload_url)
assert not are_scripts_allowed(driver)
source = 'somemapping:someresource:https://site.with.paylo.ad/index.html:somesecret'
assert are_scripts_allowed(driver, sha256(source.encode()).digest().hex())