tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

torCircuitPanel.js (22575B)


      1 /**
      2 * Data about the current domain and circuit for a xul:browser.
      3 *
      4 * @typedef BrowserCircuitData
      5 * @property {string?} domain - The first party domain.
      6 * @property {string?} scheme - The scheme.
      7 * @property {NodeData[]} nodes - The circuit in use for the browser.
      8 */
      9 
     10 var gTorCircuitPanel = {
     11  /**
     12   * The panel node.
     13   *
     14   * @type {MozPanel}
     15   */
     16  panel: null,
     17  /**
     18   * The toolbar button that opens the panel.
     19   *
     20   * @type {Element}
     21   */
     22  toolbarButton: null,
     23  /**
     24   * The data for the currently shown browser.
     25   *
     26   * @type {BrowserCircuitData?}
     27   */
     28  _currentBrowserData: null,
     29  /**
     30   * Whether the panel has been initialized and has not yet been uninitialized.
     31   *
     32   * @type {bool}
     33   */
     34  _isActive: false,
     35  /**
     36   * The template element for circuit nodes.
     37   *
     38   * @type {HTMLTemplateElement?}
     39   */
     40  _nodeItemTemplate: null,
     41 
     42  /**
     43   * The topic on which circuit changes are broadcast.
     44   *
     45   * @type {string}
     46   */
     47  TOR_CIRCUIT_TOPIC: "TorCircuitChange",
     48 
     49  /**
     50   * Initialize the panel.
     51   */
     52  init() {
     53    this._isActive = true;
     54 
     55    this._log = console.createInstance({
     56      prefix: "TorCircuitPanel",
     57      maxLogLevelPref: "browser.torcircuitpanel.loglevel",
     58    });
     59 
     60    this.panel = document.getElementById("tor-circuit-panel");
     61    this._panelElements = {
     62      doc: document.getElementById("tor-circuit-panel-document"),
     63      heading: document.getElementById("tor-circuit-heading"),
     64      alias: document.getElementById("tor-circuit-alias"),
     65      aliasLabel: document.getElementById("tor-circuit-alias-label"),
     66      aliasMenu: document.getElementById("tor-circuit-panel-alias-menu"),
     67      list: document.getElementById("tor-circuit-node-list"),
     68      relaysItem: document.getElementById("tor-circuit-relays-item"),
     69      endItem: document.getElementById("tor-circuit-end-item"),
     70      newCircuitDescription: document.getElementById(
     71        "tor-circuit-new-circuit-description"
     72      ),
     73    };
     74    this.toolbarButton = document.getElementById("tor-circuit-button");
     75 
     76    // We add listeners for the .tor-circuit-alias-link.
     77    // NOTE: We have to add the listeners to the parent element because the
     78    // link (with data-l10n-name="alias-link") will be replaced with a new
     79    // cloned instance every time the parent gets re-translated.
     80    this._panelElements.aliasLabel.addEventListener("click", event => {
     81      if (!this._aliasLink.contains(event.target)) {
     82        return;
     83      }
     84      event.preventDefault();
     85      if (event.button !== 0) {
     86        return;
     87      }
     88      this._openAlias("tab");
     89    });
     90    this._panelElements.aliasLabel.addEventListener("contextmenu", event => {
     91      if (!this._aliasLink.contains(event.target)) {
     92        return;
     93      }
     94      event.preventDefault();
     95      this._panelElements.aliasMenu.openPopupAtScreen(
     96        event.screenX,
     97        event.screenY,
     98        true
     99      );
    100    });
    101 
    102    // Commands similar to nsContextMenu.js
    103    document
    104      .getElementById("tor-circuit-panel-alias-menu-new-tab")
    105      .addEventListener("command", () => {
    106        this._openAlias("tab");
    107      });
    108    document
    109      .getElementById("tor-circuit-panel-alias-menu-new-window")
    110      .addEventListener("command", () => {
    111        this._openAlias("window");
    112      });
    113    document
    114      .getElementById("tor-circuit-panel-alias-menu-copy")
    115      .addEventListener("command", () => {
    116        const alias = this._aliasLink?.href;
    117        if (!alias) {
    118          return;
    119        }
    120        Cc["@mozilla.org/widget/clipboardhelper;1"]
    121          .getService(Ci.nsIClipboardHelper)
    122          .copyString(alias);
    123      });
    124 
    125    // Button is a xul:toolbarbutton, so we use "command" rather than "click".
    126    document
    127      .getElementById("tor-circuit-new-circuit")
    128      .addEventListener("command", () => {
    129        TorDomainIsolator.newCircuitForBrowser(gBrowser.selectedBrowser);
    130      });
    131 
    132    // Update the display just before opening.
    133    this.panel.addEventListener("popupshowing", event => {
    134      if (event.target !== this.panel) {
    135        return;
    136      }
    137      this._updateCircuitPanel();
    138    });
    139 
    140    // Set the initial focus to the panel document itself, which has been made a
    141    // focusable target. Similar to webextension-popup-browser.
    142    // Switching to a document should prompt screen readers to enter "browse
    143    // mode" and allow the user to navigate the dialog content.
    144    // NOTE: We could set the focus to the first focusable child within the
    145    // document, but this would usually be the "New circuit" button, which would
    146    // skip over the rest of the document content.
    147    this.panel.addEventListener("popupshown", event => {
    148      if (event.target !== this.panel) {
    149        return;
    150      }
    151      this._panelElements.doc.focus();
    152    });
    153 
    154    // this.toolbarButton follows "identity-button" markup, so is a <xul:box>
    155    // rather than a <html:button>, or <xul:toolbarbutton>, so we need to set up
    156    // listeners for both "click" and "keydown", and not for "command".
    157    this.toolbarButton.addEventListener("keydown", event => {
    158      if (event.key !== "Enter" && event.key !== " ") {
    159        return;
    160      }
    161      event.stopPropagation();
    162      this.show();
    163    });
    164    this.toolbarButton.addEventListener("click", event => {
    165      event.stopPropagation();
    166      if (event.button !== 0) {
    167        return;
    168      }
    169      this.show();
    170    });
    171 
    172    this._nodeItemTemplate = document.getElementById(
    173      "tor-circuit-node-item-template"
    174    );
    175    // Prepare the unknown region name for the current locale.
    176    // NOTE: We expect this to complete before the first call to _updateBody.
    177    this._localeChanged();
    178 
    179    this._locationListener = {
    180      onLocationChange: (webProgress, request, locationURI, flags) => {
    181        if (
    182          webProgress.isTopLevel &&
    183          !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
    184        ) {
    185          // We have switched tabs or finished loading a new page, this can hide
    186          // the toolbar button if the new page has no circuit.
    187          this._updateCurrentBrowser();
    188        }
    189      },
    190    };
    191    // Notified of new locations for the currently selected browser (tab) *and*
    192    // switching selected browser.
    193    gBrowser.addProgressListener(this._locationListener);
    194 
    195    // Get notifications for circuit changes.
    196    Services.obs.addObserver(this, this.TOR_CIRCUIT_TOPIC);
    197    Services.obs.addObserver(this, "intl:app-locales-changed");
    198  },
    199 
    200  /**
    201   * Uninitialize the panel.
    202   */
    203  uninit() {
    204    this._isActive = false;
    205    gBrowser.removeProgressListener(this._locationListener);
    206    Services.obs.removeObserver(this, this.TOR_CIRCUIT_TOPIC);
    207    Services.obs.removeObserver(this, "intl:app-locales-changed");
    208  },
    209 
    210  /**
    211   * Observe circuit changes.
    212   *
    213   * @param {nsISupports} subject Notification-specific data
    214   * @param {string} topic The notification topic
    215   */
    216  observe(subject, topic) {
    217    switch (topic) {
    218      case this.TOR_CIRCUIT_TOPIC:
    219        // TODO: Maybe check if we actually need to do something earlier.
    220        this._updateCurrentBrowser();
    221        break;
    222      case "intl:app-locales-changed":
    223        this._localeChanged();
    224        break;
    225    }
    226  },
    227 
    228  /**
    229   * Show the circuit panel.
    230   *
    231   * This should only be called if the toolbar button is visible.
    232   */
    233  show() {
    234    this.panel.openPopup(this.toolbarButton, "bottomleft topleft", 0, 0);
    235  },
    236 
    237  /**
    238   * Hide the circuit panel.
    239   */
    240  hide() {
    241    this.panel.hidePopup();
    242  },
    243 
    244  /**
    245   * Get the current alias link instance.
    246   *
    247   * Note that this element instance may change whenever its parent element
    248   * (#tor-circuit-alias-label) is re-translated. Attributes should be copied to
    249   * the new instance.
    250   *
    251   * @returns {Element?}
    252   */
    253  get _aliasLink() {
    254    return this._panelElements.aliasLabel.querySelector(
    255      ".tor-circuit-alias-link"
    256    );
    257  },
    258 
    259  /**
    260   * Open the onion alias present in the alias link.
    261   *
    262   * @param {"window"|"tab"} where - Whether to open in a new tab or a new
    263   *   window.
    264   */
    265  _openAlias(where) {
    266    const url = this._aliasLink?.href;
    267    if (!url) {
    268      return;
    269    }
    270    // We hide the panel before opening the link.
    271    this.hide();
    272    window.openWebLinkIn(url, where);
    273  },
    274 
    275  /**
    276   * A list of schemes to never show the circuit display for.
    277   *
    278   * NOTE: Some of these pages may still have remote content within them, so
    279   * will still use tor circuits. But it doesn't make much sense to show the
    280   * circuit for the page itself.
    281   *
    282   * @type {string[]}
    283   */
    284  // FIXME: Check if we find a UX to handle some of these cases, and if we
    285  // manage to solve some technical issues.
    286  // See tor-browser#41700 and tor-browser!699.
    287  _ignoredSchemes: ["about", "file", "chrome", "resource"],
    288 
    289  /**
    290   * Update the current circuit and domain data for the currently selected
    291   * browser, possibly changing the UI.
    292   */
    293  _updateCurrentBrowser() {
    294    const browser = gBrowser.selectedBrowser;
    295    const domain = TorDomainIsolator.getDomainForBrowser(browser);
    296    const circuits = TorDomainIsolator.getCircuits(
    297      browser,
    298      domain,
    299      browser.contentPrincipal.originAttributes.userContextId
    300    );
    301    // TODO: Handle multiple circuits (for conflux). Only show the primary
    302    // circuit until the UI for that is developed.
    303    const nodes = circuits.length ? circuits[0] : [];
    304    // We choose the currentURI, which matches what is shown in the URL bar and
    305    // will match up with the domain.
    306    // In contrast, documentURI corresponds to the shown page. E.g. it could
    307    // point to "about:certerror".
    308    let scheme = browser.currentURI?.scheme;
    309    if (scheme === "about" && browser.currentURI?.filePath === "reader") {
    310      const searchParams = new URLSearchParams(browser.currentURI.query);
    311      if (searchParams.has("url")) {
    312        try {
    313          const uri = Services.io.newURI(searchParams.get("url"));
    314          scheme = uri.scheme;
    315        } catch (err) {
    316          this._log.error(err);
    317        }
    318      }
    319    }
    320 
    321    if (
    322      this._currentBrowserData &&
    323      this._currentBrowserData.domain === domain &&
    324      this._currentBrowserData.scheme === scheme &&
    325      this._currentBrowserData.nodes.length === nodes.length &&
    326      // The fingerprints of the nodes match.
    327      nodes.every(
    328        (n, index) =>
    329          n.fingerprint === this._currentBrowserData.nodes[index].fingerprint
    330      )
    331    ) {
    332      // No change.
    333      this._log.debug(
    334        "Skipping browser update because the data is already up to date."
    335      );
    336      return;
    337    }
    338 
    339    this._currentBrowserData = { domain, scheme, nodes };
    340    this._log.debug("Updating current browser.", this._currentBrowserData);
    341 
    342    if (
    343      // Schemes where we always want to hide the display.
    344      this._ignoredSchemes.includes(scheme) ||
    345      // Can't show the display without a domain. Don't really expect this
    346      // outside of "about" pages.
    347      !domain ||
    348      // As a fall back, we do not show the circuit for new pages which have no
    349      // circuit nodes (yet).
    350      // FIXME: Have a back end that handles this instead, and can tell us
    351      // whether the circuit is being established, even if the path details are
    352      // unknown right now. See tor-browser#41700.
    353      !nodes.length
    354    ) {
    355      // Only show the Tor circuit if we have credentials and node data.
    356      this._log.debug("No circuit found for current document.");
    357      // Make sure we close the popup.
    358      if (
    359        this.panel.contains(document.activeElement) ||
    360        this.toolbarButton.contains(document.activeElement)
    361      ) {
    362        // Focus is about to be lost.
    363        // E.g. navigating back to a page without a circuit with Alt+ArrowLeft
    364        // whilst the popup is open, or focus on the toolbar button.
    365        // By default when the panel closes after being opened with a keyboard,
    366        // focus will move back to the toolbar button. But we are about to hide
    367        // the toolbar button, and ToolbarKeyboardNavigator does not currently
    368        // handle re-assigning focus when the current item is hidden or removed.
    369        // See bugzilla bug 1823664.
    370        // Without editing ToolbarKeyboardNavigator, it is difficult to
    371        // re-assign focus to the next focusable item, so as a compromise we
    372        // focus the URL bar, which is close by.
    373        gURLBar.focus();
    374      }
    375      this.hide();
    376      this.toolbarButton.hidden = true;
    377      return;
    378    }
    379 
    380    this.toolbarButton.hidden = false;
    381 
    382    this._updateCircuitPanel();
    383  },
    384 
    385  /**
    386   * Get the tor onion address alias for the given domain.
    387   *
    388   * @param {string} domain An .onion domain to query an alias for.
    389   * @returns {string} The alias domain, or null if it has no alias.
    390   */
    391  _getOnionAlias(domain) {
    392    let alias = null;
    393    try {
    394      const service = Cc["@torproject.org/onion-alias-service;1"].getService(
    395        Ci.IOnionAliasService
    396      );
    397      alias = service.getOnionAlias(domain);
    398    } catch (e) {
    399      this._log.error(
    400        `Cannot verify if we are visiting an onion alias: ${e.message}`
    401      );
    402      return null;
    403    }
    404    if (alias === domain) {
    405      return null;
    406    }
    407    return alias;
    408  },
    409 
    410  /**
    411   * Updates the circuit display in the panel to show the current browser data.
    412   */
    413  _updateCircuitPanel() {
    414    if (this.panel.state !== "open" && this.panel.state !== "showing") {
    415      // Don't update the panel content if it is not open or about to open.
    416      return;
    417    }
    418 
    419    // NOTE: The _currentBrowserData.nodes data may be stale. In particular, the
    420    // circuit may have expired already, or we're still waiting on the new
    421    // circuit.
    422    if (
    423      !this._currentBrowserData?.domain ||
    424      !this._currentBrowserData?.nodes.length
    425    ) {
    426      // Unexpected since the toolbar button should be hidden in this case.
    427      this._log.warn(
    428        "Hiding panel since we have no domain, or no circuit data."
    429      );
    430      this.hide();
    431      return;
    432    }
    433 
    434    this._log.debug("Updating circuit panel");
    435 
    436    let domain = this._currentBrowserData.domain;
    437    const onionAlias = this._getOnionAlias(domain);
    438 
    439    this._updateHeading(domain, onionAlias, this._currentBrowserData.scheme);
    440 
    441    if (onionAlias) {
    442      // Show the circuit ending with the alias instead.
    443      domain = onionAlias;
    444    }
    445    this._updateBody(this._currentBrowserData.nodes, domain);
    446  },
    447 
    448  /**
    449   * Update the display of the heading to show the given domain.
    450   *
    451   * @param {string} domain - The domain to show.
    452   * @param {string?} onionAlias - The onion alias address for this domain, if
    453   *   it has one.
    454   * @param {string?} scheme - The scheme in use for the current domain.
    455   */
    456  _updateHeading(domain, onionAlias, scheme) {
    457    document.l10n.setAttributes(
    458      this._panelElements.heading,
    459      "tor-circuit-panel-heading",
    460      // Only shorten the onion domain if it has no alias.
    461      { host: TorUIUtils.shortenOnionAddress(domain) }
    462    );
    463 
    464    if (onionAlias) {
    465      if (scheme === "http" || scheme === "https") {
    466        // We assume the same scheme as the current page for the alias, which we
    467        // expect to be either http or https.
    468        // NOTE: The href property is partially presentational so that the link
    469        // location appears on hover.
    470        // NOTE: The href attribute should be copied to any new instances of
    471        // .tor-circuit-alias-link (with data-l10n-name="alias-link") when the
    472        // parent _panelElements.aliasLabel gets re-translated.
    473        this._aliasLink.href = `${scheme}://${onionAlias}`;
    474      } else {
    475        this._aliasLink.removeAttribute("href");
    476      }
    477      document.l10n.setAttributes(
    478        this._panelElements.aliasLabel,
    479        "tor-circuit-panel-alias",
    480        { alias: TorUIUtils.shortenOnionAddress(onionAlias) }
    481      );
    482      this._showPanelElement(this._panelElements.alias, true);
    483    } else {
    484      this._showPanelElement(this._panelElements.alias, false);
    485    }
    486  },
    487 
    488  /**
    489   * The currently shown circuit node items.
    490   *
    491   * @type {HTMLLIElement[]}
    492   */
    493  _nodeItems: [],
    494 
    495  /**
    496   * Update the display of the circuit body.
    497   *
    498   * @param {NodeData[]} nodes - The non-empty circuit nodes to show.
    499   * @param {string} domain - The domain to show for the last node.
    500   */
    501  _updateBody(nodes, domain) {
    502    // NOTE: Rather than re-creating the <li> nodes from scratch, we prefer
    503    // updating existing <li> nodes so that the display does not "flicker" in
    504    // width as we wait for Fluent DOM to fill the nodes with text content. I.e.
    505    // the existing node and text will remain in place, occupying the same
    506    // width, up until it is replaced by Fluent DOM.
    507    for (let index = 0; index < nodes.length; index++) {
    508      if (index >= this._nodeItems.length) {
    509        const newItem =
    510          this._nodeItemTemplate.content.children[0].cloneNode(true);
    511        const flagEl = newItem.querySelector(".tor-circuit-region-flag");
    512        // Hide region flag whenever the flag src does not exist.
    513        flagEl.addEventListener("error", () => {
    514          flagEl.classList.add("no-region-flag-src");
    515          flagEl.removeAttribute("src");
    516        });
    517        this._panelElements.list.insertBefore(
    518          newItem,
    519          this._panelElements.relaysItem
    520        );
    521 
    522        this._nodeItems.push(newItem);
    523      }
    524      this._updateCircuitNodeItem(
    525        this._nodeItems[index],
    526        nodes[index],
    527        index === 0
    528      );
    529    }
    530 
    531    // Remove excess items.
    532    // NOTE: We do not expect focus within a removed node.
    533    while (nodes.length < this._nodeItems.length) {
    534      this._nodeItems.pop().remove();
    535    }
    536 
    537    this._showPanelElement(
    538      this._panelElements.relaysItem,
    539      domain.endsWith(".onion")
    540    );
    541 
    542    // Set the address that we want to copy.
    543    this._panelElements.endItem.textContent =
    544      TorUIUtils.shortenOnionAddress(domain);
    545 
    546    // Button description text, depending on whether our first node was a
    547    // bridge, or otherwise a guard.
    548    document.l10n.setAttributes(
    549      this._panelElements.newCircuitDescription,
    550      nodes[0].bridgeType === null
    551        ? "tor-circuit-panel-new-button-description-guard"
    552        : "tor-circuit-panel-new-button-description-bridge"
    553    );
    554  },
    555 
    556  /**
    557   * Update a node item for the given circuit node data.
    558   *
    559   * @param {Element} nodeItem - The item to update.
    560   * @param {NodeData} node - The circuit node data to create an item for.
    561   * @param {bool} isCircuitStart - Whether this is the first node in the
    562   *   circuit.
    563   */
    564  _updateCircuitNodeItem(nodeItem, node, isCircuitStart) {
    565    const nameEl = nodeItem.querySelector(".tor-circuit-node-name");
    566    let flagSrc = null;
    567 
    568    if (node.bridgeType === null) {
    569      const regionCode = node.regionCode;
    570      flagSrc = this._regionFlagSrc(regionCode);
    571 
    572      const regionName = regionCode
    573        ? Services.intl.getRegionDisplayNames(undefined, [regionCode])[0]
    574        : this._unknownRegionName;
    575 
    576      if (isCircuitStart) {
    577        document.l10n.setAttributes(
    578          nameEl,
    579          "tor-circuit-panel-node-region-guard",
    580          { region: regionName }
    581        );
    582      } else {
    583        // Set the text content directly, rather than using Fluent.
    584        nameEl.removeAttribute("data-l10n-id");
    585        nameEl.removeAttribute("data-l10n-args");
    586        nameEl.textContent = regionName;
    587      }
    588    } else {
    589      // Do not show a flag for bridges.
    590 
    591      let bridgeType = node.bridgeType;
    592      if (bridgeType === "meek_lite") {
    593        bridgeType = "meek";
    594      } else if (bridgeType === "vanilla") {
    595        bridgeType = "";
    596      }
    597      if (bridgeType) {
    598        document.l10n.setAttributes(
    599          nameEl,
    600          "tor-circuit-panel-node-typed-bridge",
    601          { "bridge-type": bridgeType }
    602        );
    603      } else {
    604        document.l10n.setAttributes(nameEl, "tor-circuit-panel-node-bridge");
    605      }
    606    }
    607    const flagEl = nodeItem.querySelector(".tor-circuit-region-flag");
    608    flagEl.classList.toggle("no-region-flag-src", !flagSrc);
    609    if (flagSrc) {
    610      flagEl.setAttribute("src", flagSrc);
    611    } else {
    612      flagEl.removeAttribute("src");
    613    }
    614 
    615    const addressesEl = nodeItem.querySelector(".tor-circuit-addresses");
    616    // Empty children.
    617    addressesEl.replaceChildren();
    618    let firstAddr = true;
    619    for (const ip of node.ipAddrs) {
    620      if (firstAddr) {
    621        firstAddr = false;
    622      } else {
    623        addressesEl.append(", ");
    624      }
    625      // Use semantic <code> block for the ip addresses, so the content
    626      // (especially punctuation) can be better interpreted by screen readers,
    627      // if they support this.
    628      const ipEl = document.createElement("code");
    629      ipEl.classList.add("tor-circuit-ip-address");
    630      ipEl.textContent = ip;
    631      addressesEl.append(ipEl);
    632    }
    633  },
    634 
    635  /**
    636   * The string to use for unknown region names.
    637   *
    638   * Will be updated to match the current locale.
    639   *
    640   * @type {string}
    641   */
    642  _unknownRegionName: "Unknown region",
    643 
    644  /**
    645   * Update the name for regions to match the current locale.
    646   */
    647  _localeChanged() {
    648    document.l10n
    649      .formatValue("tor-circuit-panel-node-unknown-region")
    650      .then(name => {
    651        this._unknownRegionName = name;
    652        // Update the panel for the new region names, if it is shown.
    653        this._updateCircuitPanel();
    654      });
    655  },
    656 
    657  /**
    658   * Convert a region code into an emoji flag sequence.
    659   *
    660   * @param {string?} regionCode - The code to convert. It should be an upper
    661   *   case 2-letter BCP47 Region subtag to be converted into a flag.
    662   *
    663   * @returns {src?} The emoji flag img src, or null if there is no flag.
    664   */
    665  _regionFlagSrc(regionCode) {
    666    if (!regionCode?.match(/^[A-Z]{2}$/)) {
    667      return null;
    668    }
    669    // Convert the regionCode into an emoji flag sequence.
    670    const regionalIndicatorA = 0x1f1e6;
    671    const flagName = [
    672      regionalIndicatorA + (regionCode.codePointAt(0) - 65),
    673      regionalIndicatorA + (regionCode.codePointAt(1) - 65),
    674    ]
    675      .map(cp => cp.toString(16))
    676      .join("-");
    677 
    678    return `chrome://browser/content/tor-circuit-flags/${flagName}.svg`;
    679  },
    680 
    681  /**
    682   * Show or hide an element.
    683   *
    684   * Handles moving focus if it is contained within the element.
    685   *
    686   * @param {Element} element - The element to show or hide.
    687   * @param {bool} show - Whether to show the element.
    688   */
    689  _showPanelElement(element, show) {
    690    if (!show && element.contains(document.activeElement)) {
    691      // Move focus to the panel, otherwise it will be lost to the top-level.
    692      this.panel.focus();
    693    }
    694    element.hidden = !show;
    695  },
    696 };