diff --git a/injected/integration-test/test-pages/ua-ch-brands/config/brand-override.json b/injected/integration-test/test-pages/ua-ch-brands/config/brand-override.json
new file mode 100644
index 0000000000..caee7ba229
--- /dev/null
+++ b/injected/integration-test/test-pages/ua-ch-brands/config/brand-override.json
@@ -0,0 +1,11 @@
+{
+ "readme": "This config tests that uaChBrands can override navigator.userAgentData brands",
+ "version": 1,
+ "unprotectedTemporary": [],
+ "features": {
+ "uaChBrands": {
+ "state": "enabled",
+ "exceptions": []
+ }
+ }
+}
diff --git a/injected/integration-test/test-pages/ua-ch-brands/config/domain-brand-override.json b/injected/integration-test/test-pages/ua-ch-brands/config/domain-brand-override.json
new file mode 100644
index 0000000000..8209ad7e25
--- /dev/null
+++ b/injected/integration-test/test-pages/ua-ch-brands/config/domain-brand-override.json
@@ -0,0 +1,25 @@
+{
+ "readme": "This config tests domain-specific brand overrides using conditionalChanges",
+ "version": 1,
+ "unprotectedTemporary": [],
+ "features": {
+ "uaChBrands": {
+ "state": "enabled",
+ "exceptions": [],
+ "settings": {
+ "conditionalChanges": [
+ {
+ "domain": "localhost",
+ "patchSettings": [
+ {
+ "op": "add",
+ "path": "/brandName",
+ "value": "Netscape Navigator"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/injected/integration-test/test-pages/ua-ch-brands/config/override-edge-disabled.json b/injected/integration-test/test-pages/ua-ch-brands/config/override-edge-disabled.json
new file mode 100644
index 0000000000..5fb81db254
--- /dev/null
+++ b/injected/integration-test/test-pages/ua-ch-brands/config/override-edge-disabled.json
@@ -0,0 +1,14 @@
+{
+ "readme": "Test config with overrideEdge disabled but filterWebView2 enabled (default)",
+ "version": 1,
+ "unprotectedTemporary": [],
+ "features": {
+ "uaChBrands": {
+ "state": "enabled",
+ "exceptions": [],
+ "settings": {
+ "overrideEdge": "disabled"
+ }
+ }
+ }
+}
diff --git a/injected/integration-test/test-pages/ua-ch-brands/index.html b/injected/integration-test/test-pages/ua-ch-brands/index.html
new file mode 100644
index 0000000000..0353402111
--- /dev/null
+++ b/injected/integration-test/test-pages/ua-ch-brands/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ UA CH Brands
+
+
+
+ [Home]
+
+ UA CH Brands
+
+
+
diff --git a/injected/integration-test/test-pages/ua-ch-brands/pages/brand-override.html b/injected/integration-test/test-pages/ua-ch-brands/pages/brand-override.html
new file mode 100644
index 0000000000..2c8730f7c2
--- /dev/null
+++ b/injected/integration-test/test-pages/ua-ch-brands/pages/brand-override.html
@@ -0,0 +1,82 @@
+
+
+
+
+
+ UA CH Brands - Brand Override
+
+
+
+
+ [UA CH Brands]
+
+ This page verifies that navigator.userAgentData overwrites the UA CH brands to match the Sec-CH-UA header when the uaChBrands feature is enabled.
+
+
+
+
diff --git a/injected/integration-test/test-pages/ua-ch-brands/pages/domain-brand-override.html b/injected/integration-test/test-pages/ua-ch-brands/pages/domain-brand-override.html
new file mode 100644
index 0000000000..8568a293aa
--- /dev/null
+++ b/injected/integration-test/test-pages/ua-ch-brands/pages/domain-brand-override.html
@@ -0,0 +1,96 @@
+
+
+
+
+ UA-CH-Brands: Domain Brand Override Test
+
+
+ UA-CH-Brands Domain Brand Override Test
+
+
+
+
+
diff --git a/injected/integration-test/test-pages/ua-ch-brands/pages/override-edge-disabled.html b/injected/integration-test/test-pages/ua-ch-brands/pages/override-edge-disabled.html
new file mode 100644
index 0000000000..d3fa3273af
--- /dev/null
+++ b/injected/integration-test/test-pages/ua-ch-brands/pages/override-edge-disabled.html
@@ -0,0 +1,75 @@
+
+
+
+
+ UA-CH-Brands: Override Edge Disabled Test
+
+
+ UA-CH-Brands Override Edge Disabled Test
+
+
+
+
+
diff --git a/injected/integration-test/ua-ch-brands.spec.js b/injected/integration-test/ua-ch-brands.spec.js
new file mode 100644
index 0000000000..8cb2b54311
--- /dev/null
+++ b/injected/integration-test/ua-ch-brands.spec.js
@@ -0,0 +1,96 @@
+import { test, expect } from '@playwright/test';
+import { ResultsCollector } from './page-objects/results-collector.js';
+
+test('UA CH Brands override', async ({ page }, testInfo) => {
+ const collector = ResultsCollector.create(page, testInfo.project.use);
+ await collector.load(
+ '/ua-ch-brands/pages/brand-override.html',
+ './integration-test/test-pages/ua-ch-brands/config/brand-override.json',
+ );
+ const results = await collector.results();
+
+ for (const key in results) {
+ for (const result of results[key]) {
+ await test.step(`${key}: ${result.name}`, () => {
+ expect(result.result).toEqual(result.expected);
+ });
+ }
+ }
+});
+
+test('UA CH Brands domain-specific brand override', async ({ page }, testInfo) => {
+ const collector = ResultsCollector.create(page, testInfo.project.use);
+ await collector.load(
+ '/ua-ch-brands/pages/domain-brand-override.html',
+ './integration-test/test-pages/ua-ch-brands/config/domain-brand-override.json',
+ );
+
+ await page.waitForFunction(() => {
+ // @ts-expect-error - results is set by the test framework
+ return window.results && window.results.length > 0;
+ });
+
+ // @ts-expect-error - results is set by the test framework
+ const results = await page.evaluate(() => window.results);
+
+ await test.step('brands: contains Netscape Navigator', () => {
+ const netscapeTest = results.find((r) => r.test === 'brands-contains-netscape');
+ expect(netscapeTest.result).toBe('PASS');
+ });
+
+ await test.step('brands: does not contain DuckDuckGo', () => {
+ const ddgTest = results.find((r) => r.test === 'brands-no-duckduckgo');
+ expect(ddgTest.result).toBe('PASS');
+ });
+
+ await test.step('getHighEntropyValues brands: contains Netscape Navigator', () => {
+ const netscapeTest = results.find((r) => r.test === 'getHighEntropyValues-brands-contains-netscape');
+ expect(netscapeTest.result).toBe('PASS');
+ });
+
+ await test.step('getHighEntropyValues brands: does not contain DuckDuckGo', () => {
+ const ddgTest = results.find((r) => r.test === 'getHighEntropyValues-brands-no-duckduckgo');
+ expect(ddgTest.result).toBe('PASS');
+ });
+
+ await test.step('fullVersionList: contains Netscape Navigator', () => {
+ const fvlNetscapeTest = results.find((r) => r.test === 'fullVersionList-contains-netscape');
+ expect(fvlNetscapeTest.result).toBe('PASS');
+ });
+
+ await test.step('fullVersionList: does not contain DuckDuckGo', () => {
+ const fvlDdgTest = results.find((r) => r.test === 'fullVersionList-no-duckduckgo');
+ expect(fvlDdgTest.result).toBe('PASS');
+ });
+});
+
+test('UA CH Brands with overrideEdge disabled', async ({ page }, testInfo) => {
+ const collector = ResultsCollector.create(page, testInfo.project.use);
+ await collector.load(
+ '/ua-ch-brands/pages/override-edge-disabled.html',
+ './integration-test/test-pages/ua-ch-brands/config/override-edge-disabled.json',
+ );
+
+ await page.waitForFunction(() => {
+ // @ts-expect-error - results is set by the test framework
+ return window.results && window.results.length > 0;
+ });
+
+ // @ts-expect-error - results is set by the test framework
+ const results = await page.evaluate(() => window.results);
+
+ await test.step('brands: does not contain DuckDuckGo', () => {
+ const ddgTest = results.find((r) => r.test === 'brands-no-duckduckgo');
+ expect(ddgTest.result).toBe('PASS');
+ });
+
+ await test.step('getHighEntropyValues brands: does not contain DuckDuckGo', () => {
+ const ddgTest = results.find((r) => r.test === 'getHighEntropyValues-brands-no-duckduckgo');
+ expect(ddgTest.result).toBe('PASS');
+ });
+
+ await test.step('fullVersionList: does not contain DuckDuckGo', () => {
+ const fvlDdgTest = results.find((r) => r.test === 'fullVersionList-no-duckduckgo');
+ expect(fvlDdgTest.result).toBe('PASS');
+ });
+});
diff --git a/injected/playwright.config.js b/injected/playwright.config.js
index 5b059b7576..df43c48817 100644
--- a/injected/playwright.config.js
+++ b/injected/playwright.config.js
@@ -10,6 +10,7 @@ export default defineConfig({
'integration-test/duckplayer-remote-config.spec.js',
'integration-test/harmful-apis.spec.js',
'integration-test/windows-permissions.spec.js',
+ 'integration-test/ua-ch-brands.spec.js',
'integration-test/broker-protection-tests/**/*.spec.js',
'integration-test/breakage-reporting.spec.js',
'integration-test/duck-ai-data-clearing.spec.js',
diff --git a/injected/src/features.js b/injected/src/features.js
index 3d0995ad7d..9075035dbc 100644
--- a/injected/src/features.js
+++ b/injected/src/features.js
@@ -26,6 +26,7 @@ const otherFeatures = /** @type {const} */ ([
'webCompat',
'webInterferenceDetection',
'windowsPermissionUsage',
+ 'uaChBrands',
'brokerProtection',
'performanceMetrics',
'breakageReporting',
@@ -69,6 +70,7 @@ export const platformSupport = {
'webInterferenceDetection',
'webTelemetry',
'windowsPermissionUsage',
+ 'uaChBrands',
'duckPlayer',
'brokerProtection',
'breakageReporting',
diff --git a/injected/src/features/ua-ch-brands.js b/injected/src/features/ua-ch-brands.js
new file mode 100644
index 0000000000..65c5e80a34
--- /dev/null
+++ b/injected/src/features/ua-ch-brands.js
@@ -0,0 +1,154 @@
+import ContentFeature from '../content-feature';
+import { DDGReflect } from '../utils';
+
+export default class UaChBrands extends ContentFeature {
+ constructor(featureName, importConfig, args) {
+ super(featureName, importConfig, args);
+
+ this.originalBrands = null;
+ }
+
+ init() {
+ const shouldFilterWebView2 = this.getFeatureSettingEnabled('filterWebView2', 'enabled');
+ const shouldOverrideEdge = this.getFeatureSettingEnabled('overrideEdge', 'enabled');
+
+ if (!shouldFilterWebView2 && !shouldOverrideEdge) {
+ this.log.info('Both filterWebView2 and overrideEdge disabled, skipping UA-CH-Brands modifications');
+ return;
+ }
+
+ this.shimUserAgentDataBrands(shouldFilterWebView2, shouldOverrideEdge);
+ }
+
+ /**
+ * Get the override target brand from domain settings or default to DuckDuckGo
+ * @returns {string|null} - Brand name to use for replacement/append (null to skip override)
+ */
+ getBrandOverride() {
+ const brandName = this.getFeatureSetting('brandName') || 'DuckDuckGo';
+ if (brandName !== 'DuckDuckGo') {
+ this.log.info(`Using brand override: "${brandName}"`);
+ }
+ return brandName;
+ }
+
+ /**
+ * Override navigator.userAgentData.brands to match the Sec-CH-UA header
+ * @param {boolean} shouldFilterWebView2 - Whether to filter WebView2
+ * @param {boolean} shouldOverrideEdge - Whether to append/replace with target brand
+ */
+ shimUserAgentDataBrands(shouldFilterWebView2, shouldOverrideEdge) {
+ try {
+ // @ts-expect-error - userAgentData not yet standard
+ if (!navigator.userAgentData || !navigator.userAgentData.brands) {
+ this.log.info('shimUserAgentDataBrands - navigator.userAgentData not available');
+ return;
+ }
+
+ // @ts-expect-error - userAgentData not yet standard
+ this.originalBrands = [...navigator.userAgentData.brands];
+ this.log.info(
+ 'shimUserAgentDataBrands - captured original brands:',
+ this.originalBrands.map((b) => `"${b.brand}" v${b.version}`).join(', '),
+ );
+
+ const targetBrand = shouldOverrideEdge ? this.getBrandOverride() : null;
+ const mutatedBrands = this.applyBrandMutationsToList(this.originalBrands, targetBrand, shouldFilterWebView2);
+
+ if (mutatedBrands.length) {
+ this.log.info(
+ 'shimUserAgentDataBrands - about to apply override with:',
+ mutatedBrands.map((b) => `"${b.brand}" v${b.version}`).join(', '),
+ );
+ this.applyBrandsOverride(mutatedBrands, shouldOverrideEdge, shouldFilterWebView2);
+ this.log.info('shimUserAgentDataBrands - override applied successfully');
+ }
+ } catch (error) {
+ this.log.error('Error in shimUserAgentDataBrands:', error);
+ }
+ }
+
+ /**
+ * Filter out unwanted brands and append/replace with target brand to match Sec-CH-UA header
+ * @param {Array<{brand: string, version: string}>} list - Original brands list
+ * @param {string|null} targetBrand - Brand to use for replacement/append (null to skip override)
+ * @param {boolean} [shouldFilterWebView2=true] - Whether to filter WebView2
+ * @returns {Array<{brand: string, version: string}>} - Modified brands array
+ */
+ applyBrandMutationsToList(list, targetBrand, shouldFilterWebView2 = true) {
+ if (!Array.isArray(list) || !list.length) {
+ this.log.info('applyBrandMutationsToList - no brands to mutate');
+ return [];
+ }
+
+ let mutated = [...list];
+
+ if (shouldFilterWebView2) {
+ mutated = mutated.filter((b) => b.brand !== 'Microsoft Edge WebView2');
+ if (mutated.length < list.length) {
+ this.log.info('Removed "Microsoft Edge WebView2" brand');
+ }
+ }
+
+ if (targetBrand !== null) {
+ const edgeIndex = mutated.findIndex((b) => b.brand === 'Microsoft Edge');
+ if (edgeIndex !== -1) {
+ const edgeVersion = mutated[edgeIndex].version;
+ mutated[edgeIndex] = { brand: targetBrand, version: edgeVersion };
+ this.log.info(`Replaced "Microsoft Edge" v${edgeVersion} with "${targetBrand}" v${edgeVersion}`);
+ } else {
+ const chromium = mutated.find((b) => b.brand === 'Chromium');
+ if (chromium) {
+ mutated.push({ brand: targetBrand, version: chromium.version });
+ this.log.info(`Appended "${targetBrand}" v${chromium.version} (to match Chromium version)`);
+ }
+ }
+ }
+
+ const brandNames = mutated.map((b) => `"${b.brand}" v${b.version}`).join(', ');
+ this.log.info(`Final brands: [${brandNames}]`);
+ return mutated;
+ }
+
+ /**
+ * Apply the brand override to navigator.userAgentData
+ * @param {Array<{brand: string, version: string}>} newBrands - Brands to apply
+ * @param {boolean} shouldOverrideEdge - Whether to replace/append brand
+ * @param {boolean} shouldFilterWebView2 - Whether to filter WebView2
+ */
+ applyBrandsOverride(newBrands, shouldOverrideEdge, shouldFilterWebView2) {
+ // @ts-expect-error - userAgentData not yet standard
+ const proto = Object.getPrototypeOf(navigator.userAgentData);
+
+ this.wrapProperty(proto, 'brands', {
+ get: () => newBrands,
+ });
+
+ if (proto.getHighEntropyValues) {
+ // Need to capture feature instance in closure to access applyBrandMutationsToList
+ // while preserving dynamic `this` (userAgentData) for DDGReflect.apply.
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const featureInstance = this;
+ this.wrapMethod(proto, 'getHighEntropyValues', async function (originalFn, ...args) {
+ const originalResult = await DDGReflect.apply(originalFn, this, args);
+ const modifiedResult = {};
+
+ for (const [key, value] of Object.entries(originalResult)) {
+ let result = value;
+
+ if (key === 'brands' && args[0]?.includes('brands')) {
+ result = newBrands;
+ }
+ if (key === 'fullVersionList' && args[0]?.includes('fullVersionList') && value) {
+ const targetBrand = shouldOverrideEdge ? featureInstance.getBrandOverride() : null;
+ result = featureInstance.applyBrandMutationsToList(value, targetBrand, shouldFilterWebView2);
+ }
+
+ modifiedResult[key] = result;
+ }
+
+ return modifiedResult;
+ });
+ }
+ }
+}