tor-browser

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

commit 9e69bd91cac88025a86f851be3eba8759d8a1a14
parent 233b190d8229822d9532e2de6dae444527d4dd64
Author: smayya <smayya@mozilla.com>
Date:   Fri, 19 Dec 2025 22:57:29 +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:
Anetwerk/test/unit/test_lna_captive_portal.js | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnetwerk/test/unit/xpcshell.toml | 2++
2 files changed, 314 insertions(+), 0 deletions(-)

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"]