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 };