tor-browser

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

commit af26e0d427cdfe0d2c80434252df03bf86ffeb42
parent 195b1f76068d7f08b67a70ce17d0cf5943c20663
Author: Valentin Gosu <valentin.gosu@gmail.com>
Date:   Fri, 24 Oct 2025 13:01:54 +0000

Bug 1984105 - Disable Public to Private network LNA checks for Captive Portals r=sunil,necko-reviewers,tabbrowser-reviewers,sthompson

This patch adds a isCaptivePortalTab field to the browsingContext.
We still keep setting the LOAD_FLAGS_DISABLE_TRR loadFlags,
though we could probably move that to BrowsingContext eventually.

nsHttpChannel now automatically allows captive portal browsing contexts
to do public -> private loads without prompting.

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

Diffstat:
Mbrowser/base/content/browser-captivePortal.js | 2+-
Mbrowser/base/content/test/captivePortal/browser.toml | 3+++
Abrowser/base/content/test/captivePortal/browser_captivePortal_lna.js | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/base/content/test/captivePortal/file_captivePortal_lna.html | 28++++++++++++++++++++++++++++
Mbrowser/components/tabbrowser/content/tabbrowser.js | 13+++++++++----
Mdocshell/base/BrowsingContext.h | 8++++++++
Mdocshell/base/CanonicalBrowsingContext.cpp | 11+++++++++++
Mdocshell/base/nsDocShell.cpp | 10++++++++++
Mdocshell/base/nsDocShellLoadState.cpp | 11+++++++++++
Mdocshell/base/nsDocShellLoadState.h | 7+++++++
Mdom/chrome-webidl/LoadURIOptions.webidl | 6++++++
Mdom/ipc/DOMTypes.ipdlh | 2++
Mnetwerk/protocol/http/nsHttpChannel.cpp | 6++++++
13 files changed, 322 insertions(+), 5 deletions(-)

diff --git a/browser/base/content/browser-captivePortal.js b/browser/base/content/browser-captivePortal.js @@ -395,7 +395,7 @@ var CaptivePortalWatcher = { userContextId: gBrowser.contentPrincipal.userContextId, } ), - disableTRR: true, + isCaptivePortalTab: true, }); this._captivePortalTab = Cu.getWeakReference(tab); this._previousCaptivePortalTab = Cu.getWeakReference(tab); diff --git a/browser/base/content/test/captivePortal/browser.toml b/browser/base/content/test/captivePortal/browser.toml @@ -18,6 +18,9 @@ skip-if = ["debug && verify-standalone"] ["browser_captivePortal_https_only.js"] +["browser_captivePortal_lna.js"] +support-files = ["head.js", "file_captivePortal_lna.html"] + ["browser_captivePortal_trr_mode3.js"] https_first_disabled = true diff --git a/browser/base/content/test/captivePortal/browser_captivePortal_lna.js b/browser/base/content/test/captivePortal/browser_captivePortal_lna.js @@ -0,0 +1,220 @@ +"use strict"; + +const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); + +const PUBLIC_PAGE_URL = + "https://example.com/browser/browser/base/content/test/captivePortal/file_captivePortal_lna.html"; +let SERVER_RESPONSE = ""; +const CANONICAL_HTML = "<!DOCTYPE html><html><body>hello</body></html>"; +let gHttpServer; +let privateServer; +let localServer; + +add_setup(async function setup() { + // Set up local HTTP server + gHttpServer = new HttpServer(); + gHttpServer.start(); + gHttpServer.registerPathHandler("/", (request, response) => { + response.setHeader("Access-Control-Allow-Origin", "*", false); + response.setHeader("Content-Type", "text/html", false); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(SERVER_RESPONSE); + }); + + privateServer = new HttpServer(); + privateServer.start(); + privateServer.registerPathHandler("/", (request, response) => { + response.setHeader("Access-Control-Allow-Origin", "*", false); + response.setHeader("Content-Type", "text/plain", false); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("hello"); + }); + + localServer = new HttpServer(); + localServer.start(); + localServer.registerPathHandler("/", (request, response) => { + response.setHeader("Access-Control-Allow-Origin", "*", false); + response.setHeader("Content-Type", "text/plain", false); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("hello"); + }); + + SERVER_RESPONSE = `<!DOCTYPE html><html><body><script>fetch('http://localhost:${privateServer.identity.primaryPort}/').then(r => r.text()).then(t => document.body.textContent = t);</script></body></html>`; + + await SpecialPowers.pushPrefEnv({ + set: [ + ["network.lna.blocking", true], + ["network.http.rcwn.enabled", false], + [ + "captivedetect.canonicalURL", + `http://127.0.0.1:${gHttpServer.identity.primaryPort}/`, + ], + ["captivedetect.canonicalContent", CANONICAL_HTML], + // Set up address space override to this page appear as public + [ + "network.lna.address_space.public.override", + `127.0.0.1:${gHttpServer.identity.primaryPort}`, + ], + [ + "network.lna.address_space.private.override", + `127.0.0.1:${privateServer.identity.primaryPort}`, + ], + ], + }); + + registerCleanupFunction(async () => { + await gHttpServer.stop(); + await privateServer.stop(); + await localServer.stop(); + }); +}); + +function observeRequest(url) { + return new Promise(resolve => { + const observer = { + observe(subject, topic) { + if (topic !== "http-on-stop-request") { + return; + } + + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + if (!channel || channel.URI.spec !== url) { + return; + } + + Services.obs.removeObserver(observer, "http-on-stop-request"); + resolve(channel.status); + }, + }; + Services.obs.addObserver(observer, "http-on-stop-request"); + }); +} + +// Tests that a captive portal tab making a request to a local network +// resource does not trigger an LNA permission prompt. +add_task(async function test_captivePortalTab_noLnaPrompt() { + // Simulate portal detection + await portalDetected(); + + let canonicalURL = `http://127.0.0.1:${gHttpServer.identity.primaryPort}/`; + let portalTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, canonicalURL); + + // Wait for the captive portal notification + let notification = await ensurePortalNotification(window); + + // Click the notification button to open the captive portal tab + let button = notification.querySelector("button.notification-button"); + button.click(); + + let portalTab = await portalTabPromise; + + // Verify the tab has the isCaptivePortalTab flag set + ok( + portalTab.linkedBrowser.browsingContext.isCaptivePortalTab, + "Captive portal tab should have isCaptivePortalTab flag set" + ); + + // Wait for the fetch to complete and the page content to be updated + await BrowserTestUtils.waitForCondition( + () => + SpecialPowers.spawn(portalTab.linkedBrowser, [], () => { + return content.document.body.textContent === "hello"; + }), + "Waiting for fetch response to be displayed on the page" + ); + + // Verify the page content contains "hello" from the fetch response + let bodyText = await SpecialPowers.spawn(portalTab.linkedBrowser, [], () => { + return content.document.body.textContent; + }); + is(bodyText, "hello", "Page should display the fetch response"); + + // Verify that no LNA permission prompt appeared + let lnaPrompt = PopupNotifications.getNotification( + "local-network", + portalTab.linkedBrowser + ); + ok( + !lnaPrompt, + "Should not show LNA prompt for captive portal tab accessing local network" + ); + + await SpecialPowers.spawn( + portalTab.linkedBrowser, + [localServer.identity.primaryPort], + port => { + content.console.log("url", `http://localhost:${port}/`); + content.fetch(`http://localhost:${port}/`); + } + ); + await BrowserTestUtils.waitForCondition( + () => + PopupNotifications.getNotification("localhost", portalTab.linkedBrowser), + "Waiting for localhost prompt" + ); + + // Clean up + BrowserTestUtils.removeTab(portalTab); + await freePortal(true); +}); + +// Tests that a regular tab (non-captive portal) does trigger an LNA +// permission prompt when accessing local network resources. +add_task(async function test_regularTab_hasLnaPrompt() { + await portalDetected(); + + let canonicalURL = `http://127.0.0.1:${gHttpServer.identity.primaryPort}/`; + + // Wait for the captive portal notification + await ensurePortalNotification(window); + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + canonicalURL + ); + + // Verify the tab has the isCaptivePortalTab flag set + ok( + !tab.linkedBrowser.browsingContext.isCaptivePortalTab, + "New tab should not have isCaptivePortalTab flag set" + ); + + // Wait for the LNA permission prompt to appear + await BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + + // Verify that LNA permission prompt appeared + let lnaPrompt = PopupNotifications.getNotification( + "local-network", + tab.linkedBrowser + ); + ok( + lnaPrompt, + "Should show LNA prompt for regular tab accessing local network" + ); + + // Click the "Allow" button on the doorhanger + let popupNotification = lnaPrompt?.owner?.panel?.childNodes?.[0]; + ok(popupNotification, "Notification popup is available"); + popupNotification.button.doCommand(); + + // Wait for the fetch to complete and the page content to be updated + await BrowserTestUtils.waitForCondition( + () => + SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return content.document.body.textContent === "hello"; + }), + "Waiting for fetch response to be displayed on the page" + ); + + // Verify the page content contains "hello" from the fetch response + let bodyText = await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return content.document.body.textContent; + }); + is(bodyText, "hello", "Page should display the fetch response"); + + // Clean up + BrowserTestUtils.removeTab(tab); + await freePortal(true); +}); diff --git a/browser/base/content/test/captivePortal/file_captivePortal_lna.html b/browser/base/content/test/captivePortal/file_captivePortal_lna.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Captive Portal LNA Test Page</title> +</head> +<body> +<h1>Captive Portal LNA Test</h1> +<div id="result"></div> + +<script type="text/javascript"> + window.addEventListener('load', async function () { + const resultDiv = document.getElementById('result'); + const params = new URLSearchParams(location.search); + const localUrl = "http://localhost:21555/?type=fetch&rand=" + (params.get("rand") || Math.random()); + + try { + resultDiv.textContent = "Making fetch request to local server..."; + const response = await fetch(localUrl); + const text = await response.text(); + resultDiv.textContent = "Success: " + text; + } catch (error) { + resultDiv.textContent = "Error: " + error.message; + } + }); +</script> +</body> +</html> diff --git a/browser/components/tabbrowser/content/tabbrowser.js b/browser/components/tabbrowser/content/tabbrowser.js @@ -2887,13 +2887,13 @@ bulkOrderedOpen, charset, createLazyBrowser, - disableTRR, eventDetail, focusUrlBar, forceNotRemote, forceAllowDataURI, fromExternal, inBackground = true, + isCaptivePortalTab, elementIndex, tabIndex, lazyTabTitle, @@ -3103,8 +3103,8 @@ allowInheritPrincipal, allowThirdPartyFixup, fromExternal, - disableTRR, forceAllowDataURI, + isCaptivePortalTab, skipLoad, referrerInfo, charset, @@ -3784,8 +3784,8 @@ allowInheritPrincipal, allowThirdPartyFixup, fromExternal, - disableTRR, forceAllowDataURI, + isCaptivePortalTab, skipLoad, referrerInfo, charset, @@ -3843,7 +3843,7 @@ if (!allowInheritPrincipal) { loadFlags |= LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL; } - if (disableTRR) { + if (isCaptivePortalTab) { loadFlags |= LOAD_FLAGS_DISABLE_TRR; } if (forceAllowDataURI) { @@ -3862,6 +3862,7 @@ schemelessInput, hasValidUserGestureActivation, textDirectiveUserActivation, + isCaptivePortalTab, }); } catch (ex) { console.error(ex); @@ -9081,6 +9082,10 @@ loadURIOptions ); + if (loadURIOptions.isCaptivePortalTab) { + browser.browsingContext.isCaptivePortalTab = true; + } + // XXX(nika): Is `browser.isNavigating` necessary anymore? // XXX(gijs): Unsure. But it mirrors docShell.isNavigating, but in the parent process // (and therefore imperfectly so). diff --git a/docshell/base/BrowsingContext.h b/docshell/base/BrowsingContext.h @@ -150,6 +150,9 @@ struct EmbedderColorSchemes { /* Hold the pinned/app-tab state and should be used on top level browsing \ * contexts only */ \ FIELD(IsAppTab, bool) \ + /* Whether this is a captive portal tab. Should be used on top level \ + * browsing contexts only */ \ + FIELD(IsCaptivePortalTab, bool) \ /* Whether there's more than 1 tab / toplevel browsing context in this \ * parent window. Used to determine if a given BC is allowed to resize \ * and/or move the window or not. */ \ @@ -1290,6 +1293,11 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache { bool CanSet(FieldIndex<IDX_IsAppTab>, const bool& aValue, ContentParent* aSource); + bool CanSet(FieldIndex<IDX_IsCaptivePortalTab>, const bool& aValue, + ContentParent* aSource) { + return true; + } + bool CanSet(FieldIndex<IDX_HasSiblings>, const bool& aValue, ContentParent* aSource); diff --git a/docshell/base/CanonicalBrowsingContext.cpp b/docshell/base/CanonicalBrowsingContext.cpp @@ -263,6 +263,7 @@ void CanonicalBrowsingContext::ReplacedBy( Transaction txn; txn.SetBrowserId(GetBrowserId()); txn.SetIsAppTab(GetIsAppTab()); + txn.SetIsCaptivePortalTab(GetIsCaptivePortalTab()); txn.SetHasSiblings(GetHasSiblings()); txn.SetTopLevelCreatedByWebContent(GetTopLevelCreatedByWebContent()); txn.SetHistoryID(GetHistoryID()); @@ -1908,6 +1909,11 @@ void CanonicalBrowsingContext::LoadURI(nsIURI* aURI, return; } + // Set the captive portal tab flag on the browsing context if requested + if (loadState->GetIsCaptivePortalTab()) { + (void)SetIsCaptivePortalTab(true); + } + LoadURI(loadState, true); } @@ -1928,6 +1934,11 @@ void CanonicalBrowsingContext::FixupAndLoadURIString( return; } + // Set the captive portal tab flag on the browsing context if requested + if (loadState->GetIsCaptivePortalTab()) { + (void)SetIsCaptivePortalTab(true); + } + LoadURI(loadState, true); } diff --git a/docshell/base/nsDocShell.cpp b/docshell/base/nsDocShell.cpp @@ -3204,6 +3204,11 @@ nsresult nsDocShell::LoadURI(nsIURI* aURI, return NS_ERROR_FAILURE; } + // Set the captive portal tab flag on the browsing context if requested + if (loadState->GetIsCaptivePortalTab()) { + (void)mBrowsingContext->SetIsCaptivePortalTab(true); + } + return LoadURI(loadState, true); } @@ -3267,6 +3272,11 @@ nsresult nsDocShell::FixupAndLoadURIString( return NS_ERROR_FAILURE; } + // Set the captive portal tab flag on the browsing context if requested + if (loadState->GetIsCaptivePortalTab()) { + (void)mBrowsingContext->SetIsCaptivePortalTab(true); + } + return LoadURI(loadState, true); } diff --git a/docshell/base/nsDocShellLoadState.cpp b/docshell/base/nsDocShellLoadState.cpp @@ -117,6 +117,7 @@ nsDocShellLoadState::nsDocShellLoadState( } mUnstrippedURI = aLoadState.UnstrippedURI(); mRemoteTypeOverride = aLoadState.RemoteTypeOverride(); + mIsCaptivePortalTab = aLoadState.IsCaptivePortalTab(); if (aLoadState.NavigationAPIState()) { mNavigationAPIState = MakeRefPtr<nsStructuredCloneContainer>(); @@ -534,6 +535,7 @@ nsresult nsDocShellLoadState::CreateFromLoadURIOptions( loadState->SetForceMediaDocument(aLoadURIOptions.mForceMediaDocument); loadState->SetAppLinkLaunchType(aLoadURIOptions.mAppLinkLaunchType); + loadState->SetIsCaptivePortalTab(aLoadURIOptions.mIsCaptivePortalTab); loadState.forget(aResult); return NS_OK; @@ -1449,6 +1451,7 @@ DocShellLoadStateInit nsDocShellLoadState::Serialize( } loadState.UnstrippedURI() = mUnstrippedURI; loadState.RemoteTypeOverride() = mRemoteTypeOverride; + loadState.IsCaptivePortalTab() = mIsCaptivePortalTab; if (mNavigationAPIState) { loadState.NavigationAPIState().emplace(); @@ -1527,3 +1530,11 @@ uint32_t nsDocShellLoadState::GetAppLinkLaunchType() const { void nsDocShellLoadState::SetAppLinkLaunchType(uint32_t aAppLinkLaunchType) { mAppLinkLaunchType = aAppLinkLaunchType; } + +bool nsDocShellLoadState::GetIsCaptivePortalTab() const { + return mIsCaptivePortalTab; +} + +void nsDocShellLoadState::SetIsCaptivePortalTab(bool aIsCaptivePortalTab) { + mIsCaptivePortalTab = aIsCaptivePortalTab; +} diff --git a/docshell/base/nsDocShellLoadState.h b/docshell/base/nsDocShellLoadState.h @@ -464,6 +464,10 @@ class nsDocShellLoadState final { uint32_t GetAppLinkLaunchType() const; void SetAppLinkLaunchType(uint32_t aAppLinkLaunchType); + // This is used as the getter/setter for the captive portal tab flag. + bool GetIsCaptivePortalTab() const; + void SetIsCaptivePortalTab(bool aIsCaptivePortalTab); + protected: // Destructor can't be defaulted or inlined, as header doesn't have all type // includes it needs to do so. @@ -744,6 +748,9 @@ class nsDocShellLoadState final { // App link intent launch type: 0 = unknown, 1 = cold, 2 = warm, 3 = hot. uint32_t mAppLinkLaunchType = 0; + + // Whether this is a captive portal tab. + bool mIsCaptivePortalTab = false; }; #endif /* nsDocShellLoadState_h__ */ diff --git a/dom/chrome-webidl/LoadURIOptions.webidl b/dom/chrome-webidl/LoadURIOptions.webidl @@ -137,4 +137,10 @@ dictionary LoadURIOptions { * COLD = 1, WARM = 2, HOT = 3, UNKNOWN = 0. */ unsigned long appLinkLaunchType = 0; + + /** + * Whether this is a captive portal tab. Used to grant local network access + * permissions without prompting the user. + */ + boolean isCaptivePortalTab = false; }; diff --git a/dom/ipc/DOMTypes.ipdlh b/dom/ipc/DOMTypes.ipdlh @@ -249,6 +249,8 @@ struct DocShellLoadStateInit bool TryToReplaceWithSessionHistoryLoad; bool IsMetaRefresh; + + bool IsCaptivePortalTab; }; struct TimedChannelInfo diff --git a/netwerk/protocol/http/nsHttpChannel.cpp b/netwerk/protocol/http/nsHttpChannel.cpp @@ -2197,6 +2197,12 @@ nsresult nsHttpChannel::InitTransaction() { } } + // Grant LNA permissions for captive portal tabs to allow them to access + // local network resources without prompting the user + if (bc && bc->GetIsCaptivePortalTab()) { + mLNAPermission.mLocalNetworkPermission = LNAPermission::Granted; + } + rv = mTransaction->Init( mCaps, mConnectionInfo, &mRequestHead, mUploadStream, mReqContentLength, LoadUploadStreamHasHeaders(), GetCurrentSerialEventTarget(), callbacks,