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:
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,