commit d8e2cbbf20dd4f5ecb755de50cc740892b6c23b4 parent 24c2e6869ae1c319543d9cd913f6280d4e595b98 Author: Pier Angelo Vendrame <pierov@torproject.org> Date: Mon, 21 Feb 2022 15:39:11 +0100 TB 40458: Implement .tor.onion aliases We have enabled HTTPS-Only mode, therefore we do not need HTTPS-Everywhere anymore. However, we want to keep supporting .tor.onion aliases (especially for securedrop). Therefore, in this patch we implemented the parsing of HTTPS-Everywhere rulesets, and the redirect of .tor.onion domains. Actually, Tor Browser believes they are actual domains. We change them on the fly on the SOCKS proxy requests to resolve the domain, and on the code that verifies HTTPS certificates. Diffstat:
31 files changed, 2177 insertions(+), 6 deletions(-)
diff --git a/browser/app/profile/000-tor-browser.js b/browser/app/profile/000-tor-browser.js @@ -142,3 +142,5 @@ pref("browser.torcircuitpanel.loglevel", "Log"); pref("browser.tor_android.log_level", "Info"); pref("browser.dragdropfilter.log_level", "Warn"); pref("browser.onionAuthPrompt.loglevel", "Warn"); +pref("browser.onionalias.log_level", "Warn"); +pref("browser.torRequestWatch.log_level", "Warn"); diff --git a/browser/components/BrowserComponents.manifest b/browser/components/BrowserComponents.manifest @@ -63,6 +63,7 @@ category browser-first-window-ready moz-src:///browser/modules/ClipboardPrivacy. category browser-first-window-ready moz-src:///browser/modules/SecurityLevelNotification.sys.mjs SecurityLevelNotification.ready category browser-first-window-ready moz-src:///toolkit/modules/DragDropFilter.sys.mjs DragDropFilter.init category browser-first-window-ready moz-src:///browser/modules/TorSettingsNotification.sys.mjs TorSettingsNotification.ready +category browser-first-window-ready moz-src:///browser/components/onionservices/OnionAliasStore.sys.mjs OnionAliasStore.init category browser-idle-startup moz-src:///browser/components/places/PlacesUIUtils.sys.mjs PlacesUIUtils.unblockToolbars category browser-idle-startup resource:///modules/BuiltInThemes.sys.mjs BuiltInThemes.ensureBuiltInThemes @@ -109,5 +110,6 @@ category browser-quit-application-granted resource://gre/modules/UpdateListener. #endif category browser-quit-application-granted moz-src:///browser/components/urlbar/UrlbarSearchTermsPersistence.sys.mjs UrlbarSearchTermsPersistence.uninit category browser-quit-application-granted moz-src:///browser/components/ipprotection/IPProtectionService.sys.mjs IPProtectionService.uninit +category browser-quit-application-granted moz-src:///browser/components/onionservices/OnionAliasStore.sys.mjs OnionAliasStore.uninit category browser-newtab-external-component moz-src:///browser/components/search/SearchUIUtils.sys.mjs SearchNewTabComponentsRegistrant diff --git a/browser/components/DesktopActorRegistry.sys.mjs b/browser/components/DesktopActorRegistry.sys.mjs @@ -679,6 +679,19 @@ let JSWINDOWACTORS = { enablePreference: "accessibility.blockautorefresh", }, + Rulesets: { + parent: { + esModuleURI: "resource:///modules/RulesetsParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///modules/RulesetsChild.sys.mjs", + events: { + DOMWindowCreated: {}, + }, + }, + matches: ["about:rulesets*"], + }, + ScreenshotsComponent: { parent: { esModuleURI: "resource:///modules/ScreenshotsUtils.sys.mjs", diff --git a/browser/components/about/AboutRedirector.cpp b/browser/components/about/AboutRedirector.cpp @@ -112,6 +112,11 @@ static const RedirEntry kRedirMap[] = { nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | nsIAboutModule::ALLOW_SCRIPT}, #endif + {"rulesets", "chrome://browser/content/rulesets/aboutRulesets.html", + nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::URI_MUST_LOAD_IN_CHILD | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::IS_SECURE_CHROME_UI}, {"sessionrestore", "chrome://browser/content/aboutSessionRestore.xhtml", nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT | nsIAboutModule::IS_SECURE_CHROME_UI}, diff --git a/browser/components/about/components.conf b/browser/components/about/components.conf @@ -26,6 +26,7 @@ pages = [ 'restartrequired', # Removed 'rights'. tor-browser#43901. # Removed 'robots'. tor-browser#42831. + 'rulesets', 'sessionrestore', 'settings', 'tabcrashed', diff --git a/browser/components/moz.build b/browser/components/moz.build @@ -62,6 +62,7 @@ DIRS += [ "qrcode", "reportbrokensite", "resistfingerprinting", + "rulesets", "screenshots", "search", "security", diff --git a/browser/components/onionservices/OnionAliasStore.sys.mjs b/browser/components/onionservices/OnionAliasStore.sys.mjs @@ -0,0 +1,565 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + TorConnect: "resource://gre/modules/TorConnect.sys.mjs", + TorConnectTopics: "resource://gre/modules/TorConnect.sys.mjs", + TorRequestWatch: + "moz-src:///browser/components/onionservices/TorRequestWatch.sys.mjs", +}); + +/* OnionAliasStore observer topics */ +export const OnionAliasStoreTopics = Object.freeze({ + ChannelsChanged: "onionaliasstore:channels-changed", +}); + +const SECURE_DROP = { + name: "SecureDropTorOnion2021", + pathPrefix: "https://securedrop.org/https-everywhere-2021/", + jwk: { + kty: "RSA", + e: "AQAB", + n: "vsC7BNafkRe8Uh1DUgCkv6RbPQMdJgAKKnWdSqQd7tQzU1mXfmo_k1Py_2MYMZXOWmqSZ9iwIYkykZYywJ2VyMGve4byj1sLn6YQoOkG8g5Z3V4y0S2RpEfmYumNjTzfq8nxtLnwjaYd4sCUd5wa0SzeLrpRQuXo2bF3QuUF2xcbLJloxX1MmlsMMCdBc-qGNonLJ7bpn_JuyXlDWy1Fkeyw1qgjiOdiRIbMC1x302zgzX6dSrBrNB8Cpsh-vCE0ZjUo8M9caEv06F6QbYmdGJHM0ZZY34OHMSNdf-_qUKIV_SuxuSuFE99tkAeWnbWpyI1V-xhVo1sc7NzChP8ci2TdPvI3_0JyAuCvL6zIFqJUJkZibEUghhg6F09-oNJKpy7rhUJq7zZyLXJsvuXnn0gnIxfjRvMcDfZAKUVMZKRdw7fwWzwQril4Ib0MQOVda9vb_4JMk7Gup-TUI4sfuS4NKwsnKoODIO-2U5QpJWdtp1F4AQ1pBv8ajFl1WTrVGvkRGK0woPWaO6pWyJ4kRnhnxrV2FyNNt3JSR-0JEjhFWws47kjBvpr0VRiVRFppKA-plKs4LPlaaCff39TleYmY3mETe3w1GIGc2Lliad32Jpbx496IgDe1K3FMBEoKFZfhmtlRSXft8NKgSzPt2zkatM9bFKfaCYRaSy7akbk", + }, + scope: /^https?:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.securedrop\.tor\.onion\//, + enabled: true, + mappings: [], + currentTimestamp: 0, +}; + +const kPrefOnionAliasEnabled = "browser.urlbar.onionRewrites.enabled"; + +const log = console.createInstance({ + maxLogLevelPref: "browser.onionalias.log_level", + prefix: "OnionAlias", +}); + +// Inspired by aboutMemory.js and PingCentre.jsm +function gunzip(buffer) { + return new Promise(resolve => { + const listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance( + Ci.nsIStreamLoader + ); + listener.init({ + onStreamComplete(loader, context, status, length, result) { + resolve(String.fromCharCode(...result)); + }, + }); + const scs = Cc["@mozilla.org/streamConverters;1"].getService( + Ci.nsIStreamConverterService + ); + const converter = scs.asyncConvertData( + "gzip", + "uncompressed", + listener, + null + ); + const stream = Cc[ + "@mozilla.org/io/arraybuffer-input-stream;1" + ].createInstance(Ci.nsIArrayBufferInputStream); + stream.setData(buffer, 0, buffer.byteLength); + converter.onStartRequest(null, null); + converter.onDataAvailable(null, stream, 0, buffer.byteLength); + converter.onStopRequest(null, null, null); + }); +} + +/** + * A channel that distributes Onion aliases. + * + * Each channel needs: + * - a name + * - a key used to sign the rules + * - a path prefix that will be used to build the URLs used to fetch updates + * - a scope (the apex domain for all aliases, and it must be a subdomain of + * .tor.onion). + */ +class Channel { + static get SIGN_ALGORITHM() { + return { + name: "RSA-PSS", + saltLength: 32, + hash: { name: "SHA-256" }, + }; + } + + #enabled; + + constructor(name, pathPrefix, jwk, scope, enabled) { + this.name = name; + this.pathPrefix = pathPrefix; + this.jwk = jwk; + this.scope = scope; + this.#enabled = enabled; + + this.mappings = []; + this.currentTimestamp = 0; + this.latestTimestamp = 0; + } + + async updateLatestTimestamp() { + const timestampUrl = this.pathPrefix + "/latest-rulesets-timestamp"; + log.debug(`Updating ${this.name} timestamp from ${timestampUrl}`); + const response = await fetch(timestampUrl); + if (!response.ok) { + throw Error(`Could not fetch timestamp for ${this.name}`, { + cause: response.status, + }); + } + const timestampStr = await response.text(); + const timestamp = parseInt(timestampStr); + // Avoid hijacking, sanitize the timestamp + if (isNaN(timestamp)) { + throw Error("Latest timestamp is not a number"); + } + log.debug(`Updated ${this.name} timestamp: ${timestamp}`); + this.latestTimestamp = timestamp; + } + + async makeKey() { + return crypto.subtle.importKey( + "jwk", + this.jwk, + Channel.SIGN_ALGORITHM, + false, + ["verify"] + ); + } + + async downloadVerifiedRules() { + log.debug(`Downloading and verifying ruleset for ${this.name}`); + + const key = await this.makeKey(); + const signatureUrl = + this.pathPrefix + `/rulesets-signature.${this.latestTimestamp}.sha256`; + const signatureResponse = await fetch(signatureUrl); + if (!signatureResponse.ok) { + throw Error("Could not fetch the rules signature"); + } + const signature = await signatureResponse.arrayBuffer(); + + const rulesUrl = + this.pathPrefix + `/default.rulesets.${this.latestTimestamp}.gz`; + const rulesResponse = await fetch(rulesUrl); + if (!rulesResponse.ok) { + throw Error("Could not fetch rules"); + } + const rulesGz = await rulesResponse.arrayBuffer(); + + if ( + !(await crypto.subtle.verify( + Channel.SIGN_ALGORITHM, + key, + signature, + rulesGz + )) + ) { + throw Error("Could not verify rules signature"); + } + log.debug( + `Downloaded and verified rules for ${this.name}, now uncompressing` + ); + this.#makeMappings(JSON.parse(await gunzip(rulesGz))); + } + + #makeMappings(rules) { + const toTest = /^https?:\/\/[a-zA-Z0-9\.]{56}\.onion$/; + const mappings = []; + rules.rulesets.forEach(rule => { + if (rule.rule.length != 1) { + log.warn(`Unsupported rule lenght: ${rule.rule.length}`); + return; + } + if (!toTest.test(rule.rule[0].to)) { + log.warn( + `Ignoring rule, because of a malformed to: ${rule.rule[0].to}` + ); + return; + } + const toHostname = URL.parse(rule.rule[0].to)?.hostname; + if (!toHostname) { + log.error( + "Unable to parse the URL and the hostname from the to rule", + rule.rule[0].to + ); + return; + } + + let fromRe; + try { + fromRe = new RegExp(rule.rule[0].from); + } catch (err) { + log.error("Malformed from field", rule.rule[0].from, err); + return; + } + for (const target of rule.target) { + if ( + target.endsWith(".tor.onion") && + this.scope.test(`http://${target}/`) && + fromRe.test(`http://${target}/`) + ) { + mappings.push([target, toHostname]); + } else { + log.warn("Ignoring malformed rule", rule); + } + } + }); + this.mappings = mappings; + this.currentTimestamp = rules.timestamp; + log.debug(`Updated mappings for ${this.name}`, mappings); + } + + async updateMappings(force) { + force = force === undefined ? false : !!force; + if (!this.#enabled && !force) { + return; + } + await this.updateLatestTimestamp(); + if (this.latestTimestamp <= this.currentTimestamp && !force) { + log.debug( + `Rules for ${this.name} are already up to date, skipping update` + ); + return; + } + await this.downloadVerifiedRules(); + } + + get enabled() { + return this.#enabled; + } + set enabled(enabled) { + this.#enabled = enabled; + if (!enabled) { + this.mappings = []; + this.currentTimestamp = 0; + this.latestTimestamp = 0; + } + } + + toJSON() { + let scope = this.scope.toString(); + scope = scope.substr(1, scope.length - 2); + return { + name: this.name, + pathPrefix: this.pathPrefix, + jwk: this.jwk, + scope, + enabled: this.#enabled, + mappings: this.mappings, + currentTimestamp: this.currentTimestamp, + }; + } + + static fromJSON(obj) { + let channel = new Channel( + obj.name, + obj.pathPrefix, + obj.jwk, + new RegExp(obj.scope), + obj.enabled + ); + if (obj.enabled) { + channel.mappings = obj.mappings; + channel.currentTimestamp = obj.currentTimestamp; + } + return channel; + } +} + +/** + * The manager of onion aliases. + * It allows creating, reading, updating and deleting channels and it keeps them + * updated. + * + * This class is a singleton which should be accessed with OnionAliasStore. + */ +class _OnionAliasStore { + static get RULESET_CHECK_INTERVAL() { + return 86400 * 1000; // 1 day, like HTTPS-Everywhere + } + + #channels = new Map(); + #rulesetTimeout = null; + #lastCheck = 0; + #storage = null; + + async init() { + lazy.TorRequestWatch.start(); + await this.#loadSettings(); + if (this.enabled && !lazy.TorConnect.shouldShowTorConnect) { + await this.#startUpdates(); + } else { + Services.obs.addObserver(this, lazy.TorConnectTopics.BootstrapComplete); + } + Services.prefs.addObserver(kPrefOnionAliasEnabled, this); + } + + uninit() { + this.#clear(); + if (this.#rulesetTimeout) { + clearTimeout(this.#rulesetTimeout); + } + this.#rulesetTimeout = null; + + Services.obs.removeObserver(this, lazy.TorConnectTopics.BootstrapComplete); + Services.prefs.removeObserver(kPrefOnionAliasEnabled, this); + + lazy.TorRequestWatch.stop(); + } + + async getChannels() { + if (this.#storage === null) { + await this.#loadSettings(); + } + return Array.from(this.#channels.values(), ch => ch.toJSON()); + } + + async setChannel(chanData) { + const name = chanData.name?.trim(); + if (!name) { + throw Error("Name cannot be empty"); + } + + // This will throw if the URL is invalid. + new URL(chanData.pathPrefix); + const scope = new RegExp(chanData.scope); + const ch = new Channel( + name, + chanData.pathPrefix, + chanData.jwk, + scope, + !!chanData.enabled + ); + // Call makeKey to make it throw if the key is invalid + await ch.makeKey(); + this.#channels.set(name, ch); + this.#applyMappings(); + this.#saveSettings(); + setTimeout(this.#notifyChanges.bind(this), 1); + return ch; + } + + enableChannel(name, enabled) { + const channel = this.#channels.get(name); + if (channel !== null) { + channel.enabled = enabled; + this.#applyMappings(); + this.#saveSettings(); + this.#notifyChanges(); + if (this.enabled && enabled && !channel.currentTimestamp) { + this.updateChannel(name); + } + } + } + + async updateChannel(name) { + if (!this.enabled) { + throw Error("Onion Aliases are disabled"); + } + const channel = this.#channels.get(name); + if (channel === null) { + throw Error("Channel not found"); + } + await channel.updateMappings(true); + this.#saveSettings(); + this.#applyMappings(); + setTimeout(this.#notifyChanges.bind(this), 1); + return channel; + } + + deleteChannel(name) { + if (this.#channels.delete(name)) { + this.#saveSettings(); + this.#applyMappings(); + this.#notifyChanges(); + } + } + + async #loadSettings() { + if (this.#storage !== null) { + return; + } + this.#channels = new Map(); + this.#storage = new lazy.JSONFile({ + path: PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "onion-aliases.json" + ), + dataPostProcessor: this.#settingsProcessor.bind(this), + }); + await this.#storage.load(); + log.debug("Loaded settings", this.#storage.data, this.#storage.path); + this.#applyMappings(); + this.#notifyChanges(); + } + + #settingsProcessor(data) { + if ("lastCheck" in data) { + this.#lastCheck = data.lastCheck; + } else { + data.lastCheck = 0; + } + if (!("channels" in data) || !Array.isArray(data.channels)) { + data.channels = [SECURE_DROP]; + // Force updating + data.lastCheck = 0; + } + const channels = new Map(); + data.channels = data.channels.filter(ch => { + try { + channels.set(ch.name, Channel.fromJSON(ch)); + } catch (err) { + log.error("Could not load a channel", err, ch); + return false; + } + return true; + }); + this.#channels = channels; + return data; + } + + #saveSettings() { + if (this.#storage === null) { + throw Error("Settings have not been loaded"); + } + this.#storage.data.lastCheck = this.#lastCheck; + this.#storage.data.channels = Array.from(this.#channels.values(), ch => + ch.toJSON() + ); + this.#storage.saveSoon(); + } + + #addMapping(shortOnionHost, longOnionHost) { + const service = Cc["@torproject.org/onion-alias-service;1"].getService( + Ci.IOnionAliasService + ); + service.addOnionAlias(shortOnionHost, longOnionHost); + } + + #clear() { + const service = Cc["@torproject.org/onion-alias-service;1"].getService( + Ci.IOnionAliasService + ); + service.clearOnionAliases(); + } + + #applyMappings() { + this.#clear(); + for (const ch of this.#channels.values()) { + if (!ch.enabled) { + continue; + } + for (const [short, long] of ch.mappings) { + this.#addMapping(short, long); + } + } + } + + async #periodicRulesetCheck() { + if (!this.enabled) { + log.debug("Onion Aliases are disabled, not updating rulesets."); + return; + } + log.debug("Begin scheduled ruleset update"); + this.#lastCheck = Date.now(); + let anyUpdated = false; + for (const ch of this.#channels.values()) { + if (!ch.enabled) { + log.debug(`Not updating ${ch.name} because not enabled`); + continue; + } + log.debug(`Updating ${ch.name}`); + try { + await ch.updateMappings(); + anyUpdated = true; + } catch (err) { + log.error(`Could not update mappings for channel ${ch.name}`, err); + } + } + if (anyUpdated) { + this.#saveSettings(); + this.#applyMappings(); + this.#notifyChanges(); + } else { + log.debug("No channel has been updated, avoid saving"); + } + this.#scheduleCheck(_OnionAliasStore.RULESET_CHECK_INTERVAL); + } + + async #startUpdates() { + // This is a private function, so we expect the callers to verify whether + // onion aliases are enabled. + // Callees will also do, so we avoid an additional check here. + const dt = Date.now() - this.#lastCheck; + let force = false; + for (const ch of this.#channels.values()) { + if (ch.enabled && !ch.currentTimestamp) { + // Edited while being offline or some other error happened + force = true; + break; + } + } + if (dt > _OnionAliasStore.RULESET_CHECK_INTERVAL || force) { + log.debug( + `Mappings are stale (${dt}), or force check requested (${force}), checking them immediately` + ); + await this.#periodicRulesetCheck(); + } else { + this.#scheduleCheck(_OnionAliasStore.RULESET_CHECK_INTERVAL - dt); + } + } + + #scheduleCheck(dt) { + if (this.#rulesetTimeout) { + log.warn("The previous update timeout was not null"); + clearTimeout(this.#rulesetTimeout); + } + if (!this.enabled) { + log.warn( + "Ignoring the scheduling of a new check because the Onion Alias feature is currently disabled." + ); + this.#rulesetTimeout = null; + return; + } + log.debug(`Scheduling ruleset update in ${dt}`); + this.#rulesetTimeout = setTimeout(() => { + this.#rulesetTimeout = null; + this.#periodicRulesetCheck(); + }, dt); + } + + #notifyChanges() { + Services.obs.notifyObservers( + Array.from(this.#channels.values(), ch => ch.toJSON()), + OnionAliasStoreTopics.ChannelsChanged + ); + } + + get enabled() { + return Services.prefs.getBoolPref(kPrefOnionAliasEnabled, true); + } + + observe(aSubject, aTopic) { + if (aTopic === "nsPref:changed") { + if (this.enabled) { + this.#startUpdates(); + } else if (this.#rulesetTimeout) { + clearTimeout(this.#rulesetTimeout); + this.#rulesetTimeout = null; + } + } else if ( + aTopic === lazy.TorConnectTopics.BootstrapComplete && + this.enabled + ) { + this.#startUpdates(); + } + } +} + +export const OnionAliasStore = new _OnionAliasStore(); diff --git a/browser/components/onionservices/TorRequestWatch.sys.mjs b/browser/components/onionservices/TorRequestWatch.sys.mjs @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const log = console.createInstance({ + maxLogLevelPref: "browser.torRequestWatch.log_level", + prefix: "TorRequestWatch", +}); + +/** + * This request observer blocks all the cross-site requests to *.tor.onion + * domains to prevent fingerprinting Onion alias mechanisms (or their lack). + */ +class RequestObserver { + static #topics = [ + "http-on-modify-request", + "http-on-examine-response", + "http-on-examine-cached-response", + "http-on-examine-merged-response", + ]; + #asObserver(addOrRemove) { + const action = Services.obs[`${addOrRemove}Observer`].bind(Services.obs); + for (const topic of RequestObserver.#topics) { + action(this, topic); + } + } + + start() { + this.#asObserver("add"); + log.debug("Started"); + } + stop() { + this.#asObserver("remove"); + log.debug("Stopped"); + } + + // nsIObserver implementation + observe(subject, topic) { + try { + let channel = ChannelWrapper.get( + subject.QueryInterface(Ci.nsIHttpChannel) + ); + switch (topic) { + case "http-on-modify-request": + this.onRequest(channel); + break; + case "http-on-examine-cached-response": + case "http-on-examine-merged-response": + channel.isCached = true; + // falls through + case "http-on-examine-response": + this.onResponse(channel); + break; + } + } catch (e) { + log.error(e); + } + } + + onRequest(channel) { + if (this.shouldBlind(channel, channel.documentURL)) { + log.warn(`Blocking cross-site ${channel.finalURL} ${channel.type} load.`); + channel.cancel(Cr.NS_ERROR_ABORT); + } + } + onResponse(channel) { + if (!channel.documentURL && this.shouldBlind(channel, channel.originURL)) { + const COOP = "cross-origin-opener-policy"; + // we break window.opener references if needed to mitigate XS-Leaks + for (let h of channel.getResponseHeaders()) { + if (h.name.toLowerCase() === COOP && h.value === "same-origin") { + log.debug(`${COOP} is already same-origin, nothing to do.`); + return; + } + } + log.warn(`Blinding cross-site ${channel.finalURL} load.`); + channel.setResponseHeader(COOP, "same-origin-allow-popups"); + } + } + + isCrossOrigin(url1, url2) { + const origin1 = URL.parse(url1)?.origin; + const origin2 = URL.parse(url2)?.origin; + + if (!origin1 || !origin2) { + return true; + } + + return origin1 !== origin2; + } + shouldBlindCrossOrigin(uri) { + try { + let { host } = uri; + if (host.endsWith(".onion")) { + const previousPart = host.slice(-10, -6); + return ( + previousPart && (previousPart === ".tor" || previousPart === ".bit") + ); + } + } catch (e) { + // no host + } + return false; + } + shouldBlind(channel, sourceURL) { + return ( + sourceURL && + this.shouldBlindCrossOrigin(channel.finalURI) && + this.isCrossOrigin(channel.finalURL, sourceURL) + ); + } +} + +let observer; +export const TorRequestWatch = { + start() { + if (!observer) { + (observer = new RequestObserver()).start(); + } + }, + stop() { + if (observer) { + observer.stop(); + observer = null; + } + }, +}; diff --git a/browser/components/onionservices/moz.build b/browser/components/onionservices/moz.build @@ -4,3 +4,8 @@ EXTRA_JS_MODULES += [ "OnionLocationChild.sys.mjs", "OnionLocationParent.sys.mjs", ] + +MOZ_SRC_FILES += [ + "OnionAliasStore.sys.mjs", + "TorRequestWatch.sys.mjs", +] diff --git a/browser/components/rulesets/RulesetsChild.sys.mjs b/browser/components/rulesets/RulesetsChild.sys.mjs @@ -0,0 +1,9 @@ +// Copyright (c) 2022, The Tor Project, Inc. + +import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs"; + +/** + * The child actor part for about:rulesets. + * It does not do anything, as all the communication happens with RPM* calls. + */ +export class RulesetsChild extends RemotePageChild {} diff --git a/browser/components/rulesets/RulesetsParent.sys.mjs b/browser/components/rulesets/RulesetsParent.sys.mjs @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + OnionAliasStore, + OnionAliasStoreTopics, +} from "moz-src:///browser/components/onionservices/OnionAliasStore.sys.mjs"; + +const kShowWarningPref = "torbrowser.rulesets.show_warning"; + +/** + * This class relays the changes the user's changes from about:rulesets to the + * rulesets database. + */ +export class RulesetsParent extends JSWindowActorParent { + constructor(...args) { + super(...args); + + const self = this; + this.observer = { + observe(aSubject, aTopic) { + const obj = aSubject?.wrappedJSObject; + if (aTopic === OnionAliasStoreTopics.ChannelsChanged && obj) { + self.sendAsyncMessage("rulesets:channels-change", obj); + } + }, + }; + Services.obs.addObserver( + this.observer, + OnionAliasStoreTopics.ChannelsChanged + ); + } + + didDestroy() { + Services.obs.removeObserver( + this.observer, + OnionAliasStoreTopics.ChannelsChanged + ); + } + + async receiveMessage(message) { + switch (message.name) { + // RPMSendAsyncMessage + case "rulesets:delete-channel": + OnionAliasStore.deleteChannel(message.data); + break; + case "rulesets:enable-channel": + OnionAliasStore.enableChannel(message.data.name, message.data.enabled); + break; + case "rulesets:set-show-warning": + Services.prefs.setBoolPref(kShowWarningPref, message.data); + break; + // RPMSendQuery + case "rulesets:get-channels": + return OnionAliasStore.getChannels(); + case "rulesets:get-init-args": + return { + showWarning: Services.prefs.getBoolPref(kShowWarningPref, true), + }; + case "rulesets:set-channel": { + const ch = await OnionAliasStore.setChannel(message.data); + return ch; + } + case "rulesets:update-channel": + // We need to catch any error in this way, because in case of an + // exception, RPMSendQuery does not return on the other side + try { + const channel = await OnionAliasStore.updateChannel(message.data); + return channel; + } catch (err) { + console.error("Cannot update the channel", err); + return { error: err.toString() }; + } + } + return undefined; + } +} diff --git a/browser/components/rulesets/content/aboutRulesets.css b/browser/components/rulesets/content/aboutRulesets.css @@ -0,0 +1,300 @@ +/* Copyright (c) 2022, The Tor Project, Inc. */ + +/* General rules */ + +html, +body { + width: 100%; + height: 100%; +} + +label { + display: flex; + align-items: center; + padding: 6px 0; +} + +input[type="text"] { + margin: 0; + width: 360px; + max-width: 100%; +} + +textarea { + margin: 0; + width: var(--content-width); + max-width: 100%; + box-sizing: border-box; +} + +select, +option { + font-weight: var(--font-weight-bold); +} + +dt { + margin: var(--ruleset-vmargin) 0 0 0; + padding: 0; + color: var(--text-color-deemphasized); + font-size: var(--font-size-small); +} + +dd { + margin: 8px 0 0 0; + padding: 0; + max-width: 600px; + box-sizing: border-box; +} + +hr { + width: 40px; + margin: 0; + border: none; + border-top: 1px solid var(--border-color); +} + +.hidden { + display: none !important; +} + +/* Initial warning */ + +#warning-wrapper { + display: none; +} + +.state-warning #warning-wrapper { + display: flex; + align-items: center; + height: 100%; +} + +#warning { + margin-top: -20vh; + padding: 0 160px; + background-image: url("chrome://global/skin/icons/warning.svg"); + background-position: 84px 0; + background-repeat: no-repeat; + background-size: 48px; + fill: #ffbd4f; + -moz-context-properties: fill; +} + +#warning:dir(rtl) { + background-position: right 84px top 0; +} + +#warning-description { + margin: 30px 0 16px 0; +} + +#warning-buttonbar { + margin-top: 30px; + text-align: right; +} + +/* Actual content */ + +:root { + --sidebar-width: 320px; + --content-width: 600px; + --ruleset-vmargin: 40px; +} + +#main-content { + display: flex; + height: 100%; +} + +.state-warning #main-content { + display: none; +} + +section { + display: none; + flex: 1 0 auto; + padding: 40px; +} + +.title { + display: flex; + align-items: center; + width: var(--content-width); + max-width: 100%; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.title h1 { + margin: 0; + padding: 0; + padding-inline-start: 35px; + font-size: var(--font-size-xlarge); + font-weight: var(--font-weight-bold); + background-image: url("chrome://browser/content/rulesets/securedrop.svg"); + background-position: 0 center; + background-size: 22px; + min-height: 22px; + background-repeat: no-repeat; +} + +#main-content h1:dir(rtl) { + background-position: right 0 top 4px; +} + +/* Ruleset list */ + +aside { + display: flex; + flex-direction: column; + flex: 0 0 var(--sidebar-width); + box-sizing: border-box; + + border-inline-end: 1px solid var(--border-color); + background-color: var(--background-color-box); +} + +#ruleset-heading { + padding: 16px; + text-align: center; + font-weight: var(--font-weight-bold); + border-bottom: 1px solid var(--border-color); +} + +#ruleset-list-container { + flex: 1; +} + +#ruleset-list-empty { + padding: 16px; + text-align: center; +} + +#ruleset-list-empty-description { + font-size: var(--font-size-small); +} + +#ruleset-list { + margin: 0; + padding: 0; +} + +#ruleset-list li { + display: flex; + align-items: center; + margin: 0; + padding: 10px 18px; + list-style: none; + border-inline-start: 4px solid transparent; + border-bottom: 1px solid var(--border-color); +} + +#ruleset-list li:last-child { + border-bottom: none; +} + +#ruleset-list .icon { + width: 16px; + height: 16px; + margin-inline-end: 12px; + background-image: url("chrome://browser/content/rulesets/securedrop.svg"); + background-size: 16px; +} + +#ruleset-list .icon.has-favicon { + background: transparent; +} + +#ruleset-list .name { + font-weight: var(--font-weight-bold); +} + +#ruleset-list .description { + font-size: var(--font-size-small); + color: var(--text-color-deemphasized); +} + +#ruleset-list .selected { + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-border-color-tokens */ + border-inline-start-color: var(--color-accent-primary); +} + +#ruleset-list .selected.disabled { + border-inline-start-color: var(--border-color); +} + +#ruleset-list li:not(.selected):hover { + background-color: var(--button-background-color-hover); + color: var(--button-text-color-hover); +} + +#ruleset-list li:not(.selected):hover:active { + background-color: var(--button-background-color-active); +} + +#ruleset-list #ruleset-template { + display: none; +} + +/* Ruleset details */ + +.state-details #ruleset-details { + display: block; +} + +#ruleset-jwk-value { + padding: 8px; + border-radius: var(--border-radius-xsmall); + background-color: var(--background-color-box); + font-size: var(--font-size-small); + line-break: anywhere; + /* ASCII-only text, so can set line-height. */ + line-height: 1.4; +} + +#ruleset-edit { + margin-inline-start: auto; + padding-inline-start: 32px; + background-image: url("chrome://global/skin/icons/edit.svg"); + background-repeat: no-repeat; + background-position: 8px; + -moz-context-properties: fill; + fill: currentColor; + min-width: auto; + flex: 0 0 auto; +} + +#ruleset-enable { + margin-top: var(--ruleset-vmargin); +} + +#ruleset-buttonbar { + margin: var(--ruleset-vmargin) 0; +} + +#ruleset-updated { + margin-top: 24px; + color: var(--text-color-deemphasized); + font-size: var(--font-size-small); +} + +/* Edit ruleset */ + +.state-edit #edit-ruleset { + display: block; +} + +#edit-ruleset label { + color: var(--text-color-deemphasized); + display: block; +} + +#edit-ruleset label, +#edit-buttonbar { + margin-top: var(--ruleset-vmargin); +} + +label#edit-enable { + display: flex; + align-items: center; +} diff --git a/browser/components/rulesets/content/aboutRulesets.html b/browser/components/rulesets/content/aboutRulesets.html @@ -0,0 +1,182 @@ +<!-- Copyright (c) 2022, The Tor Project, Inc. --> +<!doctype html> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="chrome://browser/content/rulesets/aboutRulesets.css" + /> + + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/global/tor-browser.ftl" /> + </head> + <body> + <!-- Warning --> + <div id="warning-wrapper"> + <div id="warning"> + <h1 id="warning-title" data-l10n-id="rulesets-warning-heading"></h1> + <p + id="warning-description" + data-l10n-id="rulesets-warning-description" + ></p> + <p> + <label> + <input + id="warning-enable-checkbox" + type="checkbox" + checked="checked" + /> + <span + id="warning-enable-label" + data-l10n-id="rulesets-warning-checkbox" + ></span> + </label> + </p> + <div id="warning-buttonbar"> + <button + id="warning-button" + autofocus="autofocus" + data-l10n-id="rulesets-warning-continue-button" + ></button> + </div> + </div> + </div> + + <div id="main-content"> + <!-- Ruleset list --> + <aside> + <div + id="ruleset-heading" + data-l10n-id="rulesets-side-panel-heading" + ></div> + <div id="ruleset-list-container"> + <div id="ruleset-list-empty"> + <p + id="ruleset-list-empty-title" + data-l10n-id="rulesets-side-panel-no-rules" + ></p> + <p + id="ruleset-list-empty-description" + data-l10n-id="rulesets-side-panel-no-rules-description" + ></p> + </div> + <ul id="ruleset-list"> + <li id="ruleset-template"> + <div class="icon"></div> + <div> + <div class="name"></div> + <div class="description"></div> + </div> + </li> + </ul> + </div> + </aside> + + <!-- Ruleset details --> + <section id="ruleset-details"> + <div class="title"> + <h1 id="ruleset-title"></h1> + <button + id="ruleset-edit" + class="ghost-button" + data-l10n-id="rulesets-details-edit-button" + ></button> + </div> + <dl> + <dt id="ruleset-jwk-label" data-l10n-id="rulesets-details-jwk"></dt> + <dd id="ruleset-jwk-value"></dd> + <dt + id="ruleset-path-prefix-label" + data-l10n-id="rulesets-details-path" + ></dt> + <dd> + <a id="ruleset-path-prefix-value" target="_blank"></a> + </dd> + <dt + id="ruleset-scope-label" + data-l10n-id="rulesets-details-scope" + ></dt> + <dd id="ruleset-scope-value"></dd> + </dl> + <label id="ruleset-enable"> + <input type="checkbox" id="ruleset-enable-checkbox" /> + <span + id="ruleset-enable-label" + data-l10n-id="rulesets-details-enable-checkbox" + ></span> + </label> + <div id="ruleset-buttonbar"> + <button + id="ruleset-update-button" + data-l10n-id="rulesets-details-update-button" + ></button> + </div> + <hr /> + <p id="ruleset-updated"></p> + </section> + + <!-- Edit ruleset --> + <section id="edit-ruleset"> + <div class="title"> + <h1 id="edit-title"></h1> + </div> + <form id="edit-ruleset-form"> + <label> + <div id="edit-jwk-label" data-l10n-id="rulesets-details-jwk"></div> + <textarea + id="edit-jwk-textarea" + rows="10" + data-l10n-id="rulesets-details-jwk-input" + ></textarea> + </label> + <label> + <div + id="edit-path-prefix-label" + data-l10n-id="rulesets-details-path" + ></div> + <input + id="edit-path-prefix-input" + type="text" + data-l10n-id="rulesets-details-path-input" + /> + </label> + <label> + <div + id="edit-scope-label" + data-l10n-id="rulesets-details-scope" + ></div> + <input + id="edit-scope-input" + type="text" + data-l10n-id="rulesets-details-scope-input" + /> + </label> + <label id="edit-enable"> + <input type="checkbox" id="edit-enable-checkbox" /> + <span + id="edit-enable-label" + data-l10n-id="rulesets-details-enable-checkbox" + ></span> + </label> + <div id="edit-buttonbar"> + <button + id="edit-save" + class="primary" + data-l10n-id="rulesets-details-save-button" + ></button> + <button + id="edit-cancel" + data-l10n-id="rulesets-details-cancel-button" + ></button> + </div> + </form> + </section> + </div> + <script src="chrome://browser/content/rulesets/aboutRulesets.js"></script> + </body> +</html> diff --git a/browser/components/rulesets/content/aboutRulesets.js b/browser/components/rulesets/content/aboutRulesets.js @@ -0,0 +1,446 @@ +"use strict"; + +const Orders = Object.freeze({ + Name: "name", + NameDesc: "name-desc", + LastUpdate: "last-update", +}); + +const States = Object.freeze({ + Warning: "warning", + Details: "details", + Edit: "edit", + NoRulesets: "noRulesets", +}); + +function setUpdateDate(ruleset, element) { + if (!ruleset.enabled) { + document.l10n.setAttributes(element, "rulesets-update-rule-disabled"); + return; + } + if (!ruleset.currentTimestamp) { + document.l10n.setAttributes(element, "rulesets-update-never"); + return; + } + + document.l10n.setAttributes(element, "rulesets-update-last", { + date: ruleset.currentTimestamp * 1000, + }); +} + +// UI states + +/** + * This is the initial warning shown when the user opens about:rulesets. + */ +class WarningState { + elements = { + enableCheckbox: document.getElementById("warning-enable-checkbox"), + button: document.getElementById("warning-button"), + }; + + constructor() { + this.elements.enableCheckbox.addEventListener( + "change", + this.onEnableChange.bind(this) + ); + + this.elements.button.addEventListener( + "click", + this.onButtonClick.bind(this) + ); + } + + show() { + this.elements.button.focus(); + } + + hide() {} + + onEnableChange() { + RPMSendAsyncMessage( + "rulesets:set-show-warning", + this.elements.enableCheckbox.checked + ); + } + + onButtonClick() { + gAboutRulesets.selectFirst(); + } +} + +/** + * State shown when the user clicks on a channel to see its details. + */ +class DetailsState { + elements = { + title: document.getElementById("ruleset-title"), + jwkValue: document.getElementById("ruleset-jwk-value"), + pathPrefixValue: document.getElementById("ruleset-path-prefix-value"), + scopeValue: document.getElementById("ruleset-scope-value"), + enableCheckbox: document.getElementById("ruleset-enable-checkbox"), + updateButton: document.getElementById("ruleset-update-button"), + updated: document.getElementById("ruleset-updated"), + }; + + constructor() { + document + .getElementById("ruleset-edit") + .addEventListener("click", this.onEdit.bind(this)); + this.elements.enableCheckbox.addEventListener( + "change", + this.onEnable.bind(this) + ); + this.elements.updateButton.addEventListener( + "click", + this.onUpdate.bind(this) + ); + } + + show(ruleset) { + const elements = this.elements; + elements.title.textContent = ruleset.name; + elements.jwkValue.textContent = JSON.stringify(ruleset.jwk); + elements.pathPrefixValue.setAttribute("href", ruleset.pathPrefix); + elements.pathPrefixValue.textContent = ruleset.pathPrefix; + elements.scopeValue.textContent = ruleset.scope; + elements.enableCheckbox.checked = ruleset.enabled; + if (ruleset.enabled) { + elements.updateButton.removeAttribute("disabled"); + } else { + elements.updateButton.setAttribute("disabled", "disabled"); + } + setUpdateDate(ruleset, elements.updated); + this._showing = ruleset; + + gAboutRulesets.list.setItemSelected(ruleset.name); + } + + hide() { + this._showing = null; + } + + onEdit() { + gAboutRulesets.setState(States.Edit, this._showing); + } + + async onEnable() { + await RPMSendAsyncMessage("rulesets:enable-channel", { + name: this._showing.name, + enabled: this.elements.enableCheckbox.checked, + }); + } + + async onUpdate() { + try { + await RPMSendQuery("rulesets:update-channel", this._showing.name); + } catch (err) { + console.error("Could not update the rulesets", err); + } + } +} + +/** + * State to edit a channel. + */ +class EditState { + elements = { + form: document.getElementById("edit-ruleset-form"), + title: document.getElementById("edit-title"), + jwkTextarea: document.getElementById("edit-jwk-textarea"), + pathPrefixInput: document.getElementById("edit-path-prefix-input"), + scopeInput: document.getElementById("edit-scope-input"), + enableCheckbox: document.getElementById("edit-enable-checkbox"), + }; + + constructor() { + document + .getElementById("edit-save") + .addEventListener("click", this.onSave.bind(this)); + document + .getElementById("edit-cancel") + .addEventListener("click", this.onCancel.bind(this)); + } + + show(ruleset) { + const elements = this.elements; + elements.form.reset(); + elements.title.textContent = ruleset.name; + elements.jwkTextarea.value = JSON.stringify(ruleset.jwk); + elements.pathPrefixInput.value = ruleset.pathPrefix; + elements.scopeInput.value = ruleset.scope; + elements.enableCheckbox.checked = ruleset.enabled; + this._editing = ruleset; + } + + hide() { + this.elements.form.reset(); + this._editing = null; + } + + async onSave(e) { + e.preventDefault(); + const elements = this.elements; + + let valid = true; + const name = this._editing.name; + + let jwk; + try { + jwk = JSON.parse(elements.jwkTextarea.value); + await crypto.subtle.importKey( + "jwk", + jwk, + { + name: "RSA-PSS", + saltLength: 32, + hash: { name: "SHA-256" }, + }, + true, + ["verify"] + ); + elements.jwkTextarea.setCustomValidity(""); + } catch (err) { + console.error("Invalid JSON or invalid JWK", err); + elements.jwkTextarea.setCustomValidity( + await document.l10n.formatValue("rulesets-details-jwk-input-invalid") + ); + valid = false; + } + + const pathPrefix = elements.pathPrefixInput.value.trim(); + try { + const url = URL.parse(pathPrefix); + if (url?.protocol !== "http:" && url?.protocol !== "https:") { + elements.pathPrefixInput.setCustomValidity( + await document.l10n.formatValue("rulesets-details-path-input-invalid") + ); + valid = false; + } else { + elements.pathPrefixInput.setCustomValidity(""); + } + } catch (err) { + console.error("The path prefix is not a valid URL", err); + elements.pathPrefixInput.setCustomValidity( + await document.l10n.formatValue("rulesets-details-path-input-invalid") + ); + valid = false; + } + + let scope; + try { + scope = new RegExp(elements.scopeInput.value.trim()); + elements.scopeInput.setCustomValidity(""); + } catch (err) { + elements.scopeInput.setCustomValidity( + await document.l10n.formatValue("rulesets-details-scope-input-invalid") + ); + valid = false; + } + + if (!valid) { + return; + } + + const enabled = elements.enableCheckbox.checked; + + const rulesetData = { name, jwk, pathPrefix, scope, enabled }; + const ruleset = await RPMSendQuery("rulesets:set-channel", rulesetData); + gAboutRulesets.setState(States.Details, ruleset); + if (enabled) { + try { + await RPMSendQuery("rulesets:update-channel", name); + } catch (err) { + console.warn("Could not update the ruleset after adding it", err); + } + } + } + + onCancel(e) { + e.preventDefault(); + if (this._editing === null) { + gAboutRulesets.selectFirst(); + } else { + gAboutRulesets.setState(States.Details, this._editing); + } + } +} + +/** + * State shown when no rulesets are available. + * Currently, the only way to reach it is to delete all the channels manually. + */ +class NoRulesetsState { + show() {} + hide() {} +} + +/** + * Manages the sidebar with the list of the various channels, and keeps it in + * sync with the data we receive from the backend. + */ +class RulesetList { + elements = { + list: document.getElementById("ruleset-list"), + emptyContainer: document.getElementById("ruleset-list-empty"), + itemTemplate: document.getElementById("ruleset-template"), + }; + + nameAttribute = "data-name"; + + rulesets = []; + + constructor() { + RPMAddMessageListener( + "rulesets:channels-change", + this.onRulesetsChanged.bind(this) + ); + } + + getSelectedRuleset() { + const name = this.elements.list + .querySelector(".selected") + ?.getAttribute(this.nameAttribute); + for (const ruleset of this.rulesets) { + if (ruleset.name == name) { + return ruleset; + } + } + return null; + } + + isEmpty() { + return !this.rulesets.length; + } + + async update() { + this.rulesets = await RPMSendQuery("rulesets:get-channels"); + await this._populateRulesets(); + } + + setItemSelected(name) { + name = name.replace(/["\\]/g, "\\$&"); + const item = this.elements.list.querySelector( + `.item[${this.nameAttribute}="${name}"]` + ); + this._selectItem(item); + } + + async _populateRulesets() { + if (this.isEmpty()) { + this.elements.emptyContainer.classList.remove("hidden"); + } else { + this.elements.emptyContainer.classList.add("hidden"); + } + + const list = this.elements.list; + const selName = list + .querySelector(".item.selected") + ?.getAttribute(this.nameAttribute); + const items = list.querySelectorAll(".item"); + for (const item of items) { + item.remove(); + } + + for (const ruleset of this.rulesets) { + const item = this._addItem(ruleset); + if (ruleset.name === selName) { + this._selectItem(item); + } + } + } + + _addItem(ruleset) { + const item = this.elements.itemTemplate.cloneNode(true); + item.removeAttribute("id"); + item.classList.add("item"); + item.querySelector(".name").textContent = ruleset.name; + const descr = item.querySelector(".description"); + setUpdateDate(ruleset, descr); + item.classList.toggle("disabled", !ruleset.enabled); + item.setAttribute(this.nameAttribute, ruleset.name); + item.addEventListener("click", () => { + this.onRulesetClick(ruleset); + }); + this.elements.list.append(item); + return item; + } + + _selectItem(item) { + this.elements.list.querySelector(".selected")?.classList.remove("selected"); + item?.classList.add("selected"); + } + + onRulesetClick(ruleset) { + gAboutRulesets.setState(States.Details, ruleset); + } + + onRulesetsChanged(data) { + this.rulesets = data.data; + this._populateRulesets(); + const selected = this.getSelectedRuleset(); + if (selected !== null) { + gAboutRulesets.setState(States.Details, selected); + } + } +} + +/** + * The entry point of about:rulesets. + * It initializes the various states and allows to switch between them. + */ +class AboutRulesets { + _state = null; + + async init() { + const args = await RPMSendQuery("rulesets:get-init-args"); + const showWarning = args.showWarning; + + this.list = new RulesetList(); + this._states = {}; + this._states[States.Warning] = new WarningState(); + this._states[States.Details] = new DetailsState(); + this._states[States.Edit] = new EditState(); + this._states[States.NoRulesets] = new NoRulesetsState(); + + await this.refreshRulesets(); + + if (showWarning) { + this.setState(States.Warning); + } else { + this.selectFirst(); + } + } + + setState(state, ...args) { + document.querySelector("body").className = `state-${state}`; + this._state?.hide(); + this._state = this._states[state]; + this._state.show(...args); + } + + async refreshRulesets() { + await this.list.update(); + if (this._state === this._states[States.Details]) { + const ruleset = this.list.getSelectedRuleset(); + if (ruleset !== null) { + this.setState(States.Details, ruleset); + } else { + this.selectFirst(); + } + } else if (this.list.isEmpty()) { + this.setState(States.NoRulesets); + } + } + + selectFirst() { + if (this.list.isEmpty()) { + this.setState(States.NoRulesets); + } else { + this.setState("details", this.list.rulesets[0]); + } + } +} + +const gAboutRulesets = new AboutRulesets(); +gAboutRulesets.init(); diff --git a/browser/components/rulesets/content/securedrop.svg b/browser/components/rulesets/content/securedrop.svg @@ -0,0 +1,173 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Generator: Adobe Illustrator 23.0.5, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> + +<svg + version="1.1" + id="Layer_1" + x="0px" + y="0px" + viewBox="0 0 423.3 423.3" + xml:space="preserve" + width="423.29999" + height="423.29999" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"><defs + id="defs49"> + + + + + + + + + + + <defs + id="defs24"> + <filter + id="Adobe_OpacityMaskFilter_1_" + filterUnits="userSpaceOnUse" + x="-66" + y="-0.89999998" + width="183.3" + height="318.20001"> + <feColorMatrix + type="matrix" + values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0" + id="feColorMatrix21" /> + </filter> + </defs> + <mask + maskUnits="userSpaceOnUse" + x="-66" + y="-0.9" + width="183.3" + height="318.2" + id="mask-4_1_"> + <g + class="st4" + id="g27"> + <polygon + id="path-3_1_" + class="st2" + points="117.3,-0.9 117.3,317.3 -66,317.3 -66,-0.9 " /> + </g> + </mask> + + + + <defs + id="defs36"> + <filter + id="Adobe_OpacityMaskFilter_2_" + filterUnits="userSpaceOnUse" + x="-66" + y="-1" + width="366.29999" + height="211.3"> + <feColorMatrix + type="matrix" + values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0" + id="feColorMatrix33" /> + </filter> + </defs> + <mask + maskUnits="userSpaceOnUse" + x="-66" + y="-1" + width="366.3" + height="211.3" + id="mask-6_1_"> + <g + class="st6" + id="g39"> + <polygon + id="path-5_1_" + class="st2" + points="300.3,-1 300.3,210.3 -66,210.3 -66,-1 " /> + </g> + </mask> + + + + + <defs + id="defs11"> + <filter + id="Adobe_OpacityMaskFilter" + filterUnits="userSpaceOnUse" + x="-65.199997" + y="-0.89999998" + width="183.5" + height="318.20001"> + <feColorMatrix + type="matrix" + values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0" + id="feColorMatrix8" /> + </filter> + </defs> + <mask + maskUnits="userSpaceOnUse" + x="-65.2" + y="-0.9" + width="183.5" + height="318.2" + id="mask-2_1_"> + <g + class="st1" + id="g14"> + <polygon + id="path-1_1_" + class="st2" + points="-65.2,317.3 -65.2,-0.9 118.3,-0.9 118.3,317.3 " /> + </g> + </mask> + + + + </defs> +<style + type="text/css" + id="style2"> + .st0{fill-rule:evenodd;clip-rule:evenodd;fill:#055FB4;} + .st1{filter:url(#Adobe_OpacityMaskFilter);} + .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;} + .st3{mask:url(#mask-2_1_);fill-rule:evenodd;clip-rule:evenodd;fill:#055FB4;} + .st4{filter:url(#Adobe_OpacityMaskFilter_1_);} + .st5{mask:url(#mask-4_1_);fill-rule:evenodd;clip-rule:evenodd;fill:#093D70;} + .st6{filter:url(#Adobe_OpacityMaskFilter_2_);} + .st7{mask:url(#mask-6_1_);fill-rule:evenodd;clip-rule:evenodd;fill:#2E8AE8;} +</style> +<title + id="title4">Big Logo HP</title> +<circle + style="fill:#ffffff;stroke:none;stroke-width:2.66667" + id="path1626" + r="176.46054" + cy="211.64999" + cx="211.64999" /><path + id="Fill-1" + class="st0" + d="m 327.99999,225.5 -41.8,23.9 0.2,58.5 42.5,-23.6 c 5.1,-2.8 8.3,-8.3 8.3,-14 v -39.7 c -0.2,-0.9 -0.2,-2.1 -0.9,-2.8 -1.9,-2.8 -5.6,-3.9 -8.3,-2.3" /><path + id="Fill-3" + class="st3" + d="m 85.9,173.2 c 0,9.9 -5.3,19 -14,24.1 l -90.7,52.3 V 127.3 l 84,-48.6 c 2.1,-1.1 4.4,-1.8 6.9,-1.8 7.6,0 13.8,6.2 13.8,13.8 z M -65.2,104.9 V 317.3 L 118.3,211.5 V -0.9 Z" + mask="url(#mask-2_1_)" + transform="translate(276.49999,106)" /><path + id="Fill-7" + class="st5" + d="M 71.7,158.3 3.3,118.8 v 14 l 68.4,39.5 v 73.9 L -22.2,192 v -30.1 l 64,37.2 v -13.8 l -64,-37.2 V 75 l 93.8,54.2 v 29.1 z M -66,-0.9 V 211.5 L 117.3,317.3 V 104.9 Z" + mask="url(#mask-4_1_)" + transform="translate(94.499994,106)" /><path + id="Fill-10" + class="st7" + d="m 135,143.2 55.3,-31.1 -62.2,-17.2 c 1.1,-2.1 1.8,-4.4 1.8,-6.6 0,-11.5 -16.7,-21.1 -37.4,-21.1 -20.6,0 -37.4,9.4 -37.4,21.1 0,11.7 16.7,21.1 37.4,21.1 2.8,0 5.3,-0.2 8,-0.5 z M 117,210.3 -66,104.7 117,-1 300.3,104.7 Z" + mask="url(#mask-6_1_)" + transform="translate(94.499994,1)" /> +<metadata + id="metadata866"><rdf:RDF><cc:Work + rdf:about=""><dc:title>Big Logo HP</dc:title></cc:Work></rdf:RDF></metadata></svg> diff --git a/browser/components/rulesets/jar.mn b/browser/components/rulesets/jar.mn @@ -0,0 +1,5 @@ +browser.jar: + content/browser/rulesets/aboutRulesets.css (content/aboutRulesets.css) + content/browser/rulesets/aboutRulesets.html (content/aboutRulesets.html) + content/browser/rulesets/aboutRulesets.js (content/aboutRulesets.js) + content/browser/rulesets/securedrop.svg (content/securedrop.svg) diff --git a/browser/components/rulesets/moz.build b/browser/components/rulesets/moz.build @@ -0,0 +1,6 @@ +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "RulesetsChild.sys.mjs", + "RulesetsParent.sys.mjs", +] diff --git a/eslint-file-globals.config.mjs b/eslint-file-globals.config.mjs @@ -393,6 +393,7 @@ export default [ "toolkit/content/aboutNetErrorHelpers.mjs", "toolkit/content/net-error-card.mjs", "toolkit/components/torconnect/content/aboutTorConnect.js", + "browser/components/rulesets/content/aboutRulesets.js", ], languageOptions: { globals: mozilla.environments["remote-page"].globals }, }, diff --git a/modules/libpref/Preferences.cpp b/modules/libpref/Preferences.cpp @@ -6299,6 +6299,10 @@ static const PrefListEntry sOverrideRestrictionsList[]{ // has it set as a string... PREF_LIST_ENTRY("services.settings.preview_enabled"), PREF_LIST_ENTRY("services.settings.server"), + + // tor-browser#41165, tor-browser!765: leave this static pref in + // gSharedMap to prevent a crash in gpu process in debug builds. + PREF_LIST_ENTRY("browser.urlbar.onionRewrites.enabled"), }; // These prefs are dynamically-named (i.e. not specified in prefs.js or diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml @@ -2107,6 +2107,14 @@ value: false mirror: always +# tor-browser#28005, tor-browser#40458: enable .tor.onion aliases by default. +# When they are enabled, the browser will need to refresh the alias lists by +# connecting to third parties periodically. +- name: browser.urlbar.onionRewrites.enabled + type: RelaxedAtomicBool + value: true + mirror: always + - name: browser.viewport.desktopWidth type: RelaxedAtomicInt32 value: 980 diff --git a/netwerk/build/components.conf b/netwerk/build/components.conf @@ -893,3 +893,14 @@ if toolkit != 'android': 'headers': ['mozilla/net/CachePurgeLock.h'], }, ] + +Classes += [ + { + 'cid': '{0df7784b-7316-486d-bc99-bf47b7a05974}', + 'contract_ids': ['@torproject.org/onion-alias-service;1'], + 'singleton': True, + 'type': 'IOnionAliasService', + 'constructor': 'torproject::OnionAliasService::GetSingleton', + 'headers': ['torproject/OnionAliasService.h'], + }, +] diff --git a/netwerk/build/nsNetCID.h b/netwerk/build/nsNetCID.h @@ -820,4 +820,12 @@ 0x4450, \ {0xb0, 0x19, 0xd1, 0xc1, 0xfb, 0xa5, 0x60, 0x25}} +// Onion alias service implementing IOnionAliasService +#define ONIONALIAS_CONTRACTID "@torproject.org/onion-alias-service;1" +#define ONIONALIAS_CID \ + {/* 0df7784b-7316-486d-bc99-bf47b7a05974 */ \ + 0x0df7784b, \ + 0x7316, \ + 0x486d, \ + {0xbc, 0x99, 0xbf, 0x47, 0xb7, 0xa0, 0x59, 0x74}} #endif // nsNetCID_h__ diff --git a/netwerk/dns/IOnionAliasService.idl b/netwerk/dns/IOnionAliasService.idl @@ -0,0 +1,34 @@ +#include "nsISupports.idl" + +/** + * Service used for .tor.onion aliases. + * It stores the real .onion address that correspond to .tor.onion addresses, + * so that both C++ code and JS can access them. + */ +[scriptable, uuid(0df7784b-7316-486d-bc99-bf47b7a05974)] +interface IOnionAliasService : nsISupports +{ + /** + * Add a new Onion alias + * @param aShortHostname + * The short hostname that is being rewritten + * @param aLongHostname + * The complete onion v3 hostname + */ + void addOnionAlias(in ACString aShortHostname, + in ACString aLongHostname); + + /** + * Return an onion alias. + * + * @param aShortHostname + * The .tor.onion hostname to resolve + * @return a v3 address, or the input, if the short hostname is not known + */ + ACString getOnionAlias(in ACString aShortHostname); + + /** + * Clears Onion aliases. + */ + void clearOnionAliases(); +}; diff --git a/netwerk/dns/OnionAliasService.cpp b/netwerk/dns/OnionAliasService.cpp @@ -0,0 +1,99 @@ +#include "torproject/OnionAliasService.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/StaticPrefs_browser.h" +#include "nsUnicharUtils.h" + +/** + * Check if a hostname is a valid Onion v3 hostname. + * + * @param aHostname + * The hostname to verify. It is not a const reference because any + * uppercase character will be transformed to lowercase during the + * verification. + * @return Tells whether the input string is an Onion v3 address + */ +static bool ValidateOnionV3(nsACString& aHostname) { + constexpr nsACString::size_type v3Length = 56 + 6; + if (aHostname.Length() != v3Length) { + return false; + } + ToLowerCase(aHostname); + if (!StringEndsWith(aHostname, ".onion"_ns)) { + return false; + } + + const char* cur = aHostname.BeginWriting(); + // We have already checked that it ends by ".onion" + const char* end = aHostname.EndWriting() - 6; + for (; cur < end; ++cur) { + if (!(islower(*cur) || ('2' <= *cur && *cur <= '7'))) { + return false; + } + } + + return true; +} + +namespace torproject { + +NS_IMPL_ISUPPORTS(OnionAliasService, IOnionAliasService) + +static mozilla::StaticRefPtr<OnionAliasService> gOAService; + +// static +already_AddRefed<IOnionAliasService> OnionAliasService::GetSingleton() { + if (gOAService) { + return do_AddRef(gOAService); + } + + gOAService = new OnionAliasService(); + ClearOnShutdown(&gOAService); + return do_AddRef(gOAService); +} + +NS_IMETHODIMP +OnionAliasService::AddOnionAlias(const nsACString& aShortHostname, + const nsACString& aLongHostname) { + nsAutoCString shortHostname; + ToLowerCase(aShortHostname, shortHostname); + mozilla::UniquePtr<nsAutoCString> longHostname = + mozilla::MakeUnique<nsAutoCString>(aLongHostname); + if (!longHostname) { + return NS_ERROR_OUT_OF_MEMORY; + } + if (!StringEndsWith(shortHostname, ".tor.onion"_ns) || + !ValidateOnionV3(*longHostname)) { + return NS_ERROR_INVALID_ARG; + } + mozilla::AutoWriteLock lock(mLock); + mOnionAliases.InsertOrUpdate(shortHostname, std::move(longHostname)); + return NS_OK; +} + +NS_IMETHODIMP +OnionAliasService::GetOnionAlias(const nsACString& aShortHostname, + nsACString& aLongHostname) { + aLongHostname = aShortHostname; + if (mozilla::StaticPrefs::browser_urlbar_onionRewrites_enabled() && + StringEndsWith(aShortHostname, ".tor.onion"_ns)) { + nsAutoCString* alias = nullptr; + // We want to keep the string stored in the map alive at least until we + // finish to copy it to the output parameter. + mozilla::AutoReadLock lock(mLock); + if (mOnionAliases.Get(aShortHostname, &alias)) { + // We take for granted aliases have already been validated + aLongHostname.Assign(*alias); + } + } + return NS_OK; +} + +NS_IMETHODIMP +OnionAliasService::ClearOnionAliases() { + mozilla::AutoWriteLock lock(mLock); + mOnionAliases.Clear(); + return NS_OK; +} + +} // namespace torproject diff --git a/netwerk/dns/OnionAliasService.h b/netwerk/dns/OnionAliasService.h @@ -0,0 +1,39 @@ +#ifndef OnionAliasService_h_ +#define OnionAliasService_h_ + +#include "IOnionAliasService.h" + +#include "mozilla/RWLock.h" +#include "nsClassHashtable.h" +#include "nsHashKeys.h" +#include "ScopedNSSTypes.h" + +namespace torproject { + +class OnionAliasService final : public IOnionAliasService { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_IONIONALIASSERVICE + + static already_AddRefed<IOnionAliasService> GetSingleton(); + + private: + OnionAliasService() = default; + OnionAliasService(const OnionAliasService&) = delete; + OnionAliasService(OnionAliasService&&) = delete; + OnionAliasService& operator=(const OnionAliasService&) = delete; + OnionAliasService& operator=(OnionAliasService&&) = delete; + virtual ~OnionAliasService() = default; + + // mLock protects access to mOnionAliases + mozilla::RWLock mLock{"OnionAliasService.mLock"}; + + // AutoCStrings have a 64 byte buffer, so it is advised not to use them for + // long storage. However, it is enough to contain onion addresses, so we use + // them instead, and avoid allocating on heap for each alias + nsClassHashtable<nsCStringHashKey, nsAutoCString> mOnionAliases; +}; + +} // namespace torproject + +#endif // OnionAliasService_h_ diff --git a/netwerk/dns/effective_tld_names.dat b/netwerk/dns/effective_tld_names.dat @@ -5134,6 +5134,8 @@ pro.om // onion : https://tools.ietf.org/html/rfc7686 onion +tor.onion +securedrop.tor.onion // org : https://www.iana.org/domains/root/db/org.html org diff --git a/netwerk/dns/moz.build b/netwerk/dns/moz.build @@ -115,3 +115,7 @@ LOCAL_INCLUDES += [ ] USE_LIBS += ["icu"] + +XPIDL_SOURCES += ["IOnionAliasService.idl"] +UNIFIED_SOURCES += ["OnionAliasService.cpp"] +EXPORTS.torproject += ["OnionAliasService.h"] diff --git a/netwerk/socket/nsSOCKSIOLayer.cpp b/netwerk/socket/nsSOCKSIOLayer.cpp @@ -24,6 +24,8 @@ #include "mozilla/Logging.h" #include "mozilla/net/DNS.h" +#include "IOnionAliasService.h" + using mozilla::LogLevel; using namespace mozilla::net; @@ -840,11 +842,23 @@ PRStatus nsSOCKSSocketInfo::WriteV5ConnectRequest() { // Add the address to the SOCKS 5 request. SOCKS 5 supports several // address types, so we pick the one that works best for us. if (proxy_resolve) { - // Add the host name. Only a single byte is used to store the length, - // so we must prevent long names from being used. - buf2 = buf.WriteUint8(0x03) // addr type -- domainname - .WriteUint8(mDestinationHost.Length()) // name length - .WriteString<MAX_HOSTNAME_LEN>(mDestinationHost); // Hostname + if (StringEndsWith(mDestinationHost, ".tor.onion"_ns)) { + nsAutoCString realHost; + nsCOMPtr<IOnionAliasService> oas = do_GetService(ONIONALIAS_CID); + if (NS_FAILED(oas->GetOnionAlias(mDestinationHost, realHost))) { + HandshakeFinished(PR_BAD_ADDRESS_ERROR); + return PR_FAILURE; + } + buf2 = buf.WriteUint8(0x03) + .WriteUint8(realHost.Length()) + .WriteString<MAX_HOSTNAME_LEN>(realHost); + } else { + // Add the host name. Only a single byte is used to store the length, + // so we must prevent long names from being used. + buf2 = buf.WriteUint8(0x03) // addr type -- domainname + .WriteUint8(mDestinationHost.Length()) // name length + .WriteString<MAX_HOSTNAME_LEN>(mDestinationHost); // Hostname + } if (!buf2) { LOGERROR(("socks5: destination host name is too long!")); HandshakeFinished(PR_BAD_ADDRESS_ERROR); diff --git a/security/manager/ssl/SSLServerCertVerification.cpp b/security/manager/ssl/SSLServerCertVerification.cpp @@ -135,6 +135,8 @@ #include "sslerr.h" #include "sslexp.h" +#include "IOnionAliasService.h" + extern mozilla::LazyLogModule gPIPNSSLog; using namespace mozilla::pkix; @@ -857,6 +859,13 @@ SECStatus SSLServerCertVerificationJob::Dispatch( return SECWouldBlock; } +void SSLServerCertVerificationJob::FixOnionAlias() { + if (StringEndsWith(mHostName, ".tor.onion"_ns)) { + nsCOMPtr<IOnionAliasService> oas = do_GetService(ONIONALIAS_CID); + oas->GetOnionAlias(mHostName, mHostName); + } +} + NS_IMETHODIMP SSLServerCertVerificationJob::Run() { // Runs on a cert verification thread and only on parent process. diff --git a/security/manager/ssl/SSLServerCertVerification.h b/security/manager/ssl/SSLServerCertVerification.h @@ -139,7 +139,11 @@ class SSLServerCertVerificationJob : public Runnable { mStapledOCSPResponse(std::move(stapledOCSPResponse)), mSCTsFromTLSExtension(std::move(sctsFromTLSExtension)), mDCInfo(std::move(dcInfo)), - mResultTask(aResultTask) {} + mResultTask(aResultTask) { + FixOnionAlias(); + } + + void FixOnionAlias(); uint64_t mAddrForLogging; void* mPinArg; diff --git a/toolkit/modules/RemotePageAccessManager.sys.mjs b/toolkit/modules/RemotePageAccessManager.sys.mjs @@ -259,6 +259,20 @@ export let RemotePageAccessManager = { RPMSendAsyncMessage: ["goBack"], RPMGetBoolPref: ["security.restrict_to_adults.always"], }, + "about:rulesets": { + RPMAddMessageListener: ["rulesets:channels-change"], + RPMSendAsyncMessage: [ + "rulesets:delete-channel", + "rulesets:enable-channel", + "rulesets:set-show-warning", + ], + RPMSendQuery: [ + "rulesets:get-channels", + "rulesets:get-init-args", + "rulesets:set-channel", + "rulesets:update-channel", + ], + }, "about:tabcrashed": { RPMSendAsyncMessage: ["Load", "closeTab", "restoreTab", "restoreAll"], RPMAddMessageListener: ["*"],