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; + }); + } + } +}