tor-browser

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

commit 1627a32bbb190bc880602d85be8938f929e73816
parent bb48c9ac0e4d1374d85e1890b307363f8052d875
Author: alex <alex.the.stout5@gmail.com>
Date:   Tue, 21 Oct 2025 14:31:29 +0000

Bug 694856 - Allow Open Link context menu items to be shown in more situations when selecting plain text links. r=urlbar-reviewers,Standard8

Replace SelectionUtils regex logic with looksLikeUrl and looksLikeOrigin methods from UrlUtils to improve recognition of plaintext links for the context menu.

Differential Revision: https://phabricator.services.mozilla.com/D258780

Diffstat:
Mbrowser/base/content/test/contextMenu/browser_contextmenu_plaintextlinks.js | 313+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/urlbar/UrlbarProviderAutofill.sys.mjs | 1+
Mtoolkit/modules/SelectionUtils.sys.mjs | 27++++++++++++++++++++-------
Mtoolkit/modules/UrlUtils.sys.mjs | 31+++++++++++++++++++++++++++++--
4 files changed, 363 insertions(+), 9 deletions(-)

diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_plaintextlinks.js b/browser/base/content/test/contextMenu/browser_contextmenu_plaintextlinks.js @@ -32,6 +32,32 @@ add_task(async function () { <div id="block2"> <p id="mainDomain">main.example.com</p> </div> + <div id="block3"> + <p id="ipURL">http://192.168.0.1/</p> + <p id="ipWithPath">http://192.168.0.1/hello</p> + <p id="hostWithSlash">example.com/</p> + <p id="hostWithPath">example.com/hello</p> + <p id="looksLikeURL">http://cheese/hello</p> + <p id="usernamePasswordURL">hello:password@google.com</p> + <p id="numberInHost">exam4ple.com</p> + <p id="hostWithPort">example.com:8080</p> + </div> + <div id="block4"> + <p id="email">hello@google.com</p> + <p id="justCheese">cheese</p> + <p id="colonSeparated">hello:this</p> + <p id="atSymbolSeparated">hello@this</p> + <p id="noHostWithNumber">hello/1</p> + <p id="noHostWithText">hello/cheese</p> + <p id="noHostWithModulo">hello/%</p> + <p id="noHostWithQuestion">hello/?</p> + <p id="noHostWithPound">hello/#</p> + <p id="noHostWithSlashOnly">hello/</p> + <p id="topDomainIllegalNumberEnd">example.com4</p> + <p id="topDomainIllegalNumberStart">example.4com</p> + <p id="topDomainIllegalNumberMid">example.co4m</p> + <p id="illegalNumberBeforePort">example.com4:8080</p> + </div> </div> `; @@ -136,6 +162,139 @@ add_task(async function () { expectedLink: "http://open-suse.ru/", message: "Link options should show for open-suse.ru", }, + { + id: "ip-address-only", + selection: { + startNode: "ipURL", + startIndex: "http://".length, + endNode: "ipURL", + endIndex: "http://192.168.0.1".length, + }, + expectLinks: true, + expectedLink: "http://192.168.0.1/", + message: "Link options should show for ip 192.168.0.1", + }, + { + id: "ip-with-protocol", + selection: { + startNode: "ipURL", + startIndex: 0, + endNode: "ipURL", + endIndex: "http://192.168.0.1".length, + }, + expectLinks: true, + expectedLink: "http://192.168.0.1/", + message: "Link options should show for ip http://192.168.0.1", + }, + { + id: "ip-with-protocol-and-slash", + selection: { + startNode: "ipURL", + startIndex: 0, + endNode: "ipURL", + endIndex: "http://192.168.0.1/".length, + }, + expectLinks: true, + expectedLink: "http://192.168.0.1/", + message: "Link options should show for ip http://192.168.0.1/", + }, + { + id: "ip-with-path", + selection: { + startNode: "ipWithPath", + startIndex: "http://".length, + endNode: "ipWithPath", + endIndex: "http://192.168.0.1/hello".length, + }, + expectLinks: true, + expectedLink: "http://192.168.0.1/hello", + message: "Link options should show for ip 192.168.0.1/hello", + }, + { + id: "ip-with-protocol-and-path", + selection: { + startNode: "ipWithPath", + startIndex: 0, + endNode: "ipWithPath", + endIndex: "http://192.168.0.1/hello".length, + }, + expectLinks: true, + expectedLink: "http://192.168.0.1/hello", + message: "Link options should show for ip http://192.168.0.1/hello", + }, + { + id: "host-with-slash-only", + selection: { + startNode: "hostWithSlash", + startIndex: 0, + endNode: "hostWithSlash", + endIndex: "example.com/".length, + }, + expectLinks: true, + expectedLink: "http://example.com/", + message: "Link options should show for example.com/", + }, + { + id: "host-with-path", + selection: { + startNode: "hostWithPath", + startIndex: 0, + endNode: "hostWithPath", + endIndex: "example.com/hello".length, + }, + expectLinks: true, + expectedLink: "http://example.com/hello", + message: "Link options should show for example.com/hello", + }, + { + id: "looks-like-URL", + selection: { + startNode: "looksLikeURL", + startIndex: 0, + endNode: "looksLikeURL", + endIndex: "http://cheese/hello".length, + }, + expectLinks: true, + expectedLink: "http://cheese/hello", + message: + "Link options should show for malformed but plausible url http://cheese/hello", + }, + { + id: "username-password-URL", + selection: { + startNode: "usernamePasswordURL", + startIndex: 0, + endNode: "usernamePasswordURL", + endIndex: "hello:password@google.com".length, + }, + expectLinks: true, + expectedLink: "http://hello:password@google.com/", + message: "Link options should show for hello:password@google.com", + }, + { + id: "host-with-number-in-name", + selection: { + startNode: "numberInHost", + startIndex: 0, + endNode: "numberInHost", + endIndex: "exam4ple.com".length, + }, + expectLinks: true, + expectedLink: "http://exam4ple.com/", + message: "Link options should show for exam4ple.com", + }, + { + id: "host-with-port", + selection: { + startNode: "hostWithPort", + startIndex: 0, + endNode: "hostWithPort", + endIndex: "example.com:8080".length, + }, + expectLinks: true, + expectedLink: "http://example.com:8080/", + message: "Link options should show for example.com:8080", + }, // ---- Non-URL selections ---- { @@ -196,6 +355,160 @@ add_task(async function () { expectLinks: false, message: "Link options should not show for 'open-suse.ru)'", }, + { + id: "email", // Emails are intentionally handled differently from URLs. + selection: { + startNode: "email", + startIndex: 0, + endNode: "email", + endIndex: "hello@google.com".length, + }, + expectLinks: false, + message: "Link options should not show for hello@google.com", + }, + { + id: "just-a-word", + selection: { + startNode: "justCheese", + startIndex: 0, + endNode: "justCheese", + endIndex: "cheese".length, + }, + expectLinks: false, + message: "Link options should not show for cheese", + }, + { + id: "colon-separated-words", + selection: { + startNode: "colonSeparated", + startIndex: 0, + endNode: "colonSeparated", + endIndex: "hello:this".length, + }, + expectLinks: false, + message: "Link options should not show for hello:this", + }, + { + id: "at-symbol-separated-words", + selection: { + startNode: "atSymbolSeparated", + startIndex: 0, + endNode: "atSymbolSeparated", + endIndex: "hello@this".length, + }, + expectLinks: false, + message: "Link options should not show for hello@this", + }, + { + id: "host-with-number-after-slash", + selection: { + startNode: "noHostWithNumber", + startIndex: 0, + endNode: "noHostWithNumber", + endIndex: "hello/1".length, + }, + expectLinks: false, + message: "Link options should not show for hello/1", + }, + { + id: "host-with-word-after-slash", + selection: { + startNode: "noHostWithText", + startIndex: 0, + endNode: "noHostWithText", + endIndex: "hello/cheese".length, + }, + expectLinks: false, + message: "Link options should not show for hello/cheese", + }, + { + id: "host-with-modulo-symbol-after-slash", + selection: { + startNode: "noHostWithModulo", + startIndex: 0, + endNode: "noHostWithModulo", + endIndex: "hello/%".length, + }, + expectLinks: false, + message: "Link options should not show for hello/%", + }, + { + id: "host-with-question-mark-after-slash", + selection: { + startNode: "noHostWithQuestion", + startIndex: 0, + endNode: "noHostWithQuestion", + endIndex: "hello/?".length, + }, + expectLinks: false, + message: "Link options should not show for hello/?", + }, + { + id: "host-with-pound-sign-after-slash", + selection: { + startNode: "noHostWithPound", + startIndex: 0, + endNode: "noHostWithPound", + endIndex: "hello/#".length, + }, + expectLinks: false, + message: "Link options should not show for hello/#", + }, + { + id: "host-with-nothing-after-slash", + selection: { + startNode: "noHostWithSlashOnly", + startIndex: 0, + endNode: "noHostWithSlashOnly", + endIndex: "hello/".length, + }, + expectLinks: false, + message: "Link options should not show for hello/", + }, + { + id: "top-level-domain-with-illegal-number-at-end", + selection: { + startNode: "topDomainIllegalNumberEnd", + startIndex: 1, + endNode: "topDomainIllegalNumberEnd", + endIndex: "example.com4".length, + }, + expectLinks: false, + message: "Link options should not show for example.com4", + }, + { + id: "top-level-domain-with-illegal-number-at-start", + selection: { + startNode: "topDomainIllegalNumberStart", + startIndex: 1, + endNode: "topDomainIllegalNumberStart", + endIndex: "example.4com".length, + }, + expectLinks: false, + message: "Link options should not show for example.4com", + }, + { + id: "top-level-domain-with-illegal-number-in-middle", + selection: { + startNode: "topDomainIllegalNumberMid", + startIndex: 1, + endNode: "topDomainIllegalNumberMid", + endIndex: "example.co4m".length, + }, + expectLinks: false, + message: "Link options should not show for example.co4m", + }, + { + id: "top-level-domain-with-illegal-number-before-port", + selection: { + startNode: "illegalNumberBeforePort", + startIndex: 1, + endNode: "illegalNumberBeforePort", + endIndex: "example.com4:8080".length, + }, + expectLinks: false, + message: "Link options should not show for example.com4:8080", + }, ]; await BrowserTestUtils.openNewForegroundTab( diff --git a/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs @@ -992,6 +992,7 @@ export class UrlbarProviderAutofill extends UrlbarProvider { if ( lazy.UrlUtils.looksLikeOrigin(this._searchString, { ignoreKnownDomains: true, + allowPartialNumericalTLDs: true, }) ) { [query, params] = this._getOriginQuery(queryContext); diff --git a/toolkit/modules/SelectionUtils.sys.mjs b/toolkit/modules/SelectionUtils.sys.mjs @@ -3,6 +3,12 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlUtils: "resource://gre/modules/UrlUtils.sys.mjs", +}); + export var SelectionUtils = { /** * Trim the selection text to a reasonable size and sanitize it to make it @@ -81,12 +87,13 @@ export var SelectionUtils = { // Have some text, let's figure out if it looks like a URL that isn't // actually a link. linkText = selectionStr.trim(); - if (/^(?:https?|ftp):/i.test(linkText)) { - try { - url = Services.io.newURI(linkText); - } catch (ex) {} - } else if (/^(?:[a-z\d-]+\.)+[a-z]+$/i.test(linkText)) { - // Check if this could be a valid url, just missing the protocol. + + if ( + lazy.UrlUtils.looksLikeUrl(linkText, { + requirePath: false, + validateOrigin: true, + }) + ) { // Now let's see if this is an intentional link selection. Our guess is // based on whether the selection begins/ends with whitespace or is // preceded/followed by a non-word character. @@ -138,7 +145,13 @@ export var SelectionUtils = { selectionStr = this.trimSelection(selectionStr, aCharLen); } - if (url && !url.host) { + // This try catch is necessary since in rare cases, we think some text is a + // link, but the host is actually invalid so we fail in nsIURI.host + try { + if (url && !url.host) { + url = null; + } + } catch (ex) { url = null; } diff --git a/toolkit/modules/UrlUtils.sys.mjs b/toolkit/modules/UrlUtils.sys.mjs @@ -22,6 +22,7 @@ export const UrlUtils = { REGEXP_LIKE_PROTOCOL: /^[A-Z+.-]+:\/*(?!\/)/i, REGEXP_USERINFO_INVALID_CHARS: /[^\w.~%!$&'()*+,;=:-]/, REGEXP_HOSTPORT_INVALID_CHARS: /[^\[\]A-Z0-9.:-]/i, + REGEXP_HOSTPORT_INVALID_TLD_NUM: /\.\w*\d\w*(:\d+)?$/, REGEXP_SINGLE_WORD_HOST: /^[^.:]+$/i, REGEXP_HOSTPORT_IP_LIKE: /^(?=(.*[.:].*){2})[a-f0-9\.\[\]:]+$/i, // This accepts partial IPv4. @@ -49,12 +50,18 @@ export const UrlUtils = { * @param {object} [options] * @param {boolean} [options.requirePath] * The url must have a path + * @param {boolean} [options.validateOrigin] + * The prepath must look like an origin * @param {ConsoleInstance} [logger] * Optional logger for debugging * @returns {boolean} * Whether the token looks like a URL */ - looksLikeUrl(token, { requirePath = false } = {}, logger) { + looksLikeUrl( + token, + { requirePath = false, validateOrigin = false } = {}, + logger + ) { if (token.length < 2) { return false; } @@ -77,6 +84,17 @@ export const UrlUtils = { return false; } + // Check if prePath looks like origin. + if (validateOrigin) { + const result = this.looksLikeOrigin(prePath, { + ignoreKnownDomains: false, + }); + if (result !== this.LOOKS_LIKE_ORIGIN.NONE) { + return true; + } + return false; + } + let path = slashIndex != -1 ? token.slice(slashIndex) : ""; logger?.debug("path", path); if (requirePath && !path) { @@ -134,6 +152,8 @@ export const UrlUtils = { * If true, the origin cannot be an IP address * @param {boolean} [options.noPort] * If true, the origin cannot have a port number + * @param {boolean} [options.allowPartialNumericalTLDs] + * If true, the origin can have numbers in its top level domain * @param {ConsoleInstance} [logger] * Optional logger for debugging * @returns {number} @@ -141,7 +161,12 @@ export const UrlUtils = { */ looksLikeOrigin( token, - { ignoreKnownDomains = false, noIp = false, noPort = false } = {}, + { + ignoreKnownDomains = false, + noIp = false, + noPort = false, + allowPartialNumericalTLDs = false, + } = {}, logger ) { if (!token.length) { @@ -174,6 +199,8 @@ export const UrlUtils = { this.REGEXP_LIKE_PROTOCOL.test(hostPort) || this.REGEXP_USERINFO_INVALID_CHARS.test(userinfo) || this.REGEXP_HOSTPORT_INVALID_CHARS.test(hostPort) || + (!allowPartialNumericalTLDs && + this.REGEXP_HOSTPORT_INVALID_TLD_NUM.test(hostPort)) || (!this.REGEXP_SINGLE_WORD_HOST.test(hostPort) && this.REGEXP_HOSTPORT_IP_LIKE.test(hostPort) && this.REGEXP_HOSTPORT_INVALID_IP.test(hostPort))