tor-browser

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

commit 34bb180f280bb27ade49bba150cfa5fa3d0694fb
parent e1b31b217b27026a727940f347444a4275d8ded7
Author: smayya <smayya@mozilla.com>
Date:   Sat, 20 Dec 2025 11:47:38 +0000

Bug 2005990 - Add test for skipping LNA checks when captive portal is active. r=necko-reviewers,valentin

This test verifies that local network access (LNA) permission checks
are skipped when a captive portal is in the LOCKED_PORTAL state.

The test creates a mock captive portal detection server and an LNA
target server, then verifies three scenarios:
1. LNA requests are blocked when no captive portal is active
2. LNA requests succeed when captive portal is locked (active)
3. LNA requests are blocked again after captive portal is unlocked

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

Diffstat:
Mbrowser/base/content/test/captivePortal/browser_captivePortal_lna.js | 38+++++++++++++++-----------------------
Anetwerk/test/unit/test_lna_captive_portal.js | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnetwerk/test/unit/xpcshell.toml | 2++
3 files changed, 329 insertions(+), 23 deletions(-)

diff --git a/browser/base/content/test/captivePortal/browser_captivePortal_lna.js b/browser/base/content/test/captivePortal/browser_captivePortal_lna.js @@ -160,9 +160,9 @@ add_task(async function test_captivePortalTab_noLnaPrompt() { 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() { +// Tests that a regular tab (non-captive portal) does NOT trigger an LNA +// permission prompt when accessing local network resources during captive portal. +add_task(async function test_regularTab_noLnaPrompt_duringCaptivePortal() { await portalDetected(); let canonicalURL = `http://127.0.0.1:${gHttpServer.identity.primaryPort}/`; @@ -175,30 +175,12 @@ add_task(async function test_regularTab_hasLnaPrompt() { canonicalURL ); - // Verify the tab has the isCaptivePortalTab flag set + // Verify the tab does not have 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" + "Regular tab should not have isCaptivePortalTab flag set" ); - // 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( () => @@ -214,6 +196,16 @@ add_task(async function test_regularTab_hasLnaPrompt() { }); is(bodyText, "hello", "Page should display the fetch response"); + // Verify that no local network LNA permission prompt appeared + let lnaPrompt = PopupNotifications.getNotification( + "local-network", + tab.linkedBrowser + ); + ok( + !lnaPrompt, + "Should not show local network LNA prompt for regular tab during captive portal" + ); + // Clean up BrowserTestUtils.removeTab(tab); await freePortal(true); diff --git a/netwerk/test/unit/test_lna_captive_portal.js b/netwerk/test/unit/test_lna_captive_portal.js @@ -0,0 +1,312 @@ +"use strict"; + +const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); + +let httpserver = null; +let lnaServer = null; + +ChromeUtils.defineLazyGetter(this, "cpURI", function () { + return ( + "http://localhost:" + httpserver.identity.primaryPort + "/captive.html" + ); +}); + +ChromeUtils.defineLazyGetter(this, "LNA_URL", function () { + return "http://localhost:" + lnaServer.identity.primaryPort + "/test"; +}); + +const SUCCESS_STRING = + '<meta http-equiv="refresh" content="0;url=https://support.mozilla.org/kb/captive-portal"/>'; +let cpResponse = SUCCESS_STRING; + +function captivePortalHandler(metadata, response) { + response.setHeader("Content-Type", "text/html"); + response.bodyOutputStream.write(cpResponse, cpResponse.length); +} + +function lnaHandler(metadata, response) { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + let body = "success"; + response.bodyOutputStream.write(body, body.length); +} + +const PREF_CAPTIVE_ENABLED = "network.captive-portal-service.enabled"; +const PREF_CAPTIVE_TESTMODE = "network.captive-portal-service.testMode"; +const PREF_CAPTIVE_ENDPOINT = "captivedetect.canonicalURL"; +const PREF_CAPTIVE_MINTIME = "network.captive-portal-service.minInterval"; +const PREF_CAPTIVE_MAXTIME = "network.captive-portal-service.maxInterval"; +const PREF_DNS_NATIVE_IS_LOCALHOST = "network.dns.native-is-localhost"; + +const cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService( + Ci.nsICaptivePortalService +); + +function makeChannel(url, triggeringPrincipalURI = null) { + let uri = NetUtil.newURI(url); + var principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + + var triggeringPrincipal; + if (triggeringPrincipalURI) { + let triggeringURI = NetUtil.newURI(triggeringPrincipalURI); + triggeringPrincipal = Services.scriptSecurityManager.createContentPrincipal( + triggeringURI, + {} + ); + } else { + let triggeringURI = NetUtil.newURI("https://public.example.com"); + triggeringPrincipal = Services.scriptSecurityManager.createContentPrincipal( + triggeringURI, + {} + ); + } + + return NetUtil.newChannel({ + uri: url, + loadingPrincipal: principal, + triggeringPrincipal, + securityFlags: Ci.nsILoadInfo.SEC_REQUIRE_SAME_ORIGIN_INHERITS_SEC_CONTEXT, + contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER, + }).QueryInterface(Ci.nsIHttpChannel); +} + +add_setup(async function () { + // Setup captive portal detection server + httpserver = new HttpServer(); + httpserver.registerPathHandler("/captive.html", captivePortalHandler); + httpserver.start(-1); + + // Setup LNA target server + lnaServer = new HttpServer(); + lnaServer.registerPathHandler("/test", lnaHandler); + lnaServer.start(-1); + + // Configure captive portal service + Services.prefs.setCharPref(PREF_CAPTIVE_ENDPOINT, cpURI); + Services.prefs.setIntPref(PREF_CAPTIVE_MINTIME, 50); + Services.prefs.setIntPref(PREF_CAPTIVE_MAXTIME, 100); + Services.prefs.setBoolPref(PREF_CAPTIVE_TESTMODE, true); + Services.prefs.setBoolPref(PREF_DNS_NATIVE_IS_LOCALHOST, true); + + // Configure LNA blocking + Services.prefs.setBoolPref("network.lna.blocking", true); + Services.prefs.setBoolPref("network.localhost.prompt.testing", true); + Services.prefs.setBoolPref("network.localnetwork.prompt.testing", true); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref(PREF_CAPTIVE_ENABLED); + Services.prefs.clearUserPref(PREF_CAPTIVE_TESTMODE); + Services.prefs.clearUserPref(PREF_CAPTIVE_ENDPOINT); + Services.prefs.clearUserPref(PREF_CAPTIVE_MINTIME); + Services.prefs.clearUserPref(PREF_CAPTIVE_MAXTIME); + Services.prefs.clearUserPref(PREF_DNS_NATIVE_IS_LOCALHOST); + Services.prefs.clearUserPref("network.lna.blocking"); + Services.prefs.clearUserPref("network.localhost.prompt.testing"); + Services.prefs.clearUserPref("network.localnetwork.prompt.testing"); + Services.prefs.clearUserPref("network.localhost.prompt.testing.allow"); + Services.prefs.clearUserPref("network.localnetwork.prompt.testing.allow"); + Services.prefs.clearUserPref("network.lna.address_space.private.override"); + + await new Promise(resolve => { + httpserver.stop(resolve); + }); + await new Promise(resolve => { + lnaServer.stop(resolve); + }); + }); +}); + +function observerPromise(topic) { + return new Promise(resolve => { + let observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe(aSubject, aTopic, aData) { + if (aTopic == topic) { + Services.obs.removeObserver(observer, topic); + resolve(aData); + } + }, + }; + Services.obs.addObserver(observer, topic); + }); +} + +add_task(async function test_localnetwork_blocked_without_captive_portal() { + // Override address space to treat this localhost:port as Private (local network) + Services.prefs.setCharPref( + "network.lna.address_space.private.override", + "127.0.0.1:" + lnaServer.identity.primaryPort + ); + + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Services.prefs.setBoolPref( + "network.localnetwork.prompt.testing.allow", + false + ); + + let chan = makeChannel(LNA_URL); + chan.loadInfo.parentIpAddressSpace = Ci.nsILoadInfo.Public; + + await new Promise(resolve => { + chan.asyncOpen(new ChannelListener(resolve, null, CL_EXPECT_FAILURE)); + }); + + Assert.equal( + chan.status, + Cr.NS_ERROR_LOCAL_NETWORK_ACCESS_DENIED, + "Request should be blocked when captive portal is not active" + ); + Services.prefs.clearUserPref("network.lna.address_space.private.override"); +}); + +add_task(async function test_localnetwork_allowed_with_captive_portal() { + // Override address space to treat this localhost:port as Private (local network) + Services.prefs.setCharPref( + "network.lna.address_space.private.override", + "127.0.0.1:" + lnaServer.identity.primaryPort + ); + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Assert.equal(cps.state, Ci.nsICaptivePortalService.UNKNOWN); + + // Start captive portal service and wait for it to detect "no captive portal" + let notification = observerPromise("network:captive-portal-connectivity"); + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, true); + await notification; + Assert.equal(cps.state, Ci.nsICaptivePortalService.NOT_CAPTIVE); + + // Trigger captive portal detection (locked state) + cpResponse = "captive portal page"; + notification = observerPromise("captive-portal-login"); + cps.recheckCaptivePortal(); + await notification; + Assert.equal( + cps.state, + Ci.nsICaptivePortalService.LOCKED_PORTAL, + "Captive portal should be in LOCKED_PORTAL state" + ); + + // Set prompt to deny - but it should still succeed because captive portal is active + Services.prefs.setBoolPref( + "network.localnetwork.prompt.testing.allow", + false + ); + + let chan = makeChannel(LNA_URL); + chan.loadInfo.parentIpAddressSpace = Ci.nsILoadInfo.Public; + + await new Promise(resolve => { + chan.asyncOpen(new ChannelListener(resolve, null, 0)); + }); + + Assert.equal( + chan.status, + Cr.NS_OK, + "Request should succeed when captive portal is active (locked)" + ); + + // Cleanup: unlock the captive portal + cpResponse = SUCCESS_STRING; + notification = observerPromise("captive-portal-login-success"); + cps.recheckCaptivePortal(); + await notification; + Assert.equal(cps.state, Ci.nsICaptivePortalService.UNLOCKED_PORTAL); + + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Services.prefs.clearUserPref("network.lna.address_space.private.override"); +}); + +add_task(async function test_localhost_blocked_during_captive_portal() { + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Assert.equal(cps.state, Ci.nsICaptivePortalService.UNKNOWN); + + // Start captive portal service and wait for it to detect "no captive portal" + let notification = observerPromise("network:captive-portal-connectivity"); + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, true); + await notification; + Assert.equal(cps.state, Ci.nsICaptivePortalService.NOT_CAPTIVE); + + // Trigger captive portal detection (locked state) + cpResponse = "captive portal page"; + notification = observerPromise("captive-portal-login"); + cps.recheckCaptivePortal(); + await notification; + Assert.equal( + cps.state, + Ci.nsICaptivePortalService.LOCKED_PORTAL, + "Captive portal should be in LOCKED_PORTAL state" + ); + + // Set prompt to deny localhost access + Services.prefs.setBoolPref("network.localhost.prompt.testing.allow", false); + + // Create a separate localhost server (without private override) + // This will be treated as Local address space, not Private + let localhostServer = new HttpServer(); + localhostServer.registerPathHandler("/test", lnaHandler); + localhostServer.start(-1); + + let localhostURL = + "http://localhost:" + localhostServer.identity.primaryPort + "/test"; + + let chan = makeChannel(localhostURL); + chan.loadInfo.parentIpAddressSpace = Ci.nsILoadInfo.Public; + + await new Promise(resolve => { + chan.asyncOpen(new ChannelListener(resolve, null, CL_EXPECT_FAILURE)); + }); + + Assert.equal( + chan.status, + Cr.NS_ERROR_LOCAL_NETWORK_ACCESS_DENIED, + "Localhost access should be blocked even when captive portal is active" + ); + + // Cleanup + await new Promise(resolve => { + localhostServer.stop(resolve); + }); + + // Unlock the captive portal + cpResponse = SUCCESS_STRING; + notification = observerPromise("captive-portal-login-success"); + cps.recheckCaptivePortal(); + await notification; + Assert.equal(cps.state, Ci.nsICaptivePortalService.UNLOCKED_PORTAL); + + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); +}); + +add_task( + async function test_localnetwork_blocked_after_captive_portal_unlocked() { + Services.prefs.setCharPref( + "network.lna.address_space.private.override", + "127.0.0.1:" + lnaServer.identity.primaryPort + ); + + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Services.prefs.setBoolPref( + "network.localnetwork.prompt.testing.allow", + false + ); + + Assert.equal(cps.state, Ci.nsICaptivePortalService.UNKNOWN); + + let chan = makeChannel(LNA_URL); + chan.loadInfo.parentIpAddressSpace = Ci.nsILoadInfo.Public; + + await new Promise(resolve => { + chan.asyncOpen(new ChannelListener(resolve, null, CL_EXPECT_FAILURE)); + }); + + Assert.equal( + chan.status, + Cr.NS_ERROR_LOCAL_NETWORK_ACCESS_DENIED, + "Request should be blocked again when captive portal is no longer active" + ); + Services.prefs.clearUserPref("network.lna.address_space.private.override"); + } +); diff --git a/netwerk/test/unit/xpcshell.toml b/netwerk/test/unit/xpcshell.toml @@ -1115,6 +1115,8 @@ skip-if = [ ["test_local_network_access.js"] +["test_lna_captive_portal.js"] + ["test_localhost_offline.js"] ["test_localstreams.js"]