From a0f3b68a20fa94b3956a04f843dbb4d943007f47 Mon Sep 17 00:00:00 2001 From: smayya Date: Thu, 22 May 2025 11:11:28 +0000 Subject: [PATCH] Bug 1960582 - Add LNA telemetry. r=necko-reviewers,valentin Differential Revision: https://phabricator.services.mozilla.com/D249887 --- netwerk/base/nsNetUtil.cpp | 31 +++ netwerk/base/nsNetUtil.h | 3 + netwerk/metrics.yaml | 42 +++ netwerk/protocol/http/nsHttpChannel.cpp | 59 +++++ .../unit/test_ip_address_space_lna_glean.js | 247 ++++++++++++++++++ netwerk/test/unit/xpcshell.toml | 2 + 6 files changed, 384 insertions(+) create mode 100644 netwerk/test/unit/test_ip_address_space_lna_glean.js diff --git a/netwerk/base/nsNetUtil.cpp b/netwerk/base/nsNetUtil.cpp index 1eb16c54d6f5..217079758ff3 100644 --- a/netwerk/base/nsNetUtil.cpp +++ b/netwerk/base/nsNetUtil.cpp @@ -4166,5 +4166,36 @@ nsresult AddExtraHeaders(nsIHttpChannel* aHttpChannel, return NS_OK; } +bool IsLocalNetworkAccess(nsILoadInfo::IPAddressSpace aParentIPAddressSpace, + nsILoadInfo::IPAddressSpace aTargetIPAddressSpace) { + // Determine if the request is moving to a more private address space + // i.e. Public -> Private or Local + // Private -> Local + // Refer + // https://wicg.github.io/private-network-access/#private-network-request-heading + // for private network access + // XXX (sunil) add link to LNA spec once it is published + + if (aTargetIPAddressSpace == nsILoadInfo::IPAddressSpace::Public || + aTargetIPAddressSpace == nsILoadInfo::IPAddressSpace::Unknown) { + return false; + } + // Check if this is an access to a local resource from Public or Private + // network + if ((aTargetIPAddressSpace == nsILoadInfo::IPAddressSpace::Local) && + (aParentIPAddressSpace == nsILoadInfo::IPAddressSpace::Public || + aParentIPAddressSpace == nsILoadInfo::IPAddressSpace::Private)) { + return true; + } + + // Check if this is an access to a Private Network resource from a Public + // network + if ((aTargetIPAddressSpace == nsILoadInfo::IPAddressSpace::Private) && + (aParentIPAddressSpace == nsILoadInfo::IPAddressSpace::Public)) { + return true; + } + + return false; +} } // namespace net } // namespace mozilla diff --git a/netwerk/base/nsNetUtil.h b/netwerk/base/nsNetUtil.h index 8b689ee6e90f..50eabdb10aac 100644 --- a/netwerk/base/nsNetUtil.h +++ b/netwerk/base/nsNetUtil.h @@ -1188,6 +1188,9 @@ void ParseSimpleURISchemes(const nsACString& schemeList); nsresult AddExtraHeaders(nsIHttpChannel* aHttpChannel, const nsACString& aExtraHeaders, bool aMerge = true); + +bool IsLocalNetworkAccess(nsILoadInfo::IPAddressSpace aParentIPAddressSpace, + nsILoadInfo::IPAddressSpace aTargetIPAddressSpace); } // namespace net } // namespace mozilla diff --git a/netwerk/metrics.yaml b/netwerk/metrics.yaml index 3b888355194b..96f6891eda8d 100644 --- a/netwerk/metrics.yaml +++ b/netwerk/metrics.yaml @@ -1881,6 +1881,48 @@ networking: - load_is_http - load_is_http_for_local_domain + local_network_access: + type: labeled_counter + description: > + Whether the request is crossing to a more private addresspace + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960582 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960582 + notification_emails: + - smayya@mozilla.com + - vgosu@mozilla.com + - necko@gmail.com + expires: 150 + labels: + - private_to_local_http + - private_to_local_https + - public_to_local_http + - public_to_local_https + - public_to_private_http + - public_to_private_https + - success + - failure + + local_network_access_port: + type: custom_distribution + description: > + port used for local network access + range_min: 1 + range_max: 65535 + bucket_count: 65535 + unit: port number + histogram_type: linear + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960582 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960582 + notification_emails: + - smayya@mozilla.com + - vgosu@mozilla.com + - necko@gmail.com + expires: 150 + http_channel_sub_open_to_first_sent_https_rr: type: timing_distribution time_unit: millisecond diff --git a/netwerk/protocol/http/nsHttpChannel.cpp b/netwerk/protocol/http/nsHttpChannel.cpp index 393743c7bd71..b88ac643bd9d 100644 --- a/netwerk/protocol/http/nsHttpChannel.cpp +++ b/netwerk/protocol/http/nsHttpChannel.cpp @@ -8533,6 +8533,64 @@ static void RecordIPAddressSpaceTelemetry(bool aLoadSuccess, nsIURI* aURI, } } +static void RecordLNATelemetry(bool aLoadSuccess, nsIURI* aURI, + nsILoadInfo* aLoadInfo, NetAddr& aPeerAddr) { + if (!aLoadInfo || !aURI) { + return; + } + + RefPtr bc; + aLoadInfo->GetBrowsingContext(getter_AddRefs(bc)); + + nsILoadInfo::IPAddressSpace parentAddressSpace = + nsILoadInfo::IPAddressSpace::Unknown; + if (!bc) { + parentAddressSpace = aLoadInfo->GetParentIpAddressSpace(); + } else { + parentAddressSpace = bc->GetCurrentIPAddressSpace(); + } + + if (!mozilla::net::IsLocalNetworkAccess(parentAddressSpace, + aLoadInfo->GetIpAddressSpace())) { + return; + } + + if (aLoadSuccess) { + mozilla::glean::networking::local_network_access.Get("success"_ns).Add(1); + } else { + mozilla::glean::networking::local_network_access.Get("failure"_ns).Add(1); + } + + uint16_t port = 0; + if (NS_SUCCEEDED(aPeerAddr.GetPort(&port))) { + mozilla::glean::networking::local_network_access_port + .AccumulateSingleSample(port); + } + + // label format is _to__ + // At this point we are sure that the request is a LNA, + // Hence we can safely assume few conditions to construct the label + nsAutoCString glean_lna_label; + if (aLoadInfo->GetParentIpAddressSpace() == + nsILoadInfo::IPAddressSpace::Public) { + glean_lna_label.Append("public_to_"_ns); + } else { + glean_lna_label.Append("private_to_"_ns); + } + if (aLoadInfo->GetIpAddressSpace() == nsILoadInfo::IPAddressSpace::Private) { + glean_lna_label.Append("private_"_ns); + } else { + glean_lna_label.Append("local_"_ns); + } + if (aURI->SchemeIs("https")) { + glean_lna_label.Append("https"_ns); + } else { + glean_lna_label.Append("http"_ns); + } + + mozilla::glean::networking::local_network_access.Get(glean_lna_label).Add(1); +} + NS_IMETHODIMP nsHttpChannel::OnStopRequest(nsIRequest* request, nsresult status) { MOZ_ASSERT(!mAsyncOpenTime.IsNull()); @@ -8684,6 +8742,7 @@ nsHttpChannel::OnStopRequest(nsIRequest* request, nsresult status) { RecordIPAddressSpaceTelemetry(NS_SUCCEEDED(mStatus), mURI, mLoadInfo, mPeerAddr); + RecordLNATelemetry(NS_SUCCEEDED(mStatus), mURI, mLoadInfo, mPeerAddr); // If we are using the transaction to serve content, we also save the // time since async open in the cache entry so we can compare telemetry diff --git a/netwerk/test/unit/test_ip_address_space_lna_glean.js b/netwerk/test/unit/test_ip_address_space_lna_glean.js new file mode 100644 index 000000000000..f399f1fb0634 --- /dev/null +++ b/netwerk/test/unit/test_ip_address_space_lna_glean.js @@ -0,0 +1,247 @@ +"use strict"; + +const override = Cc["@mozilla.org/network/native-dns-override;1"].getService( + Ci.nsINativeDNSResolverOverride +); +const mockNetwork = Cc[ + "@mozilla.org/network/mock-network-controller;1" +].getService(Ci.nsIMockNetworkLayerController); +const certOverrideService = Cc[ + "@mozilla.org/security/certoverride;1" +].getService(Ci.nsICertOverrideService); + +const DOMAIN = "example.org"; + +function makeChan(url, expected) { + let chan = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, + }).QueryInterface(Ci.nsIHttpChannel); + + if ( + expected.PublicToPrivateHttp !== undefined || + expected.PublicToLocalHttp !== undefined || + expected.PublicToPublicHttp !== undefined || + expected.PublicToPrivateHttps !== undefined || + expected.PublicToLocalHttps !== undefined || + expected.PublicToPublicHttp !== undefined + ) { + chan.loadInfo.parentIpAddressSpace = Ci.nsILoadInfo.Public; + } else if ( + expected.PrivateToLocalHttp !== undefined || + expected.PrivateToPrivateHttp !== undefined || + expected.PrivateToLocalHttps !== undefined || + expected.PrivateToPrivateHttps !== undefined + ) { + chan.loadInfo.parentIpAddressSpace = Ci.nsILoadInfo.Private; + } + return chan; +} + +function channelOpenPromise(chan, flags) { + return new Promise(resolve => { + function finish(req, buffer) { + resolve([req, buffer]); + certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + false + ); + } + certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + true + ); + chan.asyncOpen(new ChannelListener(finish, null, flags)); + }); +} + +let server; + +add_setup(async function setup() { + Services.prefs.setBoolPref("network.socket.attach_mock_network_layer", true); + + Services.fog.initializeFOG(); + + server = new NodeHTTPServer(); + await server.start(); + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("network.disable-localhost-when-offline"); + Services.prefs.clearUserPref("network.dns.use_override_as_peer_address"); + Services.prefs.clearUserPref("dom.security.https_only_mode"); + Services.prefs.clearUserPref("dom.security.https_first"); + Services.prefs.clearUserPref("dom.security.https_first_schemeless"); + Services.prefs.clearUserPref("network.socket.attach_mock_network_layer"); + await server.stop(); + }); +}); + +function verifyGleanValues(aDescription, aExpected) { + info(aDescription); + + let privateToLocalHttp = aExpected.PrivateToLocalHttp || null; + let publicToPrivateHttp = aExpected.PublicToPrivateHttp || null; + let publicToLocalHttp = aExpected.PublicToLocalHttp || null; + let privateToLocalHttps = aExpected.PrivateToLocalHttps || null; + let publicToPrivateHttps = aExpected.PublicToPrivateHttps || null; + let publicToLocalHttps = aExpected.PublicToLocalHttps || null; + + let glean = Glean.networking.localNetworkAccess; + Assert.equal( + glean.private_to_local_http.testGetValue(), + privateToLocalHttp, + "verify private_to_local_http" + ); + Assert.equal( + glean.public_to_private_http.testGetValue(), + publicToPrivateHttp, + "verify public_to_private_http" + ); + Assert.equal( + glean.public_to_local_http.testGetValue(), + publicToLocalHttp, + "verify public_to_local_http" + ); + Assert.equal( + glean.private_to_local_https.testGetValue(), + privateToLocalHttps, + "verify private_to_local_http" + ); + Assert.equal( + glean.public_to_private_https.testGetValue(), + publicToPrivateHttps, + "verify public_to_private_http" + ); + Assert.equal( + glean.public_to_local_https.testGetValue(), + publicToLocalHttps, + "verify public_to_local_http" + ); + + Assert.equal( + glean.public_to_local_https.testGetValue(), + publicToLocalHttps, + "verify public_to_local_http" + ); + + if ( + privateToLocalHttp || + publicToPrivateHttp || + publicToLocalHttp || + privateToLocalHttps || + publicToPrivateHttps || + publicToLocalHttps + ) { + Assert.equal( + glean.success.testGetValue(), + 1, + "verify local_network_access_success" + ); + // XXX (sunil) add test for local_network_access_failure cases + } +} + +async function do_test(ip, expected, srcPort, dstPort) { + Services.fog.testResetFOG(); + + override.addIPOverride(DOMAIN, ip); + let fromAddr = mockNetwork.createScriptableNetAddr(ip, srcPort ?? 80); + let toAddr = mockNetwork.createScriptableNetAddr( + fromAddr.family == Ci.nsINetAddr.FAMILY_INET ? "127.0.0.1" : "::1", + dstPort ?? server.port() + ); + + mockNetwork.addNetAddrOverride(fromAddr, toAddr); + + info(`do_test ${ip}, ${fromAddr} -> ${toAddr}`); + + let chan = makeChan(`http://${DOMAIN}`, expected); + let [req] = await channelOpenPromise(chan); + + info( + "req.remoteAddress=" + + req.QueryInterface(Ci.nsIHttpChannelInternal).remoteAddress + ); + + if (expected.PublicToPrivateHttp) { + Assert.equal(chan.loadInfo.ipAddressSpace, Ci.nsILoadInfo.Private); + } else if (expected.PrivateToLocalHttp || expected.PrivateToLocalHttp) { + Assert.equal(chan.loadInfo.ipAddressSpace, Ci.nsILoadInfo.Local); + } + + verifyGleanValues(`test ip=${ip}`, expected); + + Services.dns.clearCache(false); + override.clearOverrides(); + mockNetwork.clearNetAddrOverrides(); + Services.obs.notifyObservers(null, "net:prune-all-connections"); +} + +add_task(async function test_lna_http() { + Services.prefs.setBoolPref("dom.security.https_only_mode", false); + Services.prefs.setBoolPref("dom.security.https_first", false); + Services.prefs.setBoolPref("dom.security.https_first_schemeless", false); + + await do_test("10.0.0.1", { PublicToPrivateHttp: 1 }); + + // NO LNA access do not increment + await do_test("10.0.0.1", { PrivateToPrivateHttp: 0 }); + await do_test("2.2.2.2", { PublicToPublicHttp: 0 }); + + await do_test("100.64.0.1", { PublicToPrivateHttp: 1 }); + await do_test("127.0.0.1", { PublicToLocalHttp: 1 }); + await do_test("127.0.0.1", { PrivateToLocalHttp: 1 }); + if (AppConstants.platform != "android") { + await do_test("::1", { PrivateToLocalHttp: 1 }); + } +}); + +add_task(async function test_lna_https() { + Services.prefs.setBoolPref("dom.security.https_only_mode", true); + let httpsServer = new NodeHTTPSServer(); + await httpsServer.start(); + registerCleanupFunction(async () => { + await httpsServer.stop(); + }); + await do_test( + "10.0.0.1", + { PublicToPrivateHttps: 1 }, + 443, + httpsServer.port() + ); + + // NO LNA access do not increment + await do_test( + "10.0.0.1", + { PrivateToPrivateHttps: 0 }, + 443, + httpsServer.port() + ); + await do_test( + "2.2.2.2", + { PublicToPublicHttps: 0 }, + 443, + httpsServer.port(), + true + ); + + await do_test( + "100.64.0.1", + { PublicToPrivateHttps: 1 }, + 443, + httpsServer.port() + ); + await do_test( + "127.0.0.1", + { PublicToLocalHttps: 1 }, + 443, + httpsServer.port() + ); + await do_test( + "127.0.0.1", + { PrivateToLocalHttps: 1 }, + 443, + httpsServer.port() + ); + if (AppConstants.platform != "android") { + await do_test("::1", { PrivateToLocalHttps: 1 }, 443, httpsServer.port()); + } +}); diff --git a/netwerk/test/unit/xpcshell.toml b/netwerk/test/unit/xpcshell.toml index 86e7ae40984c..11493c1dac8e 100644 --- a/netwerk/test/unit/xpcshell.toml +++ b/netwerk/test/unit/xpcshell.toml @@ -911,6 +911,8 @@ run-sequentially = "node server exceptions dont replay well" ["test_ip_space_glean.js"] skip-if = ["os == 'android' && android_version == '24' && processor == 'x86_64'"] +["test_ip_address_space_lna_glean.js"] + ["test_large_port.js"] ["test_loadgroup_cancel.js"]