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:
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);