tor-browser

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

commit 89a700447e587357398998a6d5c4c61b800f8f2b
parent 855240736e9cbcc354020571f1ad1638b896fad7
Author: Nathan Barrett <nbarrett@mozilla.com>
Date:   Thu,  4 Dec 2025 23:06:37 +0000

Bug 2003937 - Implement MASQUE proxy for newtab images r=mconley,home-newtab-reviewers

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

Diffstat:
Mbrowser/extensions/newtab/lib/ActivityStream.sys.mjs | 152++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbrowser/extensions/newtab/test/unit/lib/ActivityStream.test.js | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/test/unit/unit-entry.js | 5+++++
3 files changed, 371 insertions(+), 1 deletion(-)

diff --git a/browser/extensions/newtab/lib/ActivityStream.sys.mjs b/browser/extensions/newtab/lib/ActivityStream.sys.mjs @@ -13,9 +13,15 @@ const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { + AboutNewTabParent: "resource:///actors/AboutNewTabParent.sys.mjs", AboutPreferences: "resource://newtab/lib/AboutPreferences.sys.mjs", AdsFeed: "resource://newtab/lib/AdsFeed.sys.mjs", InferredPersonalizationFeed: @@ -49,6 +55,13 @@ ChromeUtils.defineESModuleGetters(lazy, { WeatherFeed: "resource://newtab/lib/WeatherFeed.sys.mjs", }); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + Ci.nsIProtocolProxyService +); + // NB: Eagerly load modules that will be loaded/constructed/initialized in the // common case to avoid the overhead of wrapping and detecting lazy loading. import { @@ -100,6 +113,14 @@ const LOCALE_SECTIONS_CONFIG = const PREF_SHOULD_AS_INITIALIZE_FEEDS = "browser.newtabpage.activity-stream.testing.shouldInitializeFeeds"; +const PREF_INFERRED_ENABLED = + "discoverystream.sections.personalization.inferred.enabled"; + +const PREF_IMAGE_PROXY_ENABLED = + "browser.newtabpage.activity-stream.discoverystream.imageProxy.enabled"; + +const PREF_IMAGE_PROXY_ENABLED_STORE = "discoverystream.imageProxy.enabled"; + export const WEATHER_OPTIN_REGIONS = [ "AT", // Austria "BE", // Belgium @@ -917,6 +938,13 @@ export const PREFS_CONFIG = new Map([ }, ], [ + "discoverystream.imageProxy.enabled", + { + title: "Boolean flag to enable image proxying for images on newtab", + value: true, + }, + ], + [ "newtabWallpapers.highlightEnabled", { title: "Boolean flag to show the highlight about the Wallpaper feature", @@ -1618,6 +1646,7 @@ export class ActivityStream { this.initialized = false; this.store = new lazy.Store(); this._defaultPrefs = new lazy.DefaultPrefs(PREFS_CONFIG); + this._proxyRegistered = false; } get feeds() { @@ -1639,6 +1668,7 @@ export class ActivityStream { this._updateDynamicPrefs(); this._defaultPrefs.init(); Services.obs.addObserver(this, "intl:app-locales-changed"); + Services.prefs.addObserver(PREF_IMAGE_PROXY_ENABLED, this); lazy.NewTabActorRegistry.init(); // Hook up the store and let all feeds and pages initialize @@ -1656,10 +1686,115 @@ export class ActivityStream { { type: at.UNINIT } ); + this.registerNetworkProxy(); this.initialized = true; } /** + * Registers network proxy channel filter for image requests. + * This enables privacy-preserving image proxy for newtab when + * inferred personalization is enabled. + */ + registerNetworkProxy() { + const enabled = Services.prefs.getBoolPref(PREF_IMAGE_PROXY_ENABLED, false); + if (!this._proxyRegistered && enabled) { + lazy.ProxyService.registerChannelFilter(this, 0); + this._proxyRegistered = true; + } + } + + /** + * Unregisters network proxy channel filter. + */ + unregisterNetworkProxy() { + if (this._proxyRegistered) { + lazy.ProxyService.unregisterChannelFilter(this); + this._proxyRegistered = false; + } + } + + /** + * Retrieves and validates image proxy configuration from prefs/nimbus. + * + * @returns {object|null} Image proxy config object, or null if disabled/invalid. + */ + getImageProxyConfig() { + const { values } = this.store.getState().Prefs; + + const config = values?.trainhopConfig?.imageProxy; + if ( + !config || + !config.enabled || + !config.proxyHost || + !config.proxyPort || + !config.masqueTemplate || + !config.proxyAuthHeader || + !values?.[PREF_INFERRED_ENABLED] || + !values?.[PREF_IMAGE_PROXY_ENABLED_STORE] + ) { + return null; + } + return { + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyAuthHeader: config.proxyAuthHeader, + masqueTemplate: config.masqueTemplate, + connectionIsolationKey: config.connectionIsolationKey || "", + failoverProxy: config.failoverProxy, + imageProxyHosts: (config.imageProxyHosts || "") + .split(",") + .map(host => host.trim()), + }; + } + + /** + * nsIProtocolProxyChannelFilter implementation. Applies MASQUE proxy + * to image requests from newtab when configured. + * + * @param {nsIChannel} channel + * @param {nsIProxyInfo} proxyInfo + * @param {nsIProtocolProxyChannelFilter} callback + */ + applyFilter(channel, proxyInfo, callback) { + const { browsingContext } = channel.loadInfo; + let browser = browsingContext?.top.embedderElement; + + if (!browser || !lazy.AboutNewTabParent.loadedTabs.has(browser)) { + callback.onProxyFilterResult(proxyInfo); + return; + } + + const config = this.getImageProxyConfig(); + + if (!config) { + callback.onProxyFilterResult(proxyInfo); + return; + } + + if ( + config.imageProxyHosts.includes(channel.URI.host) && + channel.URI.scheme === "https" + ) { + callback.onProxyFilterResult( + lazy.ProxyService.newMASQUEProxyInfo( + config.proxyHost /* aHost */, + config.proxyPort /* aPort */, + config.masqueTemplate /* uMasqueTemplate */, + config.proxyAuthHeader /* aProxyAuthorizationHeader */, + config.connectionIsolationKey /* aConnectionIsolationKey */, + 0 /* aFlags */, + 5000 /* aFailoverTimeout */, + config.failoverProxy /* failover proxy */ + ) + ); + } else { + callback.onProxyFilterResult(proxyInfo); + } + } + + QueryInterface = ChromeUtils.generateQI([Ci.nsIProtocolProxyChannelFilter]); + + /** * Check if an old pref has a custom value to migrate. Clears the pref so that * it's the default after migrating (to avoid future need to migrate). * @@ -1698,8 +1833,10 @@ export class ActivityStream { delete this.geo; Services.obs.removeObserver(this, "intl:app-locales-changed"); + Services.prefs.removeObserver(PREF_IMAGE_PROXY_ENABLED, this); this.store.uninit(); + this.unregisterNetworkProxy(); this.initialized = false; } @@ -1755,12 +1892,25 @@ export class ActivityStream { } } - observe(subject, topic) { + observe(subject, topic, data) { switch (topic) { case "intl:app-locales-changed": case lazy.Region.REGION_TOPIC: this._updateDynamicPrefs(); break; + case "nsPref:changed": + if (data === PREF_IMAGE_PROXY_ENABLED) { + const enabled = Services.prefs.getBoolPref( + PREF_IMAGE_PROXY_ENABLED, + false + ); + if (enabled) { + this.registerNetworkProxy(); + } else { + this.unregisterNetworkProxy(); + } + } + break; } } } diff --git a/browser/extensions/newtab/test/unit/lib/ActivityStream.test.js b/browser/extensions/newtab/test/unit/lib/ActivityStream.test.js @@ -875,4 +875,219 @@ describe("ActivityStream", () => { } }); }); + + describe("proxying images", () => { + let registerStub; + let unregisterStub; + + beforeEach(() => { + registerStub = sandbox.stub(as, "registerNetworkProxy"); + unregisterStub = sandbox.stub(as, "unregisterNetworkProxy"); + }); + + describe("#init", () => { + it("should call registerNetworkProxy during init", () => { + as.init(); + assert.calledOnce(registerStub); + }); + }); + + describe("#uninit", () => { + it("should call unregisterNetworkProxy during uninit", () => { + as.init(); + as.uninit(); + assert.calledOnce(unregisterStub); + }); + }); + + describe("#getImageProxyConfig", () => { + it("should return null when proxy config is missing", () => { + as.store = { + getState: () => ({ + Prefs: { + values: {}, + }, + }), + }; + + const config = as.getImageProxyConfig(); + assert.isNull(config); + }); + + it("should return null when proxy is disabled", () => { + as.store = { + getState: () => ({ + Prefs: { + values: { + trainhopConfig: { + imageProxy: { + enabled: false, + proxyHost: "proxy.example.com", + proxyPort: 443, + masqueTemplate: "template", + proxyAuthHeader: "auth", + }, + }, + "discoverystream.sections.personalization.inferred.enabled": true, + }, + }, + }), + }; + + const config = as.getImageProxyConfig(); + assert.isNull(config); + }); + + it("should return null when required fields are missing", () => { + as.store = { + getState: () => ({ + Prefs: { + values: { + trainhopConfig: { + imageProxy: { + enabled: true, + proxyHost: "proxy.example.com", + }, + }, + "discoverystream.sections.personalization.inferred.enabled": true, + }, + }, + }), + }; + + const config = as.getImageProxyConfig(); + assert.isNull(config); + }); + + it("should return null when inferred personalization is disabled", () => { + as.store = { + getState: () => ({ + Prefs: { + values: { + trainhopConfig: { + imageProxy: { + enabled: true, + proxyHost: "proxy.example.com", + proxyPort: 443, + masqueTemplate: "template", + proxyAuthHeader: "auth", + }, + }, + "discoverystream.sections.personalization.inferred.enabled": false, + "discoverystream.imageProxy.enabled": true, + }, + }, + }), + }; + + const config = as.getImageProxyConfig(); + assert.isNull(config); + }); + + it("should return valid config when properly configured", () => { + as.store = { + getState: () => ({ + Prefs: { + values: { + trainhopConfig: { + imageProxy: { + enabled: true, + proxyHost: "host", + proxyPort: 1124, + masqueTemplate: "template", + proxyAuthHeader: "123", + connectionIsolationKey: "isolation-key", + failoverProxy: "failover.example.com", + imageProxyHosts: "host1.com,host2.com,host3.com", + }, + }, + "discoverystream.sections.personalization.inferred.enabled": true, + "discoverystream.imageProxy.enabled": true, + }, + }, + }), + }; + + const config = as.getImageProxyConfig(); + assert.ok(config); + }); + }); + + describe("#applyFilter", () => { + let mockChannel; + let mockCallback; + let mockProxyInfo; + let mockBrowser; + let mockMASQUEProxyInfo; + let getConfigStub; + let AboutNewTabParent; + + beforeEach(() => { + mockCallback = { onProxyFilterResult: sandbox.stub() }; + mockProxyInfo = {}; + mockMASQUEProxyInfo = {}; + mockBrowser = {}; + + mockChannel = { + URI: { host: "example.com", scheme: "https" }, + loadInfo: { + browsingContext: { top: { embedderElement: mockBrowser } }, + }, + }; + + getConfigStub = sandbox.stub(as, "getImageProxyConfig"); + + AboutNewTabParent = { loadedTabs: new Set([mockBrowser]) }; + globals.set({ + AboutNewTabParent, + ProxyService: { + newMASQUEProxyInfo: sandbox.stub().returns(mockMASQUEProxyInfo), + }, + }); + }); + + it("should pass through original proxy when config is null", () => { + getConfigStub.returns(null); + + as.applyFilter(mockChannel, mockProxyInfo, mockCallback); + + assert.calledOnce(mockCallback.onProxyFilterResult); + assert.calledWith(mockCallback.onProxyFilterResult, mockProxyInfo); + }); + + it("should apply proxy for matching HTTPS host from newtab", () => { + const config = { + imageProxyHosts: ["example.com", "other.com"], + proxyHost: "proxy.example.com", + proxyPort: 443, + masqueTemplate: "template", + proxyAuthHeader: "Bearer token", + connectionIsolationKey: "key", + failoverProxy: "failover.example.com", + }; + getConfigStub.returns(config); + + as.applyFilter(mockChannel, mockProxyInfo, mockCallback); + + assert.calledOnce(global.ProxyService.newMASQUEProxyInfo); + assert.calledWith( + global.ProxyService.newMASQUEProxyInfo, + config.proxyHost, + config.proxyPort, + config.masqueTemplate, + config.proxyAuthHeader, + config.connectionIsolationKey, + 0, + 5000, + config.failoverProxy + ); + + assert.calledOnce(mockCallback.onProxyFilterResult); + assert.calledWith( + mockCallback.onProxyFilterResult, + mockMASQUEProxyInfo + ); + }); + }); + }); }); diff --git a/browser/extensions/newtab/test/unit/unit-entry.js b/browser/extensions/newtab/test/unit/unit-entry.js @@ -250,6 +250,7 @@ const TEST_GLOBAL = { MODE_REJECT_OR_ACCEPT: 2, MODE_UNSET: 3, }, + nsIProtocolProxyChannelFilter: {}, }, Cu: { importGlobalProperties() {}, @@ -707,6 +708,10 @@ const TEST_GLOBAL = { SERVER_URL: "bogus://foo", }, NewTabContentPing, + ProxyService: { + registerChannelFilter() {}, + unregisterChannelFilter() {}, + }, }; overrider.set(TEST_GLOBAL);