Revision 96068ada
Added by koszko almost 2 years ago
| background/cookie_filter.js | ||
|---|---|---|
| 1 |
/** |
|
| 2 |
* This file is part of Haketilo. |
|
| 3 |
* |
|
| 4 |
* Function: Filtering request headers to remove haketilo cookies that might |
|
| 5 |
* have slipped through. |
|
| 6 |
* |
|
| 7 |
* Copyright (C) 2021 Wojtek Kosior |
|
| 8 |
* Redistribution terms are gathered in the `copyright' file. |
|
| 9 |
*/ |
|
| 10 |
|
|
| 11 |
/* |
|
| 12 |
* IMPORTS_START |
|
| 13 |
* IMPORT extract_signed |
|
| 14 |
* IMPORTS_END |
|
| 15 |
*/ |
|
| 16 |
|
|
| 17 |
function is_valid_haketilo_cookie(cookie) |
|
| 18 |
{
|
|
| 19 |
const match = /^haketilo-(\w*)=(.*)$/.exec(cookie); |
|
| 20 |
if (!match) |
|
| 21 |
return false; |
|
| 22 |
|
|
| 23 |
return !extract_signed(match.slice(1, 3)).fail; |
|
| 24 |
} |
|
| 25 |
|
|
| 26 |
function remove_haketilo_cookies(header) |
|
| 27 |
{
|
|
| 28 |
if (header.name !== "Cookie") |
|
| 29 |
return header; |
|
| 30 |
|
|
| 31 |
const cookies = header.value.split("; ");
|
|
| 32 |
const value = cookies.filter(c => !is_valid_haketilo_cookie(c)).join("; ");
|
|
| 33 |
|
|
| 34 |
return value ? {name: "Cookie", value} : null;
|
|
| 35 |
} |
|
| 36 |
|
|
| 37 |
function filter_cookie_headers(headers) |
|
| 38 |
{
|
|
| 39 |
return headers.map(remove_haketilo_cookies).filter(h => h); |
|
| 40 |
} |
|
| 41 |
|
|
| 42 |
/* |
|
| 43 |
* EXPORTS_START |
|
| 44 |
* EXPORT filter_cookie_headers |
|
| 45 |
* EXPORTS_END |
|
| 46 |
*/ |
|
| background/main.js | ||
|---|---|---|
| 17 | 17 |
* IMPORT browser |
| 18 | 18 |
* IMPORT is_privileged_url |
| 19 | 19 |
* IMPORT query_best |
| 20 |
* IMPORT gen_nonce |
|
| 21 | 20 |
* IMPORT inject_csp_headers |
| 22 | 21 |
* IMPORT apply_stream_filter |
| 23 |
* IMPORT filter_cookie_headers |
|
| 24 | 22 |
* IMPORT is_chrome |
| 23 |
* IMPORT is_mozilla |
|
| 25 | 24 |
* IMPORTS_END |
| 26 | 25 |
*/ |
| 27 | 26 |
|
| ... | ... | |
| 51 | 50 |
|
| 52 | 51 |
browser.runtime.onInstalled.addListener(init_ext); |
| 53 | 52 |
|
| 53 |
/* |
|
| 54 |
* The function below implements a more practical interface for what it does by |
|
| 55 |
* wrapping the old query_best() function. |
|
| 56 |
*/ |
|
| 57 |
function decide_policy_for_url(storage, policy_observable, url) |
|
| 58 |
{
|
|
| 59 |
if (storage === undefined) |
|
| 60 |
return {allow: false};
|
|
| 61 |
|
|
| 62 |
const settings = |
|
| 63 |
{allow: policy_observable !== undefined && policy_observable.value};
|
|
| 64 |
|
|
| 65 |
const [pattern, queried_settings] = query_best(storage, url); |
|
| 66 |
|
|
| 67 |
if (queried_settings) {
|
|
| 68 |
settings.payload = queried_settings.components; |
|
| 69 |
settings.allow = !!queried_settings.allow && !settings.payload; |
|
| 70 |
settings.pattern = pattern; |
|
| 71 |
} |
|
| 72 |
|
|
| 73 |
return settings; |
|
| 74 |
} |
|
| 54 | 75 |
|
| 55 | 76 |
let storage; |
| 56 | 77 |
let policy_observable = {};
|
| 57 | 78 |
|
| 58 |
function on_headers_received(details)
|
|
| 79 |
function sanitize_web_page(details)
|
|
| 59 | 80 |
{
|
| 60 | 81 |
const url = details.url; |
| 61 | 82 |
if (is_privileged_url(details.url)) |
| 62 | 83 |
return; |
| 63 | 84 |
|
| 64 |
const [pattern, settings] = query_best(storage, details.url); |
|
| 65 |
const has_payload = !!(settings && settings.components); |
|
| 66 |
const allow = !has_payload && |
|
| 67 |
!!(settings ? settings.allow : policy_observable.value); |
|
| 68 |
const nonce = gen_nonce(); |
|
| 69 |
const policy = {allow, url, nonce, has_payload};
|
|
| 85 |
const policy = |
|
| 86 |
decide_policy_for_url(storage, policy_observable, details.url); |
|
| 70 | 87 |
|
| 71 | 88 |
let headers = details.responseHeaders; |
| 89 |
|
|
| 90 |
headers = inject_csp_headers(headers, policy); |
|
| 91 |
|
|
| 72 | 92 |
let skip = false; |
| 73 | 93 |
for (const header of headers) {
|
| 74 | 94 |
if ((header.name.toLowerCase().trim() === "content-disposition" && |
| 75 | 95 |
/^\s*attachment\s*(;.*)$/i.test(header.value))) |
| 76 | 96 |
skip = true; |
| 77 | 97 |
} |
| 78 |
|
|
| 79 |
headers = inject_csp_headers(headers, policy); |
|
| 80 |
|
|
| 81 | 98 |
skip = skip || (details.statusCode >= 300 && details.statusCode < 400); |
| 99 |
|
|
| 82 | 100 |
if (!skip) {
|
| 83 | 101 |
/* Check for API availability. */ |
| 84 | 102 |
if (browser.webRequest.filterResponseData) |
| ... | ... | |
| 88 | 106 |
return {responseHeaders: headers};
|
| 89 | 107 |
} |
| 90 | 108 |
|
| 91 |
function on_before_send_headers(details) |
|
| 109 |
const request_url_regex = /^[^?]*\?url=(.*)$/; |
|
| 110 |
const redirect_url_template = browser.runtime.getURL("dummy") + "?settings=";
|
|
| 111 |
|
|
| 112 |
function synchronously_smuggle_policy(details) |
|
| 92 | 113 |
{
|
| 93 |
let headers = details.requestHeaders; |
|
| 94 |
headers = filter_cookie_headers(headers); |
|
| 95 |
return {requestHeaders: headers};
|
|
| 114 |
/* |
|
| 115 |
* Content script will make a synchronous XmlHttpRequest to extension's |
|
| 116 |
* `dummy` file to query settings for given URL. We smuggle that |
|
| 117 |
* information in query parameter of the URL we redirect to. |
|
| 118 |
* A risk of fingerprinting arises if a page with script execution allowed |
|
| 119 |
* guesses the dummy file URL and makes an AJAX call to it. It is currently |
|
| 120 |
* a problem in ManifestV2 Chromium-family port of Haketilo because Chromium |
|
| 121 |
* uses predictable URLs for web-accessible resources. We plan to fix it in |
|
| 122 |
* the future ManifestV3 port. |
|
| 123 |
*/ |
|
| 124 |
if (details.type !== "xmlhttprequest") |
|
| 125 |
return {cancel: true};
|
|
| 126 |
|
|
| 127 |
console.debug(`Settings queried using XHR for '${details.url}'.`);
|
|
| 128 |
|
|
| 129 |
let policy = {allow: false};
|
|
| 130 |
|
|
| 131 |
try {
|
|
| 132 |
/* |
|
| 133 |
* request_url should be of the following format: |
|
| 134 |
* <url_for_extension's_dummy_file>?url=<valid_urlencoded_url> |
|
| 135 |
*/ |
|
| 136 |
const match = request_url_regex.exec(details.url); |
|
| 137 |
const queried_url = decodeURIComponent(match[1]); |
|
| 138 |
|
|
| 139 |
if (details.initiator && !queried_url.startsWith(details.initiator)) {
|
|
| 140 |
console.warn(`Blocked suspicious query of '${url}' by '${details.initiator}'. This might be the result of page fingerprinting the browser.`);
|
|
| 141 |
return {cancel: true};
|
|
| 142 |
} |
|
| 143 |
|
|
| 144 |
policy = decide_policy_for_url(storage, policy_observable, queried_url); |
|
| 145 |
} catch (e) {
|
|
| 146 |
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.`);
|
|
| 147 |
} |
|
| 148 |
|
|
| 149 |
const encoded_policy = encodeURIComponent(JSON.stringify(policy)); |
|
| 150 |
|
|
| 151 |
return {redirectUrl: redirect_url_template + encoded_policy};
|
|
| 96 | 152 |
} |
| 97 | 153 |
|
| 98 | 154 |
const all_types = [ |
| ... | ... | |
| 110 | 166 |
extra_opts.push("extraHeaders");
|
| 111 | 167 |
|
| 112 | 168 |
browser.webRequest.onHeadersReceived.addListener( |
| 113 |
on_headers_received,
|
|
| 169 |
sanitize_web_page,
|
|
| 114 | 170 |
{urls: ["<all_urls>"], types: ["main_frame", "sub_frame"]},
|
| 115 | 171 |
extra_opts.concat("responseHeaders")
|
| 116 | 172 |
); |
| 117 | 173 |
|
| 118 |
browser.webRequest.onBeforeSendHeaders.addListener( |
|
| 119 |
on_before_send_headers, |
|
| 120 |
{urls: ["<all_urls>"], types: all_types},
|
|
| 121 |
extra_opts.concat("requestHeaders")
|
|
| 174 |
const dummy_url_pattern = browser.runtime.getURL("dummy") + "?url=*";
|
|
| 175 |
browser.webRequest.onBeforeRequest.addListener( |
|
| 176 |
synchronously_smuggle_policy, |
|
| 177 |
{urls: [dummy_url_pattern], types: ["xmlhttprequest"]},
|
|
| 178 |
extra_opts |
|
| 122 | 179 |
); |
| 123 | 180 |
|
| 124 | 181 |
policy_observable = await light_storage.observe_var("default_allow");
|
| 125 | 182 |
} |
| 126 | 183 |
|
| 127 | 184 |
start_webRequest_operations(); |
| 185 |
|
|
| 186 |
const code = `\ |
|
| 187 |
console.warn("Hi, I'm Mr Dynamic!");
|
|
| 188 |
|
|
| 189 |
console.debug("let's see how window.killtheweb looks like now");
|
|
| 190 |
|
|
| 191 |
console.log("killtheweb", window.killtheweb);
|
|
| 192 |
` |
|
| 193 |
|
|
| 194 |
async function test_dynamic_content_scripts() |
|
| 195 |
{
|
|
| 196 |
browser.contentScripts.register({
|
|
| 197 |
"js": [{code}],
|
|
| 198 |
"matches": ["<all_urls>"], |
|
| 199 |
"allFrames": true, |
|
| 200 |
"runAt": "document_start" |
|
| 201 |
}); |
|
| 202 |
} |
|
| 203 |
|
|
| 204 |
if (is_mozilla) |
|
| 205 |
test_dynamic_content_scripts(); |
|
| background/page_actions_server.js | ||
|---|---|---|
| 16 | 16 |
* IMPORT browser |
| 17 | 17 |
* IMPORT listen_for_connection |
| 18 | 18 |
* IMPORT sha256 |
| 19 |
* IMPORT query_best |
|
| 20 | 19 |
* IMPORT make_ajax_request |
| 21 | 20 |
* IMPORTS_END |
| 22 | 21 |
*/ |
| 23 | 22 |
|
| 24 | 23 |
var storage; |
| 25 | 24 |
var handler; |
| 26 |
let policy_observable; |
|
| 27 |
|
|
| 28 |
function send_actions(url, port) |
|
| 29 |
{
|
|
| 30 |
const [pattern, queried_settings] = query_best(storage, url); |
|
| 31 |
|
|
| 32 |
const settings = {allow: policy_observable && policy_observable.value};
|
|
| 33 |
Object.assign(settings, queried_settings); |
|
| 34 |
if (settings.components) |
|
| 35 |
settings.allow = false; |
|
| 36 |
|
|
| 37 |
const repos = storage.get_all(TYPE_PREFIX.REPO); |
|
| 38 |
|
|
| 39 |
port.postMessage(["settings", [pattern, settings, repos]]); |
|
| 40 |
|
|
| 41 |
const components = settings.components; |
|
| 42 |
const processed_bags = new Set(); |
|
| 43 |
|
|
| 44 |
if (components !== undefined) |
|
| 45 |
send_scripts([components], port, processed_bags); |
|
| 46 |
} |
|
| 47 | 25 |
|
| 48 | 26 |
// TODO: parallelize script fetching |
| 49 | 27 |
async function send_scripts(components, port, processed_bags) |
| ... | ... | |
| 116 | 94 |
function handle_message(port, message, handler) |
| 117 | 95 |
{
|
| 118 | 96 |
port.onMessage.removeListener(handler[0]); |
| 119 |
let url = message.url; |
|
| 120 |
console.log({url});
|
|
| 121 |
send_actions(url, port); |
|
| 97 |
console.debug(`Loading payload '${message.payload}'.`);
|
|
| 98 |
|
|
| 99 |
const processed_bags = new Set(); |
|
| 100 |
|
|
| 101 |
send_scripts([message.payload], port, processed_bags); |
|
| 122 | 102 |
} |
| 123 | 103 |
|
| 124 | 104 |
function new_connection(port) |
| ... | ... | |
| 134 | 114 |
storage = await get_storage(); |
| 135 | 115 |
|
| 136 | 116 |
listen_for_connection(CONNECTION_TYPE.PAGE_ACTIONS, new_connection); |
| 137 |
|
|
| 138 |
policy_observable = await light_storage.observe_var("default_allow");
|
|
| 139 | 117 |
} |
| 140 | 118 |
|
| 141 | 119 |
/* |
| background/policy_injector.js | ||
|---|---|---|
| 10 | 10 |
|
| 11 | 11 |
/* |
| 12 | 12 |
* IMPORTS_START |
| 13 |
* IMPORT sign_data |
|
| 14 |
* IMPORT extract_signed |
|
| 15 | 13 |
* IMPORT make_csp_rule |
| 16 | 14 |
* IMPORT csp_header_regex |
| 15 |
* Re-enable the import below once nonce stuff here is ready |
|
| 16 |
* !mport gen_nonce |
|
| 17 | 17 |
* IMPORTS_END |
| 18 | 18 |
*/ |
| 19 | 19 |
|
| 20 | 20 |
function inject_csp_headers(headers, policy) |
| 21 | 21 |
{
|
| 22 | 22 |
let csp_headers; |
| 23 |
let old_signature; |
|
| 24 |
let haketilo_header; |
|
| 25 | 23 |
|
| 26 |
for (const header of headers.filter(h => h.name === "x-haketilo")) {
|
|
| 27 |
/* x-haketilo header has format: <signature>_0_<data> */ |
|
| 28 |
const match = /^([^_]+)_(0_.*)$/.exec(header.value); |
|
| 29 |
if (!match) |
|
| 30 |
continue; |
|
| 24 |
if (policy.payload) {
|
|
| 25 |
headers = headers.filter(h => !csp_header_regex.test(h.name)); |
|
| 31 | 26 |
|
| 32 |
const result = extract_signed(...match.slice(1, 3));
|
|
| 33 |
if (result.fail)
|
|
| 34 |
continue;
|
|
| 27 |
// TODO: make CSP rules with nonces and facilitate passing them to
|
|
| 28 |
// content scripts via dynamic content script registration or
|
|
| 29 |
// synchronous XHRs
|
|
| 35 | 30 |
|
| 36 |
/* This should succeed - it's our self-produced valid JSON. */ |
|
| 37 |
const old_data = JSON.parse(decodeURIComponent(result.data)); |
|
| 38 |
|
|
| 39 |
/* Confirmed- it's the originals, smuggled in! */ |
|
| 40 |
csp_headers = old_data.csp_headers; |
|
| 41 |
old_signature = old_data.policy_sig; |
|
| 42 |
|
|
| 43 |
haketilo_header = header; |
|
| 44 |
break; |
|
| 31 |
// policy.nonce = gen_nonce(); |
|
| 45 | 32 |
} |
| 46 | 33 |
|
| 47 |
if (policy.has_payload) {
|
|
| 48 |
csp_headers = []; |
|
| 49 |
const non_csp_headers = []; |
|
| 50 |
const header_list = |
|
| 51 |
h => csp_header_regex.test(h) ? csp_headers : non_csp_headers; |
|
| 52 |
headers.forEach(h => header_list(h.name).push(h)); |
|
| 53 |
headers = non_csp_headers; |
|
| 54 |
} else {
|
|
| 55 |
headers.push(...csp_headers || []); |
|
| 56 |
} |
|
| 57 |
|
|
| 58 |
if (!haketilo_header) {
|
|
| 59 |
haketilo_header = {name: "x-haketilo"};
|
|
| 60 |
headers.push(haketilo_header); |
|
| 61 |
} |
|
| 62 |
|
|
| 63 |
if (old_signature) |
|
| 64 |
headers = headers.filter(h => h.value.search(old_signature) === -1); |
|
| 65 |
|
|
| 66 |
const policy_str = encodeURIComponent(JSON.stringify(policy)); |
|
| 67 |
const signed_policy = sign_data(policy_str, new Date().getTime()); |
|
| 68 |
const later_30sec = new Date(new Date().getTime() + 30000).toGMTString(); |
|
| 69 |
headers.push({
|
|
| 70 |
name: "Set-Cookie", |
|
| 71 |
value: `haketilo-${signed_policy.join("=")}; Expires=${later_30sec};`
|
|
| 72 |
}); |
|
| 73 |
|
|
| 74 |
/* |
|
| 75 |
* Smuggle in the signature and the original CSP headers for future use. |
|
| 76 |
* These are signed with a time of 0, as it's not clear there is a limit on |
|
| 77 |
* how long Firefox might retain headers in the cache. |
|
| 78 |
*/ |
|
| 79 |
let haketilo_data = {csp_headers, policy_sig: signed_policy[0]};
|
|
| 80 |
haketilo_data = encodeURIComponent(JSON.stringify(haketilo_data)); |
|
| 81 |
haketilo_header.value = sign_data(haketilo_data, 0).join("_");
|
|
| 82 |
|
|
| 83 |
if (!policy.allow) {
|
|
| 34 |
if (!policy.allow && (policy.nonce || !policy.payload)) {
|
|
| 84 | 35 |
headers.push({
|
| 85 | 36 |
name: "content-security-policy", |
| 86 | 37 |
value: make_csp_rule(policy) |
| background/stream_filter.js | ||
|---|---|---|
| 174 | 174 |
* as harmless anyway). |
| 175 | 175 |
*/ |
| 176 | 176 |
|
| 177 |
const dummy_script = |
|
| 178 |
`<script data-haketilo-deleteme="${properties.policy.nonce}" nonce="${properties.policy.nonce}">null</script>`;
|
|
| 177 |
const dummy_script = `<script>null</script>`; |
|
| 179 | 178 |
const doctype_decl = /^(\s*<!doctype[^<>"']*>)?/i.exec(decoded)[0]; |
| 180 | 179 |
decoded = doctype_decl + dummy_script + |
| 181 | 180 |
decoded.substring(doctype_decl.length); |
| ... | ... | |
| 189 | 188 |
|
| 190 | 189 |
function apply_stream_filter(details, headers, policy) |
| 191 | 190 |
{
|
| 192 |
if (!policy.has_payload)
|
|
| 191 |
if (!policy.payload) |
|
| 193 | 192 |
return headers; |
| 194 | 193 |
|
| 195 | 194 |
const properties = properties_from_headers(headers); |
| 196 |
properties.policy = policy; |
|
| 197 | 195 |
|
| 198 | 196 |
properties.filter = |
| 199 | 197 |
browser.webRequest.filterResponseData(details.requestId); |
| build.sh | ||
|---|---|---|
| 180 | 180 |
mkdir -p "$BUILDDIR"/$DIR |
| 181 | 181 |
done |
| 182 | 182 |
|
| 183 |
CHROMIUM_KEY='' |
|
| 184 | 183 |
CHROMIUM_UPDATE_URL='' |
| 185 | 184 |
GECKO_APPLICATIONS='' |
| 186 | 185 |
|
| ... | ... | |
| 189 | 188 |
fi |
| 190 | 189 |
|
| 191 | 190 |
if [ "$BROWSER" = "chromium" ]; then |
| 192 |
CHROMIUM_KEY="$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64)" |
|
| 193 |
CHROMIUM_KEY=$(echo chromium-key-dummy-file-$CHROMIUM_KEY | tr / -) |
|
| 194 |
touch "$BUILDDIR"/$CHROMIUM_KEY |
|
| 195 |
|
|
| 196 | 191 |
CHROMIUM_UPDATE_URL="$UPDATE_URL" |
| 197 |
|
|
| 198 |
CHROMIUM_KEY="\n\ |
|
| 199 |
// WARNING!!!\n\ |
|
| 200 |
// EACH USER SHOULD REPLACE DUMMY FILE's VALUE WITH A UNIQUE ONE!!!\n\ |
|
| 201 |
// OTHERWISE, SECURITY CAN BE TRIVIALLY COMPROMISED!\n\ |
|
| 202 |
// Only relevant to users of chrome-based browsers.\n\ |
|
| 203 |
// Users of Firefox forks are safe.\n\ |
|
| 204 |
\"$CHROMIUM_KEY\"\ |
|
| 205 |
" |
|
| 206 | 192 |
else |
| 207 | 193 |
GECKO_APPLICATIONS="\n\ |
| 208 | 194 |
\"applications\": {\n\
|
| ... | ... | |
| 215 | 201 |
|
| 216 | 202 |
sed "\ |
| 217 | 203 |
s^_GECKO_APPLICATIONS_^$GECKO_APPLICATIONS^ |
| 218 |
s^_CHROMIUM_KEY_^$CHROMIUM_KEY^ |
|
| 219 | 204 |
s^_CHROMIUM_UPDATE_URL_^$CHROMIUM_UPDATE_URL^ |
| 220 | 205 |
s^_BGSCRIPTS_^$BGSCRIPTS^ |
| 221 | 206 |
s^_CONTENTSCRIPTS_^$CONTENTSCRIPTS^" \ |
| ... | ... | |
| 279 | 264 |
fi |
| 280 | 265 |
|
| 281 | 266 |
cp -r copyright licenses/ "$BUILDDIR" |
| 267 |
cp dummy "$BUILDDIR" |
|
| 282 | 268 |
cp html/*.css "$BUILDDIR"/html |
| 283 | 269 |
mkdir "$BUILDDIR"/icons |
| 284 | 270 |
cp icons/*.png "$BUILDDIR"/icons |
| common/misc.js | ||
|---|---|---|
| 49 | 49 |
function make_csp_rule(policy) |
| 50 | 50 |
{
|
| 51 | 51 |
let rule = "prefetch-src 'none'; script-src-attr 'none';"; |
| 52 |
const script_src = policy.has_payload ?
|
|
| 52 |
const script_src = policy.nonce !== undefined ?
|
|
| 53 | 53 |
`'nonce-${policy.nonce}'` : "'none'";
|
| 54 | 54 |
rule += ` script-src ${script_src}; script-src-elem ${script_src};`;
|
| 55 | 55 |
return rule; |
| common/signing.js | ||
|---|---|---|
| 1 |
/** |
|
| 2 |
* This file is part of Haketilo. |
|
| 3 |
* |
|
| 4 |
* Functions: Operations related to "signing" of data. |
|
| 5 |
* |
|
| 6 |
* Copyright (C) 2021 Wojtek Kosior |
|
| 7 |
* Redistribution terms are gathered in the `copyright' file. |
|
| 8 |
*/ |
|
| 9 |
|
|
| 10 |
/* |
|
| 11 |
* IMPORTS_START |
|
| 12 |
* IMPORT sha256 |
|
| 13 |
* IMPORT browser |
|
| 14 |
* IMPORT is_mozilla |
|
| 15 |
* IMPORTS_END |
|
| 16 |
*/ |
|
| 17 |
|
|
| 18 |
/* |
|
| 19 |
* In order to make certain data synchronously accessible in certain contexts, |
|
| 20 |
* Haketilo smuggles it in string form in places like cookies, URLs and headers. |
|
| 21 |
* When using the smuggled data, we first need to make sure it isn't spoofed. |
|
| 22 |
* For that, we use this pseudo-signing mechanism. |
|
| 23 |
* |
|
| 24 |
* Despite what name suggests, no assymetric cryptography is involved, as it |
|
| 25 |
* would bring no additional benefits and would incur bigger performance |
|
| 26 |
* overhead. Instead, we hash the string data together with some secret value |
|
| 27 |
* that is supposed to be known only by this browser instance. Resulting hash |
|
| 28 |
* sum plays the role of the signature. In the hash we also include current |
|
| 29 |
* time. This way, even if signed data leaks (which shouldn't happen in the |
|
| 30 |
* first place), an attacker won't be able to re-use it indefinitely. |
|
| 31 |
* |
|
| 32 |
* The secret shared between execution contexts has to be available |
|
| 33 |
* synchronously. Under Mozilla, this is the extension's per-session id. Under |
|
| 34 |
* Chromium, this is a dummy web-accessible-resource name that resides in the |
|
| 35 |
* manifest and is supposed to be constructed by each user using a unique value |
|
| 36 |
* (this is done automatically by `build.sh'). |
|
| 37 |
*/ |
|
| 38 |
|
|
| 39 |
function get_secret() |
|
| 40 |
{
|
|
| 41 |
if (is_mozilla) |
|
| 42 |
return browser.runtime.getURL("dummy");
|
|
| 43 |
|
|
| 44 |
return chrome.runtime.getManifest().web_accessible_resources |
|
| 45 |
.map(r => /^chromium-key-dummy-file-(.*)/.exec(r)).filter(r => r)[0][1]; |
|
| 46 |
} |
|
| 47 |
|
|
| 48 |
function extract_signed(signature, signed_data) |
|
| 49 |
{
|
|
| 50 |
const match = /^([1-9][0-9]{12}|0)_(.*)$/.exec(signed_data);
|
|
| 51 |
if (!match) |
|
| 52 |
return {fail: "bad format"};
|
|
| 53 |
|
|
| 54 |
const result = {time: parseInt(match[1]), data: match[2]};
|
|
| 55 |
if (sign_data(result.data, result.time)[0] !== signature) |
|
| 56 |
result.fail = "bad signature"; |
|
| 57 |
|
|
| 58 |
return result; |
|
| 59 |
} |
|
| 60 |
|
|
| 61 |
/* |
|
| 62 |
* Sign a given string for a given time. Time should be either 0 or in the range |
|
| 63 |
* 10^12 <= time < 10^13. |
|
| 64 |
*/ |
|
| 65 |
function sign_data(data, time) {
|
|
| 66 |
return [sha256(get_secret() + time + data), `${time}_${data}`];
|
|
| 67 |
} |
|
| 68 |
|
|
| 69 |
/* |
|
| 70 |
* EXPORTS_START |
|
| 71 |
* EXPORT extract_signed |
|
| 72 |
* EXPORT sign_data |
|
| 73 |
* EXPORTS_END |
|
| 74 |
*/ |
|
| content/activity_info_server.js | ||
|---|---|---|
| 42 | 42 |
|
| 43 | 43 |
function report_settings(settings) |
| 44 | 44 |
{
|
| 45 |
report_activity("settings", settings);
|
|
| 45 |
const settings_clone = {};
|
|
| 46 |
Object.assign(settings_clone, settings) |
|
| 47 |
report_activity("settings", settings_clone);
|
|
| 46 | 48 |
} |
| 47 | 49 |
|
| 48 | 50 |
function report_document_type(is_html) |
| content/main.js | ||
|---|---|---|
| 11 | 11 |
/* |
| 12 | 12 |
* IMPORTS_START |
| 13 | 13 |
* IMPORT handle_page_actions |
| 14 |
* IMPORT extract_signed |
|
| 15 |
* IMPORT sign_data |
|
| 16 | 14 |
* IMPORT gen_nonce |
| 17 | 15 |
* IMPORT is_privileged_url |
| 16 |
* IMPORT browser |
|
| 18 | 17 |
* IMPORT is_chrome |
| 19 | 18 |
* IMPORT is_mozilla |
| 20 | 19 |
* IMPORT start_activity_info_server |
| 21 | 20 |
* IMPORT make_csp_rule |
| 22 | 21 |
* IMPORT csp_header_regex |
| 22 |
* IMPORT report_settings |
|
| 23 | 23 |
* IMPORTS_END |
| 24 | 24 |
*/ |
| 25 | 25 |
|
| ... | ... | |
| 29 | 29 |
|
| 30 | 30 |
wait_loaded(document).then(() => document.content_loaded = true); |
| 31 | 31 |
|
| 32 |
function extract_cookie_policy(cookie, min_time) |
|
| 33 |
{
|
|
| 34 |
let best_result = {time: -1};
|
|
| 35 |
let policy = null; |
|
| 36 |
const extracted_signatures = []; |
|
| 37 |
|
|
| 38 |
for (const match of cookie.matchAll(/haketilo-(\w*)=([^;]*)/g)) {
|
|
| 39 |
const new_result = extract_signed(...match.slice(1, 3)); |
|
| 40 |
if (new_result.fail) |
|
| 41 |
continue; |
|
| 42 |
|
|
| 43 |
extracted_signatures.push(match[1]); |
|
| 44 |
|
|
| 45 |
if (new_result.time < Math.max(min_time, best_result.time)) |
|
| 46 |
continue; |
|
| 47 |
|
|
| 48 |
/* This should succeed - it's our self-produced valid JSON. */ |
|
| 49 |
const new_policy = JSON.parse(decodeURIComponent(new_result.data)); |
|
| 50 |
if (new_policy.url !== document.URL) |
|
| 51 |
continue; |
|
| 52 |
|
|
| 53 |
best_result = new_result; |
|
| 54 |
policy = new_policy; |
|
| 55 |
} |
|
| 56 |
|
|
| 57 |
return [policy, extracted_signatures]; |
|
| 58 |
} |
|
| 59 |
|
|
| 60 |
function extract_url_policy(url, min_time) |
|
| 61 |
{
|
|
| 62 |
const [base_url, payload, anchor] = |
|
| 63 |
/^([^#]*)#?([^#]*)(#?.*)$/.exec(url).splice(1, 4); |
|
| 64 |
|
|
| 65 |
const match = /^haketilo_([^_]+)_(.*)$/.exec(payload); |
|
| 66 |
if (!match) |
|
| 67 |
return [null, url]; |
|
| 68 |
|
|
| 69 |
const result = extract_signed(...match.slice(1, 3)); |
|
| 70 |
if (result.fail) |
|
| 71 |
return [null, url]; |
|
| 72 |
|
|
| 73 |
const original_url = base_url + anchor; |
|
| 74 |
const policy = result.time < min_time ? null : |
|
| 75 |
JSON.parse(decodeURIComponent(result.data)); |
|
| 76 |
|
|
| 77 |
return [policy.url === original_url ? policy : null, original_url]; |
|
| 78 |
} |
|
| 79 |
|
|
| 80 |
function employ_nonhttp_policy(policy) |
|
| 81 |
{
|
|
| 82 |
if (!policy.allow) |
|
| 83 |
return; |
|
| 84 |
|
|
| 85 |
policy.nonce = gen_nonce(); |
|
| 86 |
const [base_url, target] = /^([^#]*)(#?.*)$/.exec(policy.url).slice(1, 3); |
|
| 87 |
const encoded_policy = encodeURIComponent(JSON.stringify(policy)); |
|
| 88 |
const payload = "haketilo_" + |
|
| 89 |
sign_data(encoded_policy, new Date().getTime()).join("_");
|
|
| 90 |
const resulting_url = `${base_url}#${payload}${target}`;
|
|
| 91 |
location.href = resulting_url; |
|
| 92 |
location.reload(); |
|
| 93 |
} |
|
| 94 |
|
|
| 95 | 32 |
/* |
| 96 | 33 |
* In the case of HTML documents: |
| 97 | 34 |
* 1. When injecting some payload we need to sanitize <meta> CSP tags before |
| ... | ... | |
| 306 | 243 |
start_data_urls_sanitizing(doc); |
| 307 | 244 |
} |
| 308 | 245 |
|
| 309 |
async function disable_service_workers() |
|
| 246 |
async function _disable_service_workers()
|
|
| 310 | 247 |
{
|
| 311 | 248 |
if (!navigator.serviceWorker) |
| 312 | 249 |
return; |
| ... | ... | |
| 315 | 252 |
if (registrations.length === 0) |
| 316 | 253 |
return; |
| 317 | 254 |
|
| 318 |
console.warn("Service Workers detected on this page! Unregistering and reloading");
|
|
| 255 |
console.warn("Service Workers detected on this page! Unregistering and reloading.");
|
|
| 319 | 256 |
|
| 320 | 257 |
try {
|
| 321 | 258 |
await Promise.all(registrations.map(r => r.unregister())); |
| ... | ... | |
| 327 | 264 |
return new Promise(() => 0); |
| 328 | 265 |
} |
| 329 | 266 |
|
| 330 |
if (!is_privileged_url(document.URL)) {
|
|
| 331 |
let policy_received_callback = () => undefined; |
|
| 332 |
let policy; |
|
| 333 |
|
|
| 334 |
/* Signature valid for half an hour. */ |
|
| 335 |
const min_time = new Date().getTime() - 1800 * 1000; |
|
| 336 |
|
|
| 337 |
if (/^https?:/.test(document.URL)) {
|
|
| 338 |
let signatures; |
|
| 339 |
[policy, signatures] = extract_cookie_policy(document.cookie, min_time); |
|
| 340 |
for (const signature of signatures) |
|
| 341 |
document.cookie = `haketilo-${signature}=; Max-Age=-1;`;
|
|
| 342 |
} else {
|
|
| 343 |
const scheme = /^([^:]*)/.exec(document.URL)[1]; |
|
| 344 |
const known_scheme = ["file", "ftp"].includes(scheme); |
|
| 345 |
|
|
| 346 |
if (!known_scheme) |
|
| 347 |
console.warn(`Unknown url scheme: \`${scheme}'!`);
|
|
| 348 |
|
|
| 349 |
let original_url; |
|
| 350 |
[policy, original_url] = extract_url_policy(document.URL, min_time); |
|
| 351 |
history.replaceState(null, "", original_url); |
|
| 352 |
|
|
| 353 |
if (known_scheme && !policy) |
|
| 354 |
policy_received_callback = employ_nonhttp_policy; |
|
| 267 |
/* |
|
| 268 |
* Trying to use servce workers APIs might result in exceptions, for example |
|
| 269 |
* when in a non-HTML document. Because of this, we wrap the function that does |
|
| 270 |
* the actual work in a try {} block.
|
|
| 271 |
*/ |
|
| 272 |
async function disable_service_workers() |
|
| 273 |
{
|
|
| 274 |
try {
|
|
| 275 |
await _disable_service_workers() |
|
| 276 |
} catch (e) {
|
|
| 277 |
console.debug("Exception thrown during an attempt to detect and disable service workers.", e);
|
|
| 355 | 278 |
} |
| 279 |
} |
|
| 356 | 280 |
|
| 357 |
if (!policy) {
|
|
| 358 |
console.debug("Using fallback policy!");
|
|
| 359 |
policy = {allow: false, nonce: gen_nonce()};
|
|
| 281 |
function synchronously_get_policy(url) |
|
| 282 |
{
|
|
| 283 |
const encoded_url = encodeURIComponent(url); |
|
| 284 |
const request_url = `${browser.runtime.getURL("dummy")}?url=${encoded_url}`;
|
|
| 285 |
|
|
| 286 |
try {
|
|
| 287 |
var xhttp = new XMLHttpRequest(); |
|
| 288 |
xhttp.open("GET", request_url, false);
|
|
| 289 |
xhttp.send(); |
|
| 290 |
} catch(e) {
|
|
| 291 |
console.error("Failure to synchronously fetch policy for url.", e);
|
|
| 292 |
return {allow: false};
|
|
| 360 | 293 |
} |
| 361 | 294 |
|
| 295 |
const policy = /^[^?]*\?settings=(.*)$/.exec(xhttp.responseURL)[1]; |
|
| 296 |
return JSON.parse(decodeURIComponent(policy)); |
|
| 297 |
} |
|
| 298 |
|
|
| 299 |
if (!is_privileged_url(document.URL)) {
|
|
| 300 |
const policy = synchronously_get_policy(document.URL); |
|
| 301 |
|
|
| 362 | 302 |
if (!(document instanceof HTMLDocument)) |
| 363 |
policy.has_payload = false;
|
|
| 303 |
delete policy.payload;
|
|
| 364 | 304 |
|
| 365 | 305 |
console.debug("current policy", policy);
|
| 366 | 306 |
|
| 307 |
report_settings(policy); |
|
| 308 |
|
|
| 309 |
policy.nonce = gen_nonce(); |
|
| 310 |
|
|
| 367 | 311 |
const doc_ready = Promise.all([ |
| 368 | 312 |
policy.allow ? Promise.resolve() : sanitize_document(document, policy), |
| 369 | 313 |
policy.allow ? Promise.resolve() : disable_service_workers(), |
| 370 | 314 |
wait_loaded(document) |
| 371 | 315 |
]); |
| 372 | 316 |
|
| 373 |
handle_page_actions(policy.nonce, policy_received_callback, doc_ready);
|
|
| 317 |
handle_page_actions(policy, doc_ready); |
|
| 374 | 318 |
|
| 375 | 319 |
start_activity_info_server(); |
| 376 | 320 |
} |
| content/page_actions.js | ||
|---|---|---|
| 12 | 12 |
* IMPORT CONNECTION_TYPE |
| 13 | 13 |
* IMPORT browser |
| 14 | 14 |
* IMPORT report_script |
| 15 |
* IMPORT report_settings |
|
| 16 | 15 |
* IMPORT report_document_type |
| 17 | 16 |
* IMPORTS_END |
| 18 | 17 |
*/ |
| 19 | 18 |
|
| 20 |
let policy_received_callback;
|
|
| 19 |
let policy; |
|
| 21 | 20 |
/* Snapshot url and content type early; these can be changed by other code. */ |
| 22 | 21 |
let url; |
| 23 | 22 |
let is_html; |
| 24 | 23 |
let port; |
| 25 | 24 |
let loaded = false; |
| 26 | 25 |
let scripts_awaiting = []; |
| 27 |
let nonce; |
|
| 28 | 26 |
|
| 29 | 27 |
function handle_message(message) |
| 30 | 28 |
{
|
| ... | ... | |
| 38 | 36 |
scripts_awaiting.push(script_text); |
| 39 | 37 |
} |
| 40 | 38 |
} |
| 41 |
if (action === "settings") {
|
|
| 42 |
report_settings(data); |
|
| 43 |
policy_received_callback({url, allow: data[1].allow});
|
|
| 39 |
else {
|
|
| 40 |
console.error(`Bad page action '${action}'.`);
|
|
| 44 | 41 |
} |
| 45 | 42 |
} |
| 46 | 43 |
|
| ... | ... | |
| 61 | 58 |
|
| 62 | 59 |
let script = document.createElement("script");
|
| 63 | 60 |
script.textContent = script_text; |
| 64 |
script.setAttribute("nonce", nonce);
|
|
| 61 |
script.setAttribute("nonce", policy.nonce);
|
|
| 65 | 62 |
script.haketilo_payload = true; |
| 66 | 63 |
document.body.appendChild(script); |
| 67 | 64 |
|
| 68 | 65 |
report_script(script_text); |
| 69 | 66 |
} |
| 70 | 67 |
|
| 71 |
function handle_page_actions(script_nonce, policy_received_cb,
|
|
| 72 |
doc_ready_promise) {
|
|
| 73 |
policy_received_callback = policy_received_cb; |
|
| 68 |
function handle_page_actions(_policy, doc_ready_promise) {
|
|
| 69 |
policy = _policy;
|
|
| 70 |
|
|
| 74 | 71 |
url = document.URL; |
| 75 | 72 |
is_html = document instanceof HTMLDocument; |
| 76 | 73 |
report_document_type(is_html); |
| 77 | 74 |
|
| 78 | 75 |
doc_ready_promise.then(document_ready); |
| 79 | 76 |
|
| 80 |
port = browser.runtime.connect({name : CONNECTION_TYPE.PAGE_ACTIONS});
|
|
| 81 |
port.onMessage.addListener(handle_message);
|
|
| 82 |
port.postMessage({url});
|
|
| 83 |
|
|
| 84 |
nonce = script_nonce;
|
|
| 77 |
if (policy.payload) {
|
|
| 78 |
port = browser.runtime.connect({name : CONNECTION_TYPE.PAGE_ACTIONS});
|
|
| 79 |
port.onMessage.addListener(handle_message);
|
|
| 80 |
port.postMessage({payload: policy.payload});
|
|
| 81 |
}
|
|
| 85 | 82 |
} |
| 86 | 83 |
|
| 87 | 84 |
/* |
| html/display-panel.js | ||
|---|---|---|
| 229 | 229 |
const [type, data] = message; |
| 230 | 230 |
|
| 231 | 231 |
if (type === "settings") {
|
| 232 |
let [pattern, settings] = data;
|
|
| 232 |
const settings = data;
|
|
| 233 | 233 |
|
| 234 | 234 |
blocked_span.textContent = settings.allow ? "no" : "yes"; |
| 235 | 235 |
|
| 236 |
if (pattern) {
|
|
| 236 |
if (settings.pattern) {
|
|
| 237 | 237 |
pattern_span.textContent = pattern; |
| 238 | 238 |
const settings_opener = |
| 239 |
() => open_in_settings(TYPE_PREFIX.PAGE, pattern); |
|
| 239 |
() => open_in_settings(TYPE_PREFIX.PAGE, settings.pattern);
|
|
| 240 | 240 |
view_pattern_but.classList.remove("hide");
|
| 241 | 241 |
view_pattern_but.addEventListener("click", settings_opener);
|
| 242 | 242 |
} else {
|
| ... | ... | |
| 244 | 244 |
blocked_span.textContent = blocked_span.textContent + " (default)"; |
| 245 | 245 |
} |
| 246 | 246 |
|
| 247 |
const components = settings.components; |
|
| 248 |
if (components) {
|
|
| 249 |
payload_span.textContent = nice_name(...components); |
|
| 247 |
if (settings.payload) {
|
|
| 248 |
payload_span.textContent = nice_name(...settings.payload); |
|
| 250 | 249 |
payload_buttons_div.classList.remove("hide");
|
| 251 |
const settings_opener = () => open_in_settings(...components);
|
|
| 250 |
const settings_opener = () => open_in_settings(...settings.payload);
|
|
| 252 | 251 |
view_payload_but.addEventListener("click", settings_opener);
|
| 253 | 252 |
} else {
|
| 254 | 253 |
payload_span.textContent = "none"; |
| manifest.json | ||
|---|---|---|
| 44 | 44 |
"page": "html/options.html", |
| 45 | 45 |
"open_in_tab": true |
| 46 | 46 |
}_CHROMIUM_UPDATE_URL_, |
| 47 |
"web_accessible_resources": [_CHROMIUM_KEY_ |
|
| 48 |
], |
|
| 47 |
"web_accessible_resources": ["dummy"], |
|
| 49 | 48 |
"background": {
|
| 50 | 49 |
"persistent": true, |
| 51 | 50 |
"scripts": [_BGSCRIPTS_] |
Also available in: Unified diff
replace cookies with synchronous XmlHttpRequest as policy smuggling method.
Note: this breaks Mozilla port of Haketilo. Synchronous XmlHttpRequest doesn't work as well there. This will be fixed with dynamically-registered content scripts later.