[tbb-commits] [tor-browser] 71/72: Bug 40458: Implement .tor.onion aliases

gitolite role git at cupani.torproject.org
Wed Aug 3 13:06:01 UTC 2022


This is an automated email from the git hooks/post-receive script.

richard pushed a commit to branch tor-browser-91.12.0esr-12.0-1
in repository tor-browser.

commit 0a8574384e06b5118fb9759ad08c57499e3d461e
Author: Pier Angelo Vendrame <pierov at torproject.org>
AuthorDate: Mon Feb 21 15:39:11 2022 +0100

    Bug 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.
---
 browser/base/content/browser-siteIdentity.js       |   4 +-
 browser/components/BrowserGlue.jsm                 |  42 ++
 browser/components/about/AboutRedirector.cpp       |   4 +
 browser/components/about/components.conf           |   1 +
 browser/components/moz.build                       |   1 +
 .../components/onionservices/OnionAliasStore.jsm   | 562 +++++++++++++++++++++
 browser/components/onionservices/moz.build         |   1 +
 browser/components/rulesets/RulesetsChild.jsm      |  11 +
 browser/components/rulesets/RulesetsParent.jsm     |  79 +++
 .../components/rulesets/content/aboutRulesets.css  | 319 ++++++++++++
 .../components/rulesets/content/aboutRulesets.html | 110 ++++
 .../components/rulesets/content/aboutRulesets.js   | 531 +++++++++++++++++++
 browser/components/rulesets/content/securedrop.svg | 173 +++++++
 browser/components/rulesets/jar.mn                 |   5 +
 browser/components/rulesets/moz.build              |   6 +
 modules/libpref/init/StaticPrefList.yaml           |   5 +
 netwerk/build/components.conf                      |  11 +
 netwerk/build/nsNetCID.h                           |  10 +
 netwerk/dns/IOnionAliasService.idl                 |  34 ++
 netwerk/dns/OnionAliasService.cpp                  | 100 ++++
 netwerk/dns/OnionAliasService.h                    |  36 ++
 netwerk/dns/effective_tld_names.dat                |   2 +
 netwerk/dns/moz.build                              |   4 +
 netwerk/socket/nsSOCKSIOLayer.cpp                  |  24 +-
 security/manager/ssl/SSLServerCertVerification.cpp |   9 +
 security/manager/ssl/SSLServerCertVerification.h   |   4 +-
 toolkit/modules/RemotePageAccessManager.jsm        |  14 +
 27 files changed, 2094 insertions(+), 8 deletions(-)

diff --git a/browser/base/content/browser-siteIdentity.js b/browser/base/content/browser-siteIdentity.js
index 6682ae8b096fe..5e70d458094cf 100644
--- a/browser/base/content/browser-siteIdentity.js
+++ b/browser/base/content/browser-siteIdentity.js
@@ -58,8 +58,8 @@ var gIdentityHandler = {
    * the browser UI.
    */
   _secureInternalPages: (AppConstants.TOR_BROWSER_UPDATE ?
-                        /^(?:accounts|addons|cache|certificate|config|crashes|downloads|license|logins|preferences|protections|rights|sessionrestore|support|welcomeback|tor|torconnect|tbupdate)(?:[?#]|$)/i :
-                        /^(?:accounts|addons|cache|certificate|config|crashes|downloads|license|logins|preferences|protections|rights|sessionrestore|support|welcomeback|tor|torconnect)(?:[?#]|$)/i),
+                        /^(?:accounts|addons|cache|certificate|config|crashes|downloads|license|logins|preferences|protections|rights|rulesets|sessionrestore|support|welcomeback|tor|torconnect|tbupdate)(?:[?#]|$)/i :
+                        /^(?:accounts|addons|cache|certificate|config|crashes|downloads|license|logins|preferences|protections|rights|rulesets|sessionrestore|support|welcomeback|tor|torconnect)(?:[?#]|$)/i),
 
   /**
    * Whether the established HTTPS connection is considered "broken".
diff --git a/browser/components/BrowserGlue.jsm b/browser/components/BrowserGlue.jsm
index ac27535e23dde..d41934720c250 100644
--- a/browser/components/BrowserGlue.jsm
+++ b/browser/components/BrowserGlue.jsm
@@ -84,6 +84,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
   TabUnloader: "resource:///modules/TabUnloader.jsm",
   TelemetryUtils: "resource://gre/modules/TelemetryUtils.jsm",
   TRRRacer: "resource:///modules/TRRPerformance.jsm",
+  OnionAliasStore: "resource:///modules/OnionAliasStore.jsm",
   UIState: "resource://services-sync/UIState.jsm",
   UrlbarQuickSuggest: "resource:///modules/UrlbarQuickSuggest.jsm",
   UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
@@ -661,6 +662,19 @@ let JSWINDOWACTORS = {
     enablePreference: "accessibility.blockautorefresh",
   },
 
+  Rulesets: {
+    parent: {
+      moduleURI: "resource:///modules/RulesetsParent.jsm",
+    },
+    child: {
+      moduleURI: "resource:///modules/RulesetsChild.jsm",
+      events: {
+        DOMWindowCreated: {},
+      },
+    },
+    matches: ["about:rulesets*"],
+  },
+
   SearchSERPTelemetry: {
     parent: {
       moduleURI: "resource:///actors/SearchSERPTelemetryParent.jsm",
@@ -1991,6 +2005,7 @@ BrowserGlue.prototype = {
     Normandy.uninit();
     RFPHelper.uninit();
     ASRouterNewTabHook.destroy();
+    OnionAliasStore.uninit();
   },
 
   // Set up a listener to enable/disable the screenshots extension
@@ -2495,6 +2510,33 @@ BrowserGlue.prototype = {
         },
       },
 
+      {
+        task: () => {
+          const { TorConnect, TorConnectTopics } = ChromeUtils.import(
+            "resource:///modules/TorConnect.jsm"
+          );
+          if (!TorConnect.shouldShowTorConnect) {
+            // we will take this path when the user is using the legacy tor launcher or
+            // when Tor Browser didn't launch its own tor.
+            OnionAliasStore.init();
+          } else {
+            // this path is taken when using about:torconnect, we wait to init
+            // after we are bootstrapped and connected to tor
+            const topic = TorConnectTopics.BootstrapComplete;
+            let bootstrapObserver = {
+              observe(aSubject, aTopic, aData) {
+                if (aTopic === topic) {
+                  OnionAliasStore.init();
+                  // we only need to init once, so remove ourselves as an obvserver
+                  Services.obs.removeObserver(this, topic);
+                }
+              }
+            };
+            Services.obs.addObserver(bootstrapObserver, topic);
+          }
+        },
+      },
+
       {
         task: () => {
           Blocklist.loadBlocklistAsync();
diff --git a/browser/components/about/AboutRedirector.cpp b/browser/components/about/AboutRedirector.cpp
index fd828a630c92a..57c4d40c8ef56 100644
--- a/browser/components/about/AboutRedirector.cpp
+++ b/browser/components/about/AboutRedirector.cpp
@@ -68,6 +68,10 @@ static const RedirEntry kRedirMap[] = {
      nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::URI_MUST_LOAD_IN_CHILD |
          nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS |
          nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT},
+    {"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},
     {"tabcrashed", "chrome://browser/content/aboutTabCrashed.xhtml",
      nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT |
          nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT},
diff --git a/browser/components/about/components.conf b/browser/components/about/components.conf
index 0916bb75e1d57..df7c05b3193d5 100644
--- a/browser/components/about/components.conf
+++ b/browser/components/about/components.conf
@@ -24,6 +24,7 @@ pages = [
     'restartrequired',
     'rights',
     'robots',
+    'rulesets',
     'sessionrestore',
     'tabcrashed',
     'torconnect',
diff --git a/browser/components/moz.build b/browser/components/moz.build
index 3e4663eee315f..eedc66029ce69 100644
--- a/browser/components/moz.build
+++ b/browser/components/moz.build
@@ -49,6 +49,7 @@ DIRS += [
     "prompts",
     "protocolhandler",
     "resistfingerprinting",
+    "rulesets",
     "search",
     "securitylevel",
     "sessionstore",
diff --git a/browser/components/onionservices/OnionAliasStore.jsm b/browser/components/onionservices/OnionAliasStore.jsm
new file mode 100644
index 0000000000000..d5849e4b94289
--- /dev/null
+++ b/browser/components/onionservices/OnionAliasStore.jsm
@@ -0,0 +1,562 @@
+// Copyright (c) 2022, The Tor Project, Inc.
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["OnionAliasStore", "OnionAliasStoreTopics"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { setTimeout, clearTimeout } = ChromeUtils.import(
+  "resource://gre/modules/Timer.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "JSONFile",
+  "resource://gre/modules/JSONFile.jsm"
+);
+
+Cu.importGlobalProperties(["crypto", "fetch"]);
+
+/* OnionAliasStore observer topics */
+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-2U [...]
+  },
+  scope: /^https?:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.securedrop\.tor\.onion\//,
+  enabled: true,
+  mappings: [],
+  currentTimestamp: 0,
+};
+
+const kPrefOnionAliasEnabled = "browser.urlbar.onionRewrites.enabled";
+
+// Logger adapted from CustomizableUI.jsm
+const kPrefOnionAliasDebug = "browser.onionalias.debug";
+XPCOMUtils.defineLazyPreferenceGetter(
+  this,
+  "gDebuggingEnabled",
+  kPrefOnionAliasDebug,
+  false,
+  (pref, oldVal, newVal) => {
+    if (typeof log != "undefined") {
+      log.maxLogLevel = newVal ? "all" : "log";
+    }
+  }
+);
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+  let scope = {};
+  ChromeUtils.import("resource://gre/modules/Console.jsm", scope);
+  let consoleOptions = {
+    maxLogLevel: gDebuggingEnabled ? "all" : "log",
+    prefix: "OnionAlias",
+  };
+  return new scope.ConsoleAPI(consoleOptions);
+});
+
+// Inspired by aboutMemory.js and PingCentre.jsm
+function gunzip(buffer) {
+  return new Promise((resolve, reject) => {
+    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);
+  });
+}
+
+class Channel {
+  static get SIGN_ALGORITHM() {
+    return {
+      name: "RSA-PSS",
+      saltLength: 32,
+      hash: { name: "SHA-256" },
+    };
+  }
+
+  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 = /http[s]?:\/\/[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;
+      }
+      let toHostname;
+      try {
+        const toUrl = new URL(rule.rule[0].to);
+        toHostname = toUrl.hostname;
+      } catch (err) {
+        log.error(
+          "Cannot detect the hostname from the to rule",
+          rule.rule[0].to,
+          err
+        );
+      }
+      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;
+  }
+}
+
+class _OnionAliasStore {
+  static get RULESET_CHECK_INTERVAL() {
+    return 86400 * 1000; // 1 day, like HTTPS-Everywhere
+  }
+
+  constructor() {
+    this._channels = new Map();
+    this._rulesetTimeout = null;
+    this._lastCheck = 0;
+    this._storage = null;
+  }
+
+  async init() {
+    await this._loadSettings();
+    if (this.enabled) {
+      await this._startUpdates();
+    }
+    Services.prefs.addObserver(kPrefOnionAliasEnabled, this);
+  }
+
+  uninit() {
+    this._clear();
+    if (this._rulesetTimeout) {
+      clearTimeout(this._rulesetTimeout);
+    }
+    this._rulesetTimeout = null;
+    Services.prefs.removeObserver(kPrefOnionAliasEnabled, this);
+  }
+
+  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");
+    }
+
+    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 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 wheter
+    // 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, aData) {
+    if (aTopic === "nsPref:changed") {
+      if (this.enabled) {
+        this._startUpdates();
+      } else if (this._rulesetTimeout) {
+        clearTimeout(this._rulesetTimeout);
+        this._rulesetTimeout = null;
+      }
+    }
+  }
+}
+
+const OnionAliasStore = new _OnionAliasStore();
diff --git a/browser/components/onionservices/moz.build b/browser/components/onionservices/moz.build
index 27f9d2da4a9ea..8644548caa15d 100644
--- a/browser/components/onionservices/moz.build
+++ b/browser/components/onionservices/moz.build
@@ -1,6 +1,7 @@
 JAR_MANIFESTS += ["jar.mn"]
 
 EXTRA_JS_MODULES += [
+    "OnionAliasStore.jsm",
     "OnionLocationChild.jsm",
     "OnionLocationParent.jsm",
 ]
diff --git a/browser/components/rulesets/RulesetsChild.jsm b/browser/components/rulesets/RulesetsChild.jsm
new file mode 100644
index 0000000000000..e30de9c7201bd
--- /dev/null
+++ b/browser/components/rulesets/RulesetsChild.jsm
@@ -0,0 +1,11 @@
+// Copyright (c) 2022, The Tor Project, Inc.
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["RulesetsChild"];
+
+const { RemotePageChild } = ChromeUtils.import(
+  "resource://gre/actors/RemotePageChild.jsm"
+);
+
+class RulesetsChild extends RemotePageChild {}
diff --git a/browser/components/rulesets/RulesetsParent.jsm b/browser/components/rulesets/RulesetsParent.jsm
new file mode 100644
index 0000000000000..6a9553644cb17
--- /dev/null
+++ b/browser/components/rulesets/RulesetsParent.jsm
@@ -0,0 +1,79 @@
+// Copyright (c) 2022, The Tor Project, Inc.
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["RulesetsParent"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { TorStrings } = ChromeUtils.import("resource:///modules/TorStrings.jsm");
+const { OnionAliasStore, OnionAliasStoreTopics } = ChromeUtils.import(
+  "resource:///modules/OnionAliasStore.jsm"
+);
+
+const kShowWarningPref = "torbrowser.rulesets.show_warning";
+
+// This class allows about:rulesets to get TorStrings and to load/save the
+// preference for skipping the warning
+class RulesetsParent extends JSWindowActorParent {
+  constructor(...args) {
+    super(...args);
+
+    const self = this;
+    this.observer = {
+      observe(aSubject, aTopic, aData) {
+        const obj = aSubject?.wrappedJSObject;
+        if (aTopic === OnionAliasStoreTopics.ChannelsChanged && obj) {
+          self.sendAsyncMessage("rulesets:channels-change", obj);
+        }
+      },
+    };
+    Services.obs.addObserver(
+      this.observer,
+      OnionAliasStoreTopics.ChannelsChanged
+    );
+  }
+
+  willDestroy() {
+    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 {
+          TorStrings,
+          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
new file mode 100644
index 0000000000000..60b699fe8a02a
--- /dev/null
+++ b/browser/components/rulesets/content/aboutRulesets.css
@@ -0,0 +1,319 @@
+/* Copyright (c) 2022, The Tor Project, Inc. */
+
+/* General rules */
+
+html, body {
+  margin: 0;
+  padding: 0;
+  width: 100%;
+  height: 100%;
+}
+
+body {
+  font: message-box;
+  background-color: var(--in-content-page-background);
+  color: var(--in-content-page-color);
+  font-size: 15px;
+  cursor: default;
+}
+
+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: 700;
+}
+
+dt {
+  margin: var(--ruleset-vmargin) 0 0 0;
+  padding: 0;
+  color: var(--in-content-deemphasized-text);
+  font-size: 85%;
+}
+
+dd {
+  margin: 8px 0 0 0;
+  padding: 0;
+  max-width: 600px;
+  box-sizing: border-box;
+  line-height: 1.4;
+}
+
+hr {
+  width: 40px;
+  margin: 0;
+  border: none;
+  border-top: 1px solid var(--in-content-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(--in-content-border-color);
+}
+
+.title h1 {
+  margin: 0;
+  padding: 0;
+  padding-inline-start: 35px;
+  font-size: 20px;
+  font-weight: 700;
+  line-height: 30px;
+  background-image: url("chrome://browser/content/rulesets/securedrop.svg");
+  background-position: 0 4px;
+  background-size: 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(--in-content-border-color);
+  background-color: var(--in-content-box-background);
+}
+
+#ruleset-heading {
+  padding: 16px;
+  text-align: center;
+  font-weight: 700;
+  border-bottom: 1px solid var(--in-content-border-color);
+}
+
+#ruleset-list-container {
+  flex: 1;
+}
+
+#ruleset-list-empty {
+  padding: 16px;
+  text-align: center;
+}
+
+#ruleset-list-empty-description {
+  font-size: 80%;
+}
+
+#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(--in-content-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: 700;
+}
+
+#ruleset-list .description {
+  font-size: 85%;
+  color: var(--in-content-deemphasized-text);
+}
+
+#ruleset-list .selected {
+  border-inline-start-color: var(--in-content-accent-color);
+}
+
+#ruleset-list .selected.disabled {
+  border-inline-start-color: var(--in-content-border-color);
+}
+
+#ruleset-list li:not(.selected):hover {
+  background-color: var(--in-content-button-background-hover);
+  color: var(--in-content-button-text-color-hover);
+}
+
+#ruleset-list li:not(.selected):hover:active {
+  background-color: var(--in-content-button-background-active);
+}
+
+#ruleset-list #ruleset-template {
+  display: none;
+}
+
+/* Ruleset details */
+
+.state-details #ruleset-details {
+  display: block;
+}
+
+#ruleset-jwk-value {
+  padding: 8px;
+  border-radius: 2px;
+  background-color: var(--in-content-box-background);
+  font-size: 85%;
+  line-break: anywhere;
+}
+
+#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(--in-content-deemphasized-text);
+  font-size: 85%;
+}
+
+/* Edit ruleset */
+
+.state-edit #edit-ruleset {
+  display: block;
+}
+
+#edit-ruleset label {
+  color: var(--in-content-deemphasized-text);
+  display: block;
+}
+
+#edit-ruleset label, #edit-buttonbar {
+  margin-top: var(--ruleset-vmargin);
+}
+
+label#edit-enable {
+  display: flex;
+  align-items: center;
+}
+
+/* No rulesets */
+
+#no-rulesets {
+  max-width: 100%;
+  background-image: url(chrome://browser/skin/preferences/no-search-results.svg);
+  background-size: 275px 212px;
+  background-position: center center;
+  background-repeat: no-repeat;
+}
+
+.state-noRulesets #no-rulesets {
+  display: block;
+}
diff --git a/browser/components/rulesets/content/aboutRulesets.html b/browser/components/rulesets/content/aboutRulesets.html
new file mode 100644
index 0000000000000..d5b03435b1e72
--- /dev/null
+++ b/browser/components/rulesets/content/aboutRulesets.html
@@ -0,0 +1,110 @@
+<!-- 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">
+  </head>
+  <body>
+    <!-- Warning -->
+    <div id="warning-wrapper">
+      <div id="warning">
+        <h1 id="warning-title"></h1>
+        <p id="warning-description"></p>
+        <p>
+          <label>
+            <input id="warning-enable-checkbox" type="checkbox" checked="checked">
+            <span id="warning-enable-label"></span>
+          </label>
+        </p>
+        <div id="warning-buttonbar">
+          <button id="warning-button" autofocus="autofocus"></button>
+        </div>
+      </div>
+    </div>
+
+    <div id="main-content">
+      <!-- Ruleset list -->
+      <aside>
+        <div id="ruleset-heading"></div>
+        <div id="ruleset-list-container">
+          <div id="ruleset-list-empty">
+            <p id="ruleset-list-empty-title"></p>
+            <p id="ruleset-list-empty-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"></button>
+        </div>
+        <dl>
+          <dt id="ruleset-jwk-label"></dt>
+          <dd id="ruleset-jwk-value"></dd>
+          <dt id="ruleset-path-prefix-label"></dt>
+          <dd>
+            <a id="ruleset-path-prefix-value" target="_blank"></a>
+          </dd>
+          <dt id="ruleset-scope-label"></dt>
+          <dd id="ruleset-scope-value"></dd>
+        </dl>
+        <label id="ruleset-enable">
+          <input type="checkbox" id="ruleset-enable-checkbox">
+          <span id="ruleset-enable-label"></span>
+        </label>
+        <div id="ruleset-buttonbar">
+          <button id="ruleset-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"></div>
+            <textarea id="edit-jwk-textarea" rows="10"></textarea>
+          </label>
+          <label>
+            <div id="edit-path-prefix-label"></div>
+            <input id="edit-path-prefix-input" type="text">
+          </label>
+          <label>
+            <div id="edit-scope-label"></div>
+            <input id="edit-scope-input" type="text">
+          </label>
+          <label id="edit-enable">
+            <input type="checkbox" id="edit-enable-checkbox">
+            <span id="edit-enable-label"></span>
+          </label>
+          <div id="edit-buttonbar">
+            <button id="edit-save" class="primary"></button>
+            <button id="edit-cancel"></button>
+          </div>
+        </form>
+      </section>
+
+      <!-- No rulesets -->
+      <section id="no-rulesets"></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
new file mode 100644
index 0000000000000..4fabdca1b93d1
--- /dev/null
+++ b/browser/components/rulesets/content/aboutRulesets.js
@@ -0,0 +1,531 @@
+"use strict";
+
+/* globals RPMAddMessageListener, RPMSendQuery, RPMSendAsyncMessage */
+
+let TorStrings;
+
+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) {
+    element.textContent = TorStrings.rulesets.disabled;
+    return;
+  }
+  if (!ruleset.currentTimestamp) {
+    element.textContent = TorStrings.rulesets.neverUpdated;
+    return;
+  }
+
+  const formatter = new Intl.DateTimeFormat(navigator.languages, {
+    year: "numeric",
+    month: "long",
+    day: "numeric",
+  });
+  element.textContent = TorStrings.rulesets.lastUpdated.replace(
+    "%S",
+    formatter.format(new Date(ruleset.currentTimestamp * 1000))
+  );
+}
+
+class WarningState {
+  selectors = Object.freeze({
+    wrapper: "#warning-wrapper",
+    title: "#warning-title",
+    description: "#warning-description",
+    enableCheckbox: "#warning-enable-checkbox",
+    enableLabel: "#warning-enable-label",
+    button: "#warning-button",
+  });
+
+  elements = Object.freeze({
+    wrapper: document.querySelector(this.selectors.wrapper),
+    title: document.querySelector(this.selectors.title),
+    description: document.querySelector(this.selectors.description),
+    enableCheckbox: document.querySelector(this.selectors.enableCheckbox),
+    enableLabel: document.querySelector(this.selectors.enableLabel),
+    button: document.querySelector(this.selectors.button),
+  });
+
+  constructor() {
+    const elements = this.elements;
+    elements.title.textContent = TorStrings.rulesets.warningTitle;
+    elements.description.textContent = TorStrings.rulesets.warningDescription;
+    elements.enableLabel.textContent = TorStrings.rulesets.warningEnable;
+    elements.button.textContent = TorStrings.rulesets.warningButton;
+    elements.enableCheckbox.addEventListener(
+      "change",
+      this.onEnableChange.bind(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();
+  }
+}
+
+class DetailsState {
+  selectors = Object.freeze({
+    title: "#ruleset-title",
+    edit: "#ruleset-edit",
+    jwkLabel: "#ruleset-jwk-label",
+    jwkValue: "#ruleset-jwk-value",
+    pathPrefixLabel: "#ruleset-path-prefix-label",
+    pathPrefixValue: "#ruleset-path-prefix-value",
+    scopeLabel: "#ruleset-scope-label",
+    scopeValue: "#ruleset-scope-value",
+    enableCheckbox: "#ruleset-enable-checkbox",
+    enableLabel: "#ruleset-enable-label",
+    updateButton: "#ruleset-update-button",
+    updated: "#ruleset-updated",
+  });
+
+  elements = Object.freeze({
+    title: document.querySelector(this.selectors.title),
+    edit: document.querySelector(this.selectors.edit),
+    jwkLabel: document.querySelector(this.selectors.jwkLabel),
+    jwkValue: document.querySelector(this.selectors.jwkValue),
+    pathPrefixLabel: document.querySelector(this.selectors.pathPrefixLabel),
+    pathPrefixValue: document.querySelector(this.selectors.pathPrefixValue),
+    scopeLabel: document.querySelector(this.selectors.scopeLabel),
+    scopeValue: document.querySelector(this.selectors.scopeValue),
+    enableCheckbox: document.querySelector(this.selectors.enableCheckbox),
+    enableLabel: document.querySelector(this.selectors.enableLabel),
+    updateButton: document.querySelector(this.selectors.updateButton),
+    updated: document.querySelector(this.selectors.updated),
+  });
+
+  constructor() {
+    const elements = this.elements;
+    elements.edit.textContent = TorStrings.rulesets.edit;
+    elements.edit.addEventListener("click", this.onEdit.bind(this));
+    elements.jwkLabel.textContent = TorStrings.rulesets.jwk;
+    elements.pathPrefixLabel.textContent = TorStrings.rulesets.pathPrefix;
+    elements.scopeLabel.textContent = TorStrings.rulesets.scope;
+    elements.enableCheckbox.addEventListener(
+      "change",
+      this.onEnable.bind(this)
+    );
+    elements.enableLabel.textContent = TorStrings.rulesets.enable;
+    elements.updateButton.textContent = TorStrings.rulesets.checkUpdates;
+    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);
+    }
+  }
+}
+
+class EditState {
+  selectors = Object.freeze({
+    form: "#edit-ruleset-form",
+    title: "#edit-title",
+    nameGroup: "#edit-name-group",
+    nameLabel: "#edit-name-label",
+    nameInput: "#edit-name-input",
+    jwkLabel: "#edit-jwk-label",
+    jwkTextarea: "#edit-jwk-textarea",
+    pathPrefixLabel: "#edit-path-prefix-label",
+    pathPrefixInput: "#edit-path-prefix-input",
+    scopeLabel: "#edit-scope-label",
+    scopeInput: "#edit-scope-input",
+    enableCheckbox: "#edit-enable-checkbox",
+    enableLabel: "#edit-enable-label",
+    save: "#edit-save",
+    cancel: "#edit-cancel",
+  });
+
+  elements = Object.freeze({
+    form: document.querySelector(this.selectors.form),
+    title: document.querySelector(this.selectors.title),
+    jwkLabel: document.querySelector(this.selectors.jwkLabel),
+    jwkTextarea: document.querySelector(this.selectors.jwkTextarea),
+    pathPrefixLabel: document.querySelector(this.selectors.pathPrefixLabel),
+    pathPrefixInput: document.querySelector(this.selectors.pathPrefixInput),
+    scopeLabel: document.querySelector(this.selectors.scopeLabel),
+    scopeInput: document.querySelector(this.selectors.scopeInput),
+    enableCheckbox: document.querySelector(this.selectors.enableCheckbox),
+    enableLabel: document.querySelector(this.selectors.enableLabel),
+    save: document.querySelector(this.selectors.save),
+    cancel: document.querySelector(this.selectors.cancel),
+  });
+
+  constructor() {
+    const elements = this.elements;
+    elements.jwkLabel.textContent = TorStrings.rulesets.jwk;
+    elements.jwkTextarea.setAttribute(
+      "placeholder",
+      TorStrings.rulesets.jwkPlaceholder
+    );
+    elements.pathPrefixLabel.textContent = TorStrings.rulesets.pathPrefix;
+    elements.pathPrefixInput.setAttribute(
+      "placeholder",
+      TorStrings.rulesets.pathPrefixPlaceholder
+    );
+    elements.scopeLabel.textContent = TorStrings.rulesets.scope;
+    elements.scopeInput.setAttribute(
+      "placeholder",
+      TorStrings.rulesets.scopePlaceholder
+    );
+    elements.enableLabel.textContent = TorStrings.rulesets.enable;
+    elements.save.textContent = TorStrings.rulesets.save;
+    elements.save.addEventListener("click", this.onSave.bind(this));
+    elements.cancel.textContent = TorStrings.rulesets.cancel;
+    elements.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(TorStrings.rulesets.jwkInvalid);
+      valid = false;
+    }
+
+    const pathPrefix = elements.pathPrefixInput.value.trim();
+    try {
+      const url = new URL(pathPrefix);
+      if (url.protocol !== "http:" && url.protocol !== "https:") {
+        elements.pathPrefixInput.setCustomValidity(
+          TorStrings.rulesets.pathPrefixInvalid
+        );
+        valid = false;
+      } else {
+        elements.pathPrefixInput.setCustomValidity("");
+      }
+    } catch (err) {
+      console.error("The path prefix is not a valid URL", err);
+      elements.pathPrefixInput.setCustomValidity(
+        TorStrings.rulesets.pathPrefixInvalid
+      );
+      valid = false;
+    }
+
+    let scope;
+    try {
+      scope = new RegExp(elements.scopeInput.value.trim());
+      elements.scopeInput.setCustomValidity("");
+    } catch (err) {
+      elements.scopeInput.setCustomValidity(TorStrings.rulesets.scopeInvalid);
+      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);
+    }
+  }
+}
+
+class NoRulesetsState {
+  show() {}
+  hide() {}
+}
+
+class RulesetList {
+  selectors = Object.freeze({
+    heading: "#ruleset-heading",
+    list: "#ruleset-list",
+    emptyContainer: "#ruleset-list-empty",
+    emptyTitle: "#ruleset-list-empty-title",
+    emptyDescription: "#ruleset-list-empty-description",
+    itemTemplate: "#ruleset-template",
+    itemName: ".name",
+    itemDescr: ".description",
+  });
+
+  elements = Object.freeze({
+    heading: document.querySelector(this.selectors.heading),
+    list: document.querySelector(this.selectors.list),
+    emptyContainer: document.querySelector(this.selectors.emptyContainer),
+    emptyTitle: document.querySelector(this.selectors.emptyTitle),
+    emptyDescription: document.querySelector(this.selectors.emptyDescription),
+    itemTemplate: document.querySelector(this.selectors.itemTemplate),
+  });
+
+  nameAttribute = "data-name";
+
+  rulesets = [];
+
+  constructor() {
+    const elements = this.elements;
+
+    // Header
+    elements.heading.textContent = TorStrings.rulesets.rulesets;
+    // Empty
+    elements.emptyTitle.textContent = TorStrings.rulesets.noRulesets;
+    elements.emptyDescription.textContent = TorStrings.rulesets.noRulesetsDescr;
+
+    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(this.selectors.itemName).textContent = ruleset.name;
+    const descr = item.querySelector(this.selectors.itemDescr);
+    if (ruleset.enabled) {
+      setUpdateDate(ruleset, descr);
+    } else {
+      descr.textContent = TorStrings.rulesets.disabled;
+      item.classList.add("disabled");
+    }
+    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);
+    }
+  }
+}
+
+class AboutRulesets {
+  _state = null;
+
+  async init() {
+    const args = await RPMSendQuery("rulesets:get-init-args");
+    TorStrings = args.TorStrings;
+    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
new file mode 100644
index 0000000000000..69cd584ac1edf
--- /dev/null
+++ 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
new file mode 100644
index 0000000000000..e0b67442d89c0
--- /dev/null
+++ 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
new file mode 100644
index 0000000000000..daec4c3025244
--- /dev/null
+++ b/browser/components/rulesets/moz.build
@@ -0,0 +1,6 @@
+JAR_MANIFESTS += ['jar.mn']
+
+EXTRA_JS_MODULES += [
+    'RulesetsChild.jsm',
+    'RulesetsParent.jsm',
+]
diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml
index b3e93c5443969..243585deecbf7 100644
--- a/modules/libpref/init/StaticPrefList.yaml
+++ b/modules/libpref/init/StaticPrefList.yaml
@@ -1338,6 +1338,11 @@
   value: true
   mirror: always
 
+- 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
index 69e4f547e8eb3..d1348783da12e 100644
--- a/netwerk/build/components.conf
+++ b/netwerk/build/components.conf
@@ -652,3 +652,14 @@ if link_service:
             'singleton': True,
         }, **link_service)
     ]
+
+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
index 3dce83342516b..e818a481595e3 100644
--- a/netwerk/build/nsNetCID.h
+++ b/netwerk/build/nsNetCID.h
@@ -842,4 +842,14 @@
     }                                                \
   }
 
+// 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
new file mode 100644
index 0000000000000..692c74b917930
--- /dev/null
+++ 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
new file mode 100644
index 0000000000000..a23bf93cee8b0
--- /dev/null
+++ b/netwerk/dns/OnionAliasService.cpp
@@ -0,0 +1,100 @@
+#include "torproject/OnionAliasService.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;
+  }
+
+  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
new file mode 100644
index 0000000000000..5f72d295018e8
--- /dev/null
+++ b/netwerk/dns/OnionAliasService.h
@@ -0,0 +1,36 @@
+#ifndef OnionAliasService_h_
+#define OnionAliasService_h_
+
+#include "ScopedNSSTypes.h"
+#include "IOnionAliasService.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;
+};
+
+}
+
+#endif  // OnionAliasService_h_
diff --git a/netwerk/dns/effective_tld_names.dat b/netwerk/dns/effective_tld_names.dat
index 1dc8fe37114af..4f9772b57e85b 100644
--- a/netwerk/dns/effective_tld_names.dat
+++ b/netwerk/dns/effective_tld_names.dat
@@ -5527,6 +5527,8 @@ pro.om
 
 // onion : https://tools.ietf.org/html/rfc7686
 onion
+tor.onion
+securedrop.tor.onion
 
 // org : https://en.wikipedia.org/wiki/.org
 org
diff --git a/netwerk/dns/moz.build b/netwerk/dns/moz.build
index 1498dd2ceb9e0..560916367e825 100644
--- a/netwerk/dns/moz.build
+++ b/netwerk/dns/moz.build
@@ -110,3 +110,7 @@ USE_LIBS += ["icu"]
 
 if CONFIG["CC_TYPE"] in ("clang", "gcc"):
     CXXFLAGS += ["-Wno-error=shadow"]
+
+XPIDL_SOURCES += ["IOnionAliasService.idl"]
+UNIFIED_SOURCES += ["OnionAliasService.cpp"]
+EXPORTS.torproject += ["OnionAliasService.h"]
diff --git a/netwerk/socket/nsSOCKSIOLayer.cpp b/netwerk/socket/nsSOCKSIOLayer.cpp
index f9fc29552aceb..a9dc8f9dde119 100644
--- a/netwerk/socket/nsSOCKSIOLayer.cpp
+++ b/netwerk/socket/nsSOCKSIOLayer.cpp
@@ -25,6 +25,8 @@
 #include "mozilla/net/DNS.h"
 #include "mozilla/Unused.h"
 
+#include "IOnionAliasService.h"
+
 using mozilla::LogLevel;
 using namespace mozilla::net;
 
@@ -861,11 +863,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
index c20c1c0583784..0a84aecc6c724 100644
--- a/security/manager/ssl/SSLServerCertVerification.cpp
+++ b/security/manager/ssl/SSLServerCertVerification.cpp
@@ -137,6 +137,8 @@
 #include "sslerr.h"
 #include "sslexp.h"
 
+#include "IOnionAliasService.h"
+
 extern mozilla::LazyLogModule gPIPNSSLog;
 
 using namespace mozilla::pkix;
@@ -1037,6 +1039,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
index a315b30835d48..9ae7ac47b0566 100644
--- a/security/manager/ssl/SSLServerCertVerification.h
+++ b/security/manager/ssl/SSLServerCertVerification.h
@@ -141,7 +141,9 @@ 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.jsm b/toolkit/modules/RemotePageAccessManager.jsm
index c195ab688b236..f9cbf0badf69a 100644
--- a/toolkit/modules/RemotePageAccessManager.jsm
+++ b/toolkit/modules/RemotePageAccessManager.jsm
@@ -211,6 +211,20 @@ let RemotePageAccessManager = {
       ],
       RPMRecordTelemetryEvent: ["*"],
     },
+    "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: ["*"],

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the tbb-commits mailing list