aboutsummaryrefslogtreecommitdiff
path: root/common
diff options
context:
space:
mode:
Diffstat (limited to 'common')
-rw-r--r--common/misc.js50
-rw-r--r--common/observable.js28
-rw-r--r--common/patterns.js167
-rw-r--r--common/settings_query.js27
-rw-r--r--common/signing.js73
-rw-r--r--common/storage_light.js129
-rw-r--r--common/storage_raw.js54
7 files changed, 369 insertions, 159 deletions
diff --git a/common/misc.js b/common/misc.js
index 30a9e77..91d60d2 100644
--- a/common/misc.js
+++ b/common/misc.js
@@ -8,9 +8,7 @@
/*
* IMPORTS_START
- * IMPORT sha256
* IMPORT browser
- * IMPORT is_chrome
* IMPORT TYPE_NAME
* IMPORT TYPE_PREFIX
* IMPORTS_END
@@ -45,32 +43,6 @@ function gen_nonce(length) // Default 16
return Uint8toHex(randomData);
}
-function get_secure_salt()
-{
- if (is_chrome)
- return browser.runtime.getManifest().key.substring(0, 50);
- else
- return browser.runtime.getURL("dummy");
-}
-
-function extract_signed(signature, data, times)
-{
- const now = new Date();
- times = times || [[now], [now, -1]];
-
- const reductor =
- (ok, time) => ok || signature === sign_data(data, ...time);
- if (!times.reduce(reductor, false))
- return undefined;
-
- try {
- return JSON.parse(decodeURIComponent(data));
- } catch (e) {
- /* This should not be reached - it's our self-produced valid JSON. */
- console.log("Unexpected internal error - invalid JSON smuggled!", e);
- }
-}
-
/* csp rule that blocks all scripts except for those injected by us */
function csp_rule(nonce)
{
@@ -89,7 +61,7 @@ const report_only_header_name = "content-security-policy-report-only";
function is_csp_header_name(string, include_report_only)
{
- string = string && string.toLowerCase() || "";
+ string = string && string.toLowerCase().trim() || "";
return (include_report_only && string === report_only_header_name) ||
csp_header_names.has(string);
@@ -112,17 +84,13 @@ function open_in_settings(prefix, name)
window.open(url, "_blank");
}
-/* Check if url corresponds to a browser's special page */
-function is_privileged_url(url)
-{
- return !!/^(chrome(-extension)?|moz-extension):\/\/|^about:/i.exec(url);
-}
-
-/* Sign a given string for a given time */
-function sign_data(data, now, hours_offset) {
- let time = Math.floor(now / 3600000) + (hours_offset || 0);
- return sha256(get_secure_salt() + time + data);
-}
+/*
+ * Check if url corresponds to a browser's special page (or a directory index in
+ * case of `file://' protocol).
+ */
+const privileged_reg =
+ /^(chrome(-extension)?|moz-extension):\/\/|^about:|^file:\/\/.*\/$/;
+const is_privileged_url = url => privileged_reg.test(url);
/* Parse a CSP header */
function parse_csp(csp) {
@@ -193,8 +161,6 @@ const matchers = {
/*
* EXPORTS_START
* EXPORT gen_nonce
- * EXPORT extract_signed
- * EXPORT sign_data
* EXPORT csp_rule
* EXPORT is_csp_header_name
* EXPORT nice_name
diff --git a/common/observable.js b/common/observable.js
index 1fb0b0a..02f1c1b 100644
--- a/common/observable.js
+++ b/common/observable.js
@@ -6,28 +6,22 @@
* Redistribution terms are gathered in the `copyright' file.
*/
-function make()
-{
- return new Set();
-}
+const make = (value=undefined) => ({value, listeners: new Set()});
+const subscribe = (observable, cb) => observable.listeners.add(cb);
+const unsubscribe = (observable, cb) => observable.listeners.delete(cb);
-function subscribe(observable, cb)
-{
- observable.add(cb);
-}
-
-function unsubscribe(observable, cb)
-{
- observable.delete(cb);
-}
+const silent_set = (observable, value) => observable.value = value;
+const broadcast = (observable, ...values) =>
+ observable.listeners.forEach(cb => cb(...values));
-function broadcast(observable, event)
+function set(observable, value)
{
- for (const callback of observable)
- callback(event);
+ const old_value = observable.value;
+ silent_set(observable, value);
+ broadcast(observable, value, old_value);
}
-const observables = {make, subscribe, unsubscribe, broadcast};
+const observables = {make, subscribe, unsubscribe, broadcast, silent_set, set};
/*
* EXPORTS_START
diff --git a/common/patterns.js b/common/patterns.js
index be7c650..ebb55ab 100644
--- a/common/patterns.js
+++ b/common/patterns.js
@@ -5,35 +5,48 @@
* Redistribution terms are gathered in the `copyright' file.
*/
-const proto_re = "[a-zA-Z]*:\/\/";
+const proto_regex = /^(\w+):\/\/(.*)$/;
+
+const user_re = "[^/?#@]+@"
const domain_re = "[^/?#]+";
-const segments_re = "/[^?#]*";
-const query_re = "\\?[^#]*";
-
-const url_regex = new RegExp(`\
-^\
-(${proto_re})\
-(${domain_re})\
-(${segments_re})?\
-(${query_re})?\
-#?.*\$\
-`);
+const path_re = "[^?#]*";
+const query_re = "\\??[^#]*";
+
+const http_regex = new RegExp(`^(${domain_re})(${path_re})(${query_re}).*`);
+
+const file_regex = new RegExp(`^(${path_re}).*`);
+
+const ftp_regex = new RegExp(`^(${user_re})?(${domain_re})(${path_re}).*`);
function deconstruct_url(url)
{
- const regex_match = url_regex.exec(url);
- if (regex_match === null)
+ const proto_match = proto_regex.exec(url);
+ if (proto_match === null)
return undefined;
- let [_, proto, domain, path, query] = regex_match;
+ const deco = {proto: proto_match[1]};
+
+ if (deco.proto === "file") {
+ deco.path = file_regex.exec(proto_match[2])[1];
+ } else if (deco.proto === "ftp") {
+ [deco.domain, deco.path] = ftp_regex.exec(proto_match[2]).slice(2, 4);
+ } else {
+ const http_match = http_regex.exec(proto_match[2]);
+ if (!http_match)
+ return undefined;
+ [deco.domain, deco.path, deco.query] = http_match.slice(1, 4);
+ }
+
+ if (deco.domain)
+ deco.domain = deco.domain.split(".");
- domain = domain.split(".");
- let path_trailing_dash =
- path && path[path.length - 1] === "/";
- path = (path || "").split("/").filter(s => s !== "");
- path.unshift("");
+ const leading_dash = deco.path[0] === "/";
+ deco.trailing_dash = deco.path[deco.path.length - 1] === "/";
+ deco.path = deco.path.split("/").filter(s => s !== "");
+ if (leading_dash || deco.path.length === 0)
+ deco.path.unshift("");
- return {proto, domain, path, query, path_trailing_dash};
+ return deco;
}
/* Be sane: both arguments should be arrays of length >= 2 */
@@ -104,84 +117,70 @@ function url_matches(url, pattern)
return false
}
- if (pattern_deco.proto !== url_deco.proto)
- return false;
-
- return domain_matches(url_deco.domain, pattern_deco.domain) &&
- path_matches(url_deco.path, url_deco.path_trailing_dash,
- pattern_deco.path, pattern_deco.path_trailing_dash);
+ return pattern_deco.proto === url_deco.proto &&
+ !(pattern_deco.proto === "file" && pattern_deco.trailing_dash) &&
+ !!url_deco.domain === !!pattern_deco.domain &&
+ (!url_deco.domain ||
+ domain_matches(url_deco.domain, pattern_deco.domain)) &&
+ path_matches(url_deco.path, url_deco.trailing_dash,
+ pattern_deco.path, pattern_deco.trailing_dash);
}
-/*
- * Call callback for every possible pattern that matches url. Return when there
- * are no more patterns or callback returns false.
- */
-function for_each_possible_pattern(url, callback)
+function* each_domain_pattern(domain_segments)
{
- const deco = deconstruct_url(url);
-
- if (deco === undefined) {
- console.log("bad url format", url);
- return;
+ for (let slice = 0; slice < domain_segments.length; slice++) {
+ const domain_part = domain_segments.slice(slice).join(".");
+ const domain_wildcards = [];
+ if (slice === 0)
+ yield domain_part;
+ if (slice === 1)
+ yield "*." + domain_part;
+ if (slice > 1)
+ yield "**." + domain_part;
+ yield "***." + domain_part;
}
+}
- for (let d_slice = 0; d_slice < deco.domain.length; d_slice++) {
- const domain_part = deco.domain.slice(d_slice).join(".");
- const domain_wildcards = [];
- if (d_slice === 0)
- domain_wildcards.push("");
- if (d_slice === 1)
- domain_wildcards.push("*.");
- if (d_slice > 0)
- domain_wildcards.push("**.");
- domain_wildcards.push("***.");
-
- for (const domain_wildcard of domain_wildcards) {
- const domain_pattern = domain_wildcard + domain_part;
-
- for (let s_slice = deco.path.length; s_slice > 0; s_slice--) {
- const path_part = deco.path.slice(0, s_slice).join("/");
- const path_wildcards = [];
- if (s_slice === deco.path.length) {
- if (deco.path_trailing_dash)
- path_wildcards.push("/");
- path_wildcards.push("");
- }
- if (s_slice === deco.path.length - 1 &&
- deco.path[s_slice] !== "*")
- path_wildcards.push("/*");
- if (s_slice < deco.path.length &&
- (deco.path[s_slice] !== "**" ||
- s_slice < deco.path.length - 1))
- path_wildcards.push("/**");
- if (deco.path[s_slice] !== "***" || s_slice < deco.path.length)
- path_wildcards.push("/***");
-
- for (const path_wildcard of path_wildcards) {
- const path_pattern = path_part + path_wildcard;
-
- const pattern = deco.proto + domain_pattern + path_pattern;
-
- if (callback(pattern) === false)
- return;
- }
- }
+function* each_path_pattern(path_segments, trailing_dash)
+{
+ for (let slice = path_segments.length; slice > 0; slice--) {
+ const path_part = path_segments.slice(0, slice).join("/");
+ const path_wildcards = [];
+ if (slice === path_segments.length) {
+ if (trailing_dash)
+ yield path_part + "/";
+ yield path_part;
}
+ if (slice === path_segments.length - 1 && path_segments[slice] !== "*")
+ yield path_part + "/*";
+ if (slice < path_segments.length - 1)
+ yield path_part + "/**";
+ if (slice < path_segments.length - 1 ||
+ path_segments[path_segments.length - 1] !== "***")
+ yield path_part + "/***";
}
}
-function possible_patterns(url)
+/* Generate every possible pattern that matches url. */
+function* each_url_pattern(url)
{
- const patterns = [];
- for_each_possible_pattern(url, patterns.push);
+ const deco = deconstruct_url(url);
- return patterns;
+ if (deco === undefined) {
+ console.log("bad url format", url);
+ return false;
+ }
+
+ const all_domains = deco.domain ? each_domain_pattern(deco.domain) : [""];
+ for (const domain of all_domains) {
+ for (const path of each_path_pattern(deco.path, deco.trailing_dash))
+ yield `${deco.proto}://${domain}${path}`;
+ }
}
/*
* EXPORTS_START
* EXPORT url_matches
- * EXPORT for_each_possible_pattern
- * EXPORT possible_patterns
+ * EXPORT each_url_pattern
* EXPORTS_END
*/
diff --git a/common/settings_query.js b/common/settings_query.js
index e85ae63..b54e580 100644
--- a/common/settings_query.js
+++ b/common/settings_query.js
@@ -8,30 +8,25 @@
/*
* IMPORTS_START
* IMPORT TYPE_PREFIX
- * IMPORT for_each_possible_pattern
+ * IMPORT each_url_pattern
* IMPORTS_END
*/
-function check_pattern(storage, pattern, multiple, matched)
-{
- const settings = storage.get(TYPE_PREFIX.PAGE, pattern);
-
- if (settings === undefined)
- return;
-
- matched.push([pattern, settings]);
-
- if (!multiple)
- return false;
-}
-
function query(storage, url, multiple)
{
const matched = [];
const cb = p => check_pattern(storage, p, multiple, matched);
- for_each_possible_pattern(url, cb);
+ for (const pattern of each_url_pattern(url)) {
+ const result = [pattern, storage.get(TYPE_PREFIX.PAGE, pattern)];
+ if (result[1] === undefined)
+ continue;
+
+ if (!multiple)
+ return result;
+ matched.push(result);
+ }
- return multiple ? matched : (matched[0] || [undefined, undefined]);
+ return multiple ? matched : [undefined, undefined];
}
function query_best(storage, url)
diff --git a/common/signing.js b/common/signing.js
new file mode 100644
index 0000000..2171714
--- /dev/null
+++ b/common/signing.js
@@ -0,0 +1,73 @@
+/**
+ * part of Hachette
+ * Functions related to "signing" of data, refactored to a separate file.
+ *
+ * Copyright (C) 2021 Wojtek Kosior
+ * Redistribution terms are gathered in the `copyright' file.
+ */
+
+/*
+ * IMPORTS_START
+ * IMPORT sha256
+ * IMPORT browser
+ * IMPORT is_chrome
+ * IMPORTS_END
+ */
+
+/*
+ * In order to make certain data synchronously accessible in certain contexts,
+ * hachette smuggles it in string form in places like cookies, URLs and headers.
+ * When using the smuggled data, we first need to make sure it isn't spoofed.
+ * For that, we use this pseudo-signing mechanism.
+ *
+ * Despite what name suggests, no assymetric cryptography is involved, as it
+ * would bring no additional benefits and would incur bigger performance
+ * overhead. Instead, we hash the string data together with some secret value
+ * that is supposed to be known only by this browser instance. Resulting hash
+ * sum plays the role of the signature. In the hash we also include current
+ * time. This way, even if signed data leaks (which shouldn't happen in the
+ * first place), an attacker won't be able to re-use it indefinitely.
+ *
+ * The secret shared between execution contexts has to be available
+ * synchronously. Under Mozilla, this is the extension's per-session id. Under
+ * Chromium, this is the key that resides in the manifest.
+ *
+ * An idea to (under Chromium) instead store the secret in a file fetched
+ * synchronously using XMLHttpRequest is being considered.
+ */
+
+function get_secret()
+{
+ if (is_chrome)
+ return browser.runtime.getManifest().key.substring(0, 50);
+ else
+ return browser.runtime.getURL("dummy");
+}
+
+function extract_signed(signature, signed_data)
+{
+ const match = /^([1-9][0-9]{12}|0)_(.*)$/.exec(signed_data);
+ if (!match)
+ return {fail: "bad format"};
+
+ const result = {time: parseInt(match[1]), data: match[2]};
+ if (sign_data(result.data, result.time)[0] !== signature)
+ result.fail = "bad signature";
+
+ return result;
+}
+
+/*
+ * Sign a given string for a given time. Time should be either 0 or in the range
+ * 10^12 <= time < 10^13.
+ */
+function sign_data(data, time) {
+ return [sha256(get_secret() + time + data), `${time}_${data}`];
+}
+
+/*
+ * EXPORTS_START
+ * EXPORT extract_signed
+ * EXPORT sign_data
+ * EXPORTS_END
+ */
diff --git a/common/storage_light.js b/common/storage_light.js
new file mode 100644
index 0000000..067bf0c
--- /dev/null
+++ b/common/storage_light.js
@@ -0,0 +1,129 @@
+/**
+ * part of Hachette
+ * Storage manager, lighter than the previous one.
+ *
+ * Copyright (C) 2021 Wojtek Kosior
+ * Redistribution terms are gathered in the `copyright' file.
+ */
+
+/*
+ * IMPORTS_START
+ * IMPORT TYPE_PREFIX
+ * IMPORT raw_storage
+ * IMPORT is_mozilla
+ * IMPORT observables
+ */
+
+const reg_spec = new Set(["\\", "[", "]", "(", ")", "{", "}", ".", "*", "+"]);
+const escape_reg_special = c => reg_spec.has(c) ? "\\" + c : c;
+
+function make_regex(name)
+{
+ return new RegExp(`^${name.split("").map(escape_reg_special).join("")}\$`);
+}
+
+const listeners_by_callback = new Map();
+
+function listen(callback, prefix, name)
+{
+ let by_prefix = listeners_by_callback.get(callback);
+ if (!by_prefix) {
+ by_prefix = new Map();
+ listeners_by_callback.set(callback, by_prefix);
+ }
+
+ let by_name = by_prefix.get(prefix);
+ if (!by_name) {
+ by_name = new Map();
+ by_prefix.set(prefix, by_name);
+ }
+
+ let name_reg = by_name.get(name);
+ if (!name_reg) {
+ name_reg = name.test ? name : make_regex(name);
+ by_name.set(name, name_reg);
+ }
+}
+
+function no_listen(callback, prefix, name)
+{
+ const by_prefix = listeners_by_callback.get(callback);
+ if (!by_prefix)
+ return;
+
+ const by_name = by_prefix.get(prefix);
+ if (!by_name)
+ return;
+
+ const name_reg = by_name.get(name);
+ if (!name_reg)
+ return;
+
+ by_name.delete(name);
+
+ if (by_name.size === 0)
+ by_prefix.delete(prefix);
+
+ if (by_prefix.size === 0)
+ listeners_by_callback.delete(callback);
+}
+
+function storage_change_callback(changes, area)
+{
+ if (is_mozilla && area !== "local")
+ {console.log("area", area);return;}
+
+ for (const item of Object.keys(changes)) {
+ for (const [callback, by_prefix] of listeners_by_callback.entries()) {
+ const by_name = by_prefix.get(item[0]);
+ if (!by_name)
+ continue;
+
+ for (const reg of by_name.values()) {
+ if (!reg.test(item.substring(1)))
+ continue;
+
+ try {
+ callback(item, changes[item]);
+ } catch(e) {
+ console.error(e);
+ }
+ }
+ }
+ }
+}
+
+raw_storage.listen(storage_change_callback);
+
+
+const created_observables = new Map();
+
+async function observe(prefix, name)
+{
+ const observable = observables.make();
+ const callback = (it, ch) => observables.set(observable, ch.newValue);
+ listen(callback, prefix, name);
+ created_observables.set(observable, [callback, prefix, name]);
+ observables.silent_set(observable, await raw_storage.get(prefix + name));
+
+ return observable;
+}
+
+const observe_var = name => observe(TYPE_PREFIX.VAR, name);
+
+function no_observe(observable)
+{
+ no_listen(...created_observables.get(observable) || []);
+ created_observables.delete(observable);
+}
+
+const light_storage = {};
+Object.assign(light_storage, raw_storage);
+Object.assign(light_storage,
+ {listen, no_listen, observe, observe_var, no_observe});
+
+/*
+ * EXPORTS_START
+ * EXPORT light_storage
+ * EXPORTS_END
+ */
diff --git a/common/storage_raw.js b/common/storage_raw.js
new file mode 100644
index 0000000..4c02ee4
--- /dev/null
+++ b/common/storage_raw.js
@@ -0,0 +1,54 @@
+/**
+ * part of Hachette
+ * Basic wrappers for storage API functions.
+ *
+ * Copyright (C) 2021 Wojtek Kosior
+ * Redistribution terms are gathered in the `copyright' file.
+ */
+
+/*
+ * IMPORTS_START
+ * IMPORT TYPE_PREFIX
+ * IMPORT browser
+ * IMPORT is_chrome
+ * IMPORTS_END
+ */
+
+async function get(key)
+{
+ /* Fix for fact that Chrome does not use promises here */
+ const promise = is_chrome ?
+ new Promise(resolve => chrome.storage.local.get(key, resolve)) :
+ browser.storage.local.get(key);
+
+ return (await promise)[key];
+}
+
+async function set(key_or_object, value)
+{
+ const arg = typeof key_or_object === "object" ?
+ key_or_object : {[key_or_object]: value};
+ return browser.storage.local.set(arg);
+}
+
+async function set_var(name, value)
+{
+ return set(TYPE_PREFIX.VAR + name, value);
+}
+
+async function get_var(name)
+{
+ return get(TYPE_PREFIX.VAR + name);
+}
+
+const on_changed = browser.storage.onChanged || browser.storage.local.onChanged;
+const listen = cb => on_changed.addListener(cb);
+const no_listen = cb => on_changed.removeListener(cb);
+
+const raw_storage = {get, set, get_var, set_var, listen, no_listen};
+
+/*
+ * EXPORTS_START
+ * EXPORT raw_storage
+ * EXPORTS_END
+ */