From 692577bbde5e8110855c022ec913324dfddce9ae Mon Sep 17 00:00:00 2001 From: jahoti Date: Fri, 16 Jul 2021 00:00:00 +0000 Subject: Use URL-based policy smuggling Increase the power of URL-based smuggling by making it (effectively) compulsory in all cases and adapting a structure. While the details still need to be worked out, the potential for future expansion is there. --- background/policy_injector.js | 52 ++++++++++++++++++++++++++++++++++++++----- common/misc.js | 36 ++++++++++++++++++++++++++++-- content/main.js | 49 +++++++++++++--------------------------- copyright | 2 +- 4 files changed, 97 insertions(+), 42 deletions(-) diff --git a/background/policy_injector.js b/background/policy_injector.js index eb67963..9e8ed61 100644 --- a/background/policy_injector.js +++ b/background/policy_injector.js @@ -2,6 +2,7 @@ * Myext injecting policy to page using webRequest * * Copyright (C) 2021 Wojtek Kosior + * Copyright (C) 2021 jahoti * Redistribution terms are gathered in the `copyright' file. */ @@ -12,7 +13,9 @@ * IMPORT browser * IMPORT is_chrome * IMPORT gen_unique + * IMPORT gen_nonce * IMPORT url_item + * IMPORT url_extract_policy * IMPORT get_query_best * IMPORT csp_rule * IMPORTS_END @@ -39,18 +42,46 @@ function is_our_header(header, rule) return header.value === rule } -function inject(details) +function url_inject(details) { - const url = url_item(details.url); + const targets = url_extract_policy(details.url); + if (targets.policy) { + return; + } else if (targets.signed) { + /* Redirect; update policy */ + targets.target = targets.target2; + delete targets.target2 + } + + let redirect_url = targets.base_url + targets.sig; + let [pattern, settings] = query_best(targets.base_url); + if (!pattern) + /* Defaults */ + settings = {}; + + const policy = {allow: settings.allow, nonce: gen_nonce()}; + + redirect_url += encodeURIComponent(JSON.stringify(policy)); + if (targets.target) + redirect_url += targets.target; + if (targets.target2) + redirect_url += targets.target2; + + return {redirectUrl: redirect_url}; +} - const [pattern, settings] = query_best(url); +function inject(details) +{ + const targets = url_extract_policy(details.url); + if (!targets.policy) + /* Block unsigned requests */ + return {cancel: true}; - const nonce = gen_unique(url); - const rule = csp_rule(nonce); + const rule = csp_rule(targets.policy.nonce); var headers; - if (settings !== undefined && settings.allow) { + if (targets.policy.allow) { /* * Chrome doesn't have the buggy behavior of repeatedly injecting a * header we injected once. Firefox does and we have to remove it there. @@ -80,6 +111,15 @@ async function start_policy_injector() if (is_chrome) extra_opts.push("extraHeaders"); + browser.webRequest.onBeforeRequest.addListener( + url_inject, + { + urls: [""], + types: ["main_frame", "sub_frame"] + }, + ["blocking"] + ); + browser.webRequest.onHeadersReceived.addListener( inject, { diff --git a/common/misc.js b/common/misc.js index 8b56e79..825a117 100644 --- a/common/misc.js +++ b/common/misc.js @@ -2,6 +2,7 @@ * Myext miscellaneous operations refactored to a separate file * * Copyright (C) 2021 Wojtek Kosior + * Copyright (C) 2021 jahoti * Redistribution terms are gathered in the `copyright' file. */ @@ -14,6 +15,14 @@ * IMPORTS_END */ +/* Generate a random base64-encoded 128-bit sequence */ +function gen_nonce() +{ + let randomData = new Uint8Array(16); + crypto.getRandomValues(randomData); + return btoa(String.fromCharCode.apply(null, randomData)); +} + /* * generating unique, per-site value that can be computed synchronously * and is impossible to guess for a malicious website @@ -26,9 +35,9 @@ function gen_unique(url) function get_secure_salt() { if (is_chrome) - return browser.runtime.getManifest().key.substring(0, 50); + return browser.runtime.getManifest().key.substring(0, 36); else - return browser.runtime.getURL("dummy"); + return browser.runtime.getURL("dummy").substr(16, 36); } /* @@ -95,11 +104,34 @@ function is_privileged_url(url) return !!/^(chrome(-extension)?|moz-extension):\/\/|^about:/i.exec(url); } +/* Extract any policy present in the URL */ +function url_extract_policy(url) +{ + const targets = url_extract_target(url); + const key = '#' + get_secure_salt(); + targets.sig = key + gen_unique(targets.base_url); + + if (targets.target && targets.target.startsWith(key)) { + targets.signed = true; + if (targets.target.startsWith(targets.sig)) + try { + const policy_string = targets.target.substring(101); + targets.policy = JSON.parse(decodeURIComponent(policy_string)); + } catch (e) { + /* TODO what should happen here? */ + } + } + + return targets; +} + /* * EXPORTS_START + * EXPORT gen_nonce * EXPORT gen_unique * EXPORT url_item * EXPORT url_extract_target + * EXPORT url_extract_policy * EXPORT csp_rule * EXPORT nice_name * EXPORT open_in_settings diff --git a/content/main.js b/content/main.js index 9acf749..e75f61d 100644 --- a/content/main.js +++ b/content/main.js @@ -2,6 +2,7 @@ * Myext main content script run in all frames * * Copyright (C) 2021 Wojtek Kosior + * Copyright (C) 2021 jahoti * Redistribution terms are gathered in the `copyright' file. */ @@ -10,7 +11,9 @@ * IMPORT handle_page_actions * IMPORT url_item * IMPORT url_extract_target + * IMPORT url_extract_policy * IMPORT gen_unique + * IMPORT gen_nonce * IMPORT csp_rule * IMPORT is_privileged_url * IMPORT sanitize_attributes @@ -32,32 +35,6 @@ * urls has not yet been added to the extension. */ -let url = url_item(document.URL); -let unique = gen_unique(url); - - -function is_http() -{ - return !!/^https?:\/\//i.exec(document.URL); -} - -function is_whitelisted() -{ - const parsed_url = url_extract_target(document.URL); - - if (parsed_url.target !== undefined && - parsed_url.target === '#' + unique) { - if (parsed_url.target2 !== undefined) - window.location.href = parsed_url.base_url + parsed_url.target2; - else - history.replaceState(null, "", parsed_url.base_url); - - return true; - } - - return false; -} - function handle_mutation(mutations, observer) { if (document.readyState === 'complete') { @@ -113,7 +90,7 @@ function inject_csp(head) let meta = document.createElement("meta"); meta.setAttribute("http-equiv", "Content-Security-Policy"); - meta.setAttribute("content", csp_rule(unique)); + meta.setAttribute("content", csp_rule(nonce)); if (head.firstElementChild === null) head.appendChild(meta); @@ -122,14 +99,20 @@ function inject_csp(head) } if (!is_privileged_url(document.URL)) { + const targets = url_extract_policy(document.URL); + targets.policy = targets.policy || {}; + const nonce = targets.policy.nonce || gen_nonce(); + + if (targets.signed) + if (targets.target2 !== undefined) + window.location.href = targets.base_url + targets.target2; + else + history.replaceState(null, "", targets.base_url); + start_activity_info_server(); - handle_page_actions(unique); + handle_page_actions(nonce); - if (is_http()) { - /* rely on CSP injected through webRequest */ - } else if (is_whitelisted()) { - /* do not block scripts at all */ - } else { + if (!targets.policy.allow) { block_nodes_recursively(document.documentElement); if (is_chrome) { diff --git a/copyright b/copyright index a01c1fe..4ff7483 100644 --- a/copyright +++ b/copyright @@ -10,7 +10,7 @@ Files: build.sh Copyright: 2021 Wojtek Kosior License: CC0 -Files: manifest.json +Files: manifest.json background/policy_injector.js common/misc.js content/main.js Copyright: 2021 Wojtek Kosior 2021 jahoti License: GPL-3+-javascript or Alicense-1.0 -- cgit v1.2.3 From 8b823e1a6f29e52effc086d02dfe2e2812b2e187 Mon Sep 17 00:00:00 2001 From: jahoti Date: Sat, 17 Jul 2021 00:00:00 +0000 Subject: Revamp signatures and break header caching on FF Signatures, instead of consisting of the secure salt followed by the unique value generated from the URL, are now the unique value generated from the policy value (which will follow them) succeeded by the URL. CSP headers are now _always_ cleared on FF, regardless of whether the page is whitelisted or not. This means whitelisting takes effect on page reload, rather than only when caching occurs. However, it obviously presents security issues; refinment will occur in a future commit. --- background/policy_injector.js | 29 +++++++++++++---------------- common/misc.js | 26 +++++++++++++------------- content/main.js | 11 ++++++----- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/background/policy_injector.js b/background/policy_injector.js index 9e8ed61..8a767fb 100644 --- a/background/policy_injector.js +++ b/background/policy_injector.js @@ -12,6 +12,7 @@ * IMPORT get_storage * IMPORT browser * IMPORT is_chrome + * IMPORT is_mozilla * IMPORT gen_unique * IMPORT gen_nonce * IMPORT url_item @@ -45,23 +46,24 @@ function is_our_header(header, rule) function url_inject(details) { const targets = url_extract_policy(details.url); - if (targets.policy) { + if (targets.valid_sig) { return; - } else if (targets.signed) { + } else if (targets.policy) { /* Redirect; update policy */ targets.target = targets.target2; delete targets.target2 } - let redirect_url = targets.base_url + targets.sig; let [pattern, settings] = query_best(targets.base_url); if (!pattern) /* Defaults */ settings = {}; const policy = {allow: settings.allow, nonce: gen_nonce()}; + const policy_string = encodeURIComponent(JSON.stringify(policy)); + const sig = gen_unique(policy_string + targets.base_url); - redirect_url += encodeURIComponent(JSON.stringify(policy)); + let redirect_url = targets.base_url + '#' + sig + policy_string; if (targets.target) redirect_url += targets.target; if (targets.target2) @@ -73,31 +75,26 @@ function url_inject(details) function inject(details) { const targets = url_extract_policy(details.url); - if (!targets.policy) + if (!targets.valid_sig) /* Block unsigned requests */ return {cancel: true}; const rule = csp_rule(targets.policy.nonce); - var headers; + var headers = details.responseHeaders; - if (targets.policy.allow) { + if (!targets.policy.allow || is_mozilla) /* - * Chrome doesn't have the buggy behavior of repeatedly injecting a - * header we injected once. Firefox does and we have to remove it there. + * Chrome doesn't have the buggy behavior of caching headers + * we injected. Firefox does and we have to remove it there. */ - if (is_chrome) - return {cancel: false}; - - headers = details.responseHeaders.filter(h => !is_our_header(h, rule)); - } else { - headers = details.responseHeaders.filter(h => !is_csp_header(h)); + headers = headers.filter(h => !is_csp_header(h)); + if (!targets.policy.allow) headers.push({ name : header_name, value : rule }); - } return {responseHeaders: headers}; } diff --git a/common/misc.js b/common/misc.js index 825a117..036eb45 100644 --- a/common/misc.js +++ b/common/misc.js @@ -35,9 +35,9 @@ function gen_unique(url) function get_secure_salt() { if (is_chrome) - return browser.runtime.getManifest().key.substring(0, 36); + return browser.runtime.getManifest().key.substring(0, 50); else - return browser.runtime.getURL("dummy").substr(16, 36); + return browser.runtime.getURL("dummy"); } /* @@ -107,19 +107,19 @@ function is_privileged_url(url) /* Extract any policy present in the URL */ function url_extract_policy(url) { + var policy_string; const targets = url_extract_target(url); - const key = '#' + get_secure_salt(); - targets.sig = key + gen_unique(targets.base_url); - if (targets.target && targets.target.startsWith(key)) { - targets.signed = true; - if (targets.target.startsWith(targets.sig)) - try { - const policy_string = targets.target.substring(101); - targets.policy = JSON.parse(decodeURIComponent(policy_string)); - } catch (e) { - /* TODO what should happen here? */ - } + try { + policy_string = targets.target.substring(65); + targets.policy = JSON.parse(decodeURIComponent(policy_string)); + } catch (e) { + /* TODO what should happen here? */ + } + + if (targets.policy) { + const sig = gen_unique(policy_string + targets.base_url); + targets.valid_sig = targets.target.substring(1, 65) === sig; } return targets; diff --git a/content/main.js b/content/main.js index e75f61d..317b319 100644 --- a/content/main.js +++ b/content/main.js @@ -100,15 +100,16 @@ function inject_csp(head) if (!is_privileged_url(document.URL)) { const targets = url_extract_policy(document.URL); - targets.policy = targets.policy || {}; - const nonce = targets.policy.nonce || gen_nonce(); - - if (targets.signed) + if (targets.policy) { if (targets.target2 !== undefined) window.location.href = targets.base_url + targets.target2; else history.replaceState(null, "", targets.base_url); - + } + + targets.policy = targets.valid_sig ? targets.policy : {}; + + const nonce = targets.policy.nonce || gen_nonce(); start_activity_info_server(); handle_page_actions(nonce); -- cgit v1.2.3 From ecb787046271de708b94da70240713e725299d86 Mon Sep 17 00:00:00 2001 From: jahoti Date: Sun, 18 Jul 2021 00:00:00 +0000 Subject: Streamline and harden unique values/settings The base URL is now included in the settings. The unique value no longer uses it directly, as it is included by virtue of the settings; however, the number of full hours since the epoch (UTC) is now incorporated. --- background/policy_injector.js | 21 +++++++++++++-------- common/misc.js | 34 ++++++++++++++++++++++++++-------- content/main.js | 2 +- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/background/policy_injector.js b/background/policy_injector.js index 8a767fb..2cd7b6e 100644 --- a/background/policy_injector.js +++ b/background/policy_injector.js @@ -17,6 +17,7 @@ * IMPORT gen_nonce * IMPORT url_item * IMPORT url_extract_policy + * IMPORT sign_policy * IMPORT get_query_best * IMPORT csp_rule * IMPORTS_END @@ -46,7 +47,7 @@ function is_our_header(header, rule) function url_inject(details) { const targets = url_extract_policy(details.url); - if (targets.valid_sig) { + if (targets.current) { return; } else if (targets.policy) { /* Redirect; update policy */ @@ -59,11 +60,16 @@ function url_inject(details) /* Defaults */ settings = {}; - const policy = {allow: settings.allow, nonce: gen_nonce()}; - const policy_string = encodeURIComponent(JSON.stringify(policy)); - const sig = gen_unique(policy_string + targets.base_url); + const policy = encodeURIComponent( + JSON.stringify({ + allow: settings.allow, + nonce: gen_nonce(), + base_url: targets.base_url + }) + ); - let redirect_url = targets.base_url + '#' + sig + policy_string; + let redirect_url = targets.base_url; + redirect_url += '#' + sign_policy(policy, new Date()) + policy; if (targets.target) redirect_url += targets.target; if (targets.target2) @@ -75,12 +81,11 @@ function url_inject(details) function inject(details) { const targets = url_extract_policy(details.url); - if (!targets.valid_sig) - /* Block unsigned requests */ + if (!targets.current) + /* Block mis-/unsigned requests */ return {cancel: true}; const rule = csp_rule(targets.policy.nonce); - var headers = details.responseHeaders; if (!targets.policy.allow || is_mozilla) diff --git a/common/misc.js b/common/misc.js index 036eb45..472620e 100644 --- a/common/misc.js +++ b/common/misc.js @@ -104,23 +104,40 @@ function is_privileged_url(url) return !!/^(chrome(-extension)?|moz-extension):\/\/|^about:/i.exec(url); } +/* Sign a given policy for a given time */ +function sign_policy(policy, now, hours_offset) { + let time = Math.floor(now / 3600000) + (hours_offset || 0); + return gen_unique(time + policy); +} + /* Extract any policy present in the URL */ function url_extract_policy(url) { - var policy_string; const targets = url_extract_target(url); + if (!targets.target) + return targets; + + /* %7B -> { */ + const index = targets.target.indexOf('%7B'); + if (index === -1) + return targets; + + const now = new Date(); + const sig = targets.target.substring(1, index); + const policy = targets.target.substring(index); + if ( + sig !== sign_policy(policy, now) && + sig !== sign_policy(policy, now, -1) && + sig !== sign_policy(policy, now, 1) + ) + return targets; try { - policy_string = targets.target.substring(65); - targets.policy = JSON.parse(decodeURIComponent(policy_string)); + targets.policy = JSON.parse(decodeURIComponent(policy)); + targets.current = targets.policy.base_url === targets.base_url; } catch (e) { /* TODO what should happen here? */ } - - if (targets.policy) { - const sig = gen_unique(policy_string + targets.base_url); - targets.valid_sig = targets.target.substring(1, 65) === sig; - } return targets; } @@ -132,6 +149,7 @@ function url_extract_policy(url) * EXPORT url_item * EXPORT url_extract_target * EXPORT url_extract_policy + * EXPORT sign_policy * EXPORT csp_rule * EXPORT nice_name * EXPORT open_in_settings diff --git a/content/main.js b/content/main.js index 317b319..a5e04fd 100644 --- a/content/main.js +++ b/content/main.js @@ -107,7 +107,7 @@ if (!is_privileged_url(document.URL)) { history.replaceState(null, "", targets.base_url); } - targets.policy = targets.valid_sig ? targets.policy : {}; + targets.policy = targets.current ? targets.policy : {}; const nonce = targets.policy.nonce || gen_nonce(); start_activity_info_server(); -- cgit v1.2.3