[tbb-commits] [tor-browser] 80/81: Bug 40458: Implement .tor.onion aliases

gitolite role git at cupani.torproject.org
Tue Oct 18 16:13:14 UTC 2022


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

pierov pushed a commit to branch tor-browser-102.4.0esr-12.0-1
in repository tor-browser.

commit 50e3bd4498ab6ef6a46b9c50287a1a8b38c16683
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/components/BrowserGlue.jsm                 |  42 ++
 browser/components/about/AboutRedirector.cpp       |   5 +
 browser/components/about/components.conf           |   1 +
 browser/components/moz.build                       |   1 +
 .../components/onionservices/OnionAliasStore.jsm   | 563 +++++++++++++++++++++
 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                    |  40 ++
 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 +
 26 files changed, 2098 insertions(+), 6 deletions(-)

diff --git a/browser/components/BrowserGlue.jsm b/browser/components/BrowserGlue.jsm
index 5b60b15029c0..0949c795d8c7 100644
--- a/browser/components/BrowserGlue.jsm
+++ b/browser/components/BrowserGlue.jsm
@@ -59,6 +59,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
   Normandy: "resource://normandy/Normandy.jsm",
   OnboardingMessageProvider:
     "resource://activity-stream/lib/OnboardingMessageProvider.jsm",
+  OnionAliasStore: "resource:///modules/OnionAliasStore.jsm",
   OsEnvironment: "resource://gre/modules/OsEnvironment.jsm",
   PageActions: "resource:///modules/PageActions.jsm",
   PageThumbs: "resource://gre/modules/PageThumbs.jsm",
@@ -682,6 +683,19 @@ let JSWINDOWACTORS = {
     enablePreference: "accessibility.blockautorefresh",
   },
 
+  Rulesets: {
+    parent: {
+      moduleURI: "resource:///modules/RulesetsParent.jsm",
+    },
+    child: {
+      moduleURI: "resource:///modules/RulesetsChild.jsm",
+      events: {
+        DOMWindowCreated: {},
+      },
+    },
+    matches: ["about:rulesets*"],
+  },
+
   ScreenshotsComponent: {
     parent: {
       moduleURI: "resource:///modules/ScreenshotsUtils.jsm",
@@ -2042,6 +2056,7 @@ BrowserGlue.prototype = {
       () => RFPHelper.uninit(),
       () => ASRouterNewTabHook.destroy(),
       () => UpdateListener.reset(),
+      () => OnionAliasStore.uninit(),
     ];
 
     for (let task of tasks) {
@@ -2669,6 +2684,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: () => {
           TabUnloader.init();
diff --git a/browser/components/about/AboutRedirector.cpp b/browser/components/about/AboutRedirector.cpp
index faf809728352..c3095c4bd3bb 100644
--- a/browser/components/about/AboutRedirector.cpp
+++ b/browser/components/about/AboutRedirector.cpp
@@ -88,6 +88,11 @@ static const RedirEntry kRedirMap[] = {
     {"robots", "chrome://browser/content/aboutRobots.xhtml",
      nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT |
          nsIAboutModule::ALLOW_SCRIPT},
+    {"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
index a1eb66c9fc95..d35ad34ec8ba 100644
--- a/browser/components/about/components.conf
+++ b/browser/components/about/components.conf
@@ -26,6 +26,7 @@ pages = [
     'restartrequired',
     'rights',
     'robots',
+    'rulesets',
     'sessionrestore',
     'tabcrashed',
     'torconnect',
diff --git a/browser/components/moz.build b/browser/components/moz.build
index 1adecb68bc8f..27cda5d68c04 100644
--- a/browser/components/moz.build
+++ b/browser/components/moz.build
@@ -49,6 +49,7 @@ DIRS += [
     "prompts",
     "protocolhandler",
     "resistfingerprinting",
+    "rulesets",
     "screenshots",
     "search",
     "securitylevel",
diff --git a/browser/components/onionservices/OnionAliasStore.jsm b/browser/components/onionservices/OnionAliasStore.jsm
new file mode 100644
index 000000000000..bc7f8a1d79cf
--- /dev/null
+++ b/browser/components/onionservices/OnionAliasStore.jsm
@@ -0,0 +1,563 @@
+// 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", () => {
+  const { ConsoleAPI } = ChromeUtils.import(
+    "resource://gre/modules/Console.jsm"
+  );
+  let consoleOptions = {
+    maxLogLevel: gDebuggingEnabled ? "all" : "log",
+    prefix: "OnionAlias",
+  };
+  return new 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 27f9d2da4a9e..8644548caa15 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 000000000000..e30de9c7201b
--- /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 000000000000..6a9553644cb1
--- /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 000000000000..60b699fe8a02
--- /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 000000000000..d5b03435b1e7
--- /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 000000000000..4fabdca1b93d
--- /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 000000000000..69cd584ac1ed
--- /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 000000000000..e0b67442d89c
--- /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 000000000000..daec4c302524
--- /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 2fe148d74119..647cc8037a0c 100644
--- a/modules/libpref/init/StaticPrefList.yaml
+++ b/modules/libpref/init/StaticPrefList.yaml
@@ -1526,6 +1526,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 38db2e513bd7..75ea79d087a3 100644
--- a/netwerk/build/components.conf
+++ b/netwerk/build/components.conf
@@ -658,3 +658,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 43817c837412..82f6fc02dfb7 100644
--- a/netwerk/build/nsNetCID.h
+++ b/netwerk/build/nsNetCID.h
@@ -845,4 +845,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 000000000000..692c74b91793
--- /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 000000000000..3d8a7643b045
--- /dev/null
+++ b/netwerk/dns/OnionAliasService.cpp
@@ -0,0 +1,100 @@
+#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;
+  }
+
+  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 000000000000..5809e4112e24
--- /dev/null
+++ b/netwerk/dns/OnionAliasService.h
@@ -0,0 +1,40 @@
+#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;
+};
+
+}
+
+#endif  // OnionAliasService_h_
diff --git a/netwerk/dns/effective_tld_names.dat b/netwerk/dns/effective_tld_names.dat
index 00e52627e055..8a4a55c4f8f5 100644
--- a/netwerk/dns/effective_tld_names.dat
+++ b/netwerk/dns/effective_tld_names.dat
@@ -5568,6 +5568,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 5d81059ed89d..c82689a147ec 100644
--- a/netwerk/dns/moz.build
+++ b/netwerk/dns/moz.build
@@ -110,3 +110,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
index f9fc29552ace..a9dc8f9dde11 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 2df79a3bc11a..32621111e19e 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;
@@ -909,6 +911,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 fa75bb918e7f..b2190ac393b5 100644
--- a/security/manager/ssl/SSLServerCertVerification.h
+++ b/security/manager/ssl/SSLServerCertVerification.h
@@ -133,7 +133,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 ec48d9276bde..6bf4f4c97d3b 100644
--- a/toolkit/modules/RemotePageAccessManager.jsm
+++ b/toolkit/modules/RemotePageAccessManager.jsm
@@ -212,6 +212,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