2eb14fc0378432977d83366a3211c67f8913018d
max
  Mon Sep 21 06:21:51 2026 -0700
hgc and hgTrackUi: open off-site links in a new tab, with rel="noopener noreferrer"

offsiteLinksToNewTab() in utils.js runs over the rendered page alongside
addHgsidToLinks() and gives every http(s) link whose host is not ours a
target=_blank it does not already have, plus rel="noopener noreferrer".  Without
noopener the page that opens keeps a handle on the tab it came from and can
navigate it; without noreferrer the Referer header carries our own URL, which has
the session id in it.  The href on a track description page is written by whoever
wrote the track or the hub, so neither is theoretical.  A mailto: or an ftp: link
is left alone, and so is every link that stays on this server.

jsAddHgsidToLinks() now emits both calls and is renamed jsFixUpPageLinks().
hgTracks.js runs the same pass over the track description popup: the replace it
does before that already puts a target on every link in there, so what this adds
is the rel on the ones that leave.

refs #38380

diff --git src/hg/js/utils.js src/hg/js/utils.js
index 157dd006b69..03cb16912fe 100644
--- src/hg/js/utils.js
+++ src/hg/js/utils.js
@@ -1491,30 +1491,64 @@
             hash = href.substring(hashAt);
             href = href.substring(0, hashAt);
         }
         // a description page written while the server still filled in a $hgsid variable can
         // carry the reference itself.  Nothing resolves it now, and hgsid=$hgsid is not a
         // session id, so drop it and let the real one take its place below.
         href = href.replace(/([?&])hgsid=(?:\$\{hgsid\}|\$hgsid)(&|$)/i, '$1')
                    .replace(/[?&]$/, '');
         if (/[?&]hgsid=/i.test(href))
             continue;
         links[i].setAttribute('href', href + (href.indexOf('?') === -1 ? '?' : '&') +
                               'hgsid=' + encodeURIComponent(hgsid) + hash);
     }
 }
 
+function offsiteLinksToNewTab(root)
+{// send every link under root that leaves this server to a new tab, and keep that tab from
+ // reaching back. rel=noopener stops the page that opens from steering the tab it came from
+ // through window.opener; rel=noreferrer keeps our own URL, which carries the session id,
+ // out of the Referer header it sends. The href on a track description page is written by
+ // whoever wrote the track or the hub, so neither is theoretical.
+    var links = (root || document).querySelectorAll('a[href]');
+    for (var i = 0; i < links.length; i++) {
+        var href = links[i].getAttribute('href');
+        if (!href || href.charAt(0) === '#')
+            continue;
+        var url;
+        try {
+            url = new URL(href, document.baseURI);
+        } catch (e) {
+            continue;
+        }
+        // a page somewhere else: a mailto: or an ftp: link has nothing to gain from a tab
+        if ((url.protocol !== 'http:' && url.protocol !== 'https:') ||
+            url.host === window.location.host)
+            continue;
+        // leave a target the page asked for alone, but still add the rel: the popup in
+        // hgTracks puts target=_blank on everything before this runs
+        if (!links[i].getAttribute('target'))
+            links[i].setAttribute('target', '_blank');
+        var rel = links[i].getAttribute('rel') || '';
+        if (!/\bnoopener\b/.test(rel))
+            rel += (rel ? ' ' : '') + 'noopener';
+        if (!/\bnoreferrer\b/.test(rel))
+            rel += ' noreferrer';
+        links[i].setAttribute('rel', rel);
+    }
+}
+
 function undecoratedDb(db)
 // return the db name with any hub_id_ stripped
 {
 var retDb = db;
 if (db.startsWith("hub_")) {
     retDb = db.split('_').slice(2).join('_');
 }
 return retDb;
 }
 
 function getDb()
 {
     var db = normed($("input[name='db']").first());
     if (db)
         return db.value;