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..f7322f6d2f --- /dev/null +++ b/injected/integration-test/test-pages/ua-ch-brands/config/brand-override.json @@ -0,0 +1,23 @@ +{ + "readme": "This config tests that uaChBrands can override navigator.userAgentData.brands", + "version": 1, + "unprotectedTemporary": [], + "features": { + "uaChBrands": { + "state": "enabled", + "exceptions": [], + "settings": { + "brands": [ + { + "brand": "Chromium", + "version": "140" + }, + { + "brand": "DuckDuckGo", + "version": "140" + } + ] + } + } + } +} diff --git a/injected/integration-test/test-pages/ua-ch-brands/config/brands-missing.json b/injected/integration-test/test-pages/ua-ch-brands/config/brands-missing.json new file mode 100644 index 0000000000..f62047bc68 --- /dev/null +++ b/injected/integration-test/test-pages/ua-ch-brands/config/brands-missing.json @@ -0,0 +1,13 @@ +{ + "readme": "This config tests that uaChBrands feature returns early when brands setting is missing (feature is enabled but settings.brands is undefined)", + "version": 1, + "unprotectedTemporary": [], + "features": { + "uaChBrands": { + "state": "enabled", + "exceptions": [], + "settings": { + } + } + } +} 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..3b1c82c1b2 --- /dev/null +++ b/injected/integration-test/test-pages/ua-ch-brands/index.html @@ -0,0 +1,18 @@ + + +
+ + +UA CH Brands
+This page verifies that navigator.userAgentData.brands uses the configured brands from the uaChBrands feature settings
+ + + + diff --git a/injected/integration-test/test-pages/ua-ch-brands/pages/brands-missing.html b/injected/integration-test/test-pages/ua-ch-brands/pages/brands-missing.html new file mode 100644 index 0000000000..1b74ca07bb --- /dev/null +++ b/injected/integration-test/test-pages/ua-ch-brands/pages/brands-missing.html @@ -0,0 +1,69 @@ + + + + + +This page verifies that when brands setting is missing/null/empty, the feature returns early and original brands are preserved
+ + + + 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..37260bff09 --- /dev/null +++ b/injected/integration-test/ua-ch-brands.spec.js @@ -0,0 +1,36 @@ +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 when brands missing', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load( + '/ua-ch-brands/pages/brands-missing.html', + './integration-test/test-pages/ua-ch-brands/config/brands-missing.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); + }); + } + } +}); 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 929612d768..a8f7e0f556 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -26,6 +26,7 @@ const otherFeatures = /** @type {const} */ ([ 'harmfulApis', 'webCompat', 'windowsPermissionUsage', + 'uaChBrands', 'brokerProtection', 'performanceMetrics', 'breakageReporting', @@ -68,6 +69,7 @@ export const platformSupport = { ...baseFeatures, '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..b2805ee0f5 --- /dev/null +++ b/injected/src/features/ua-ch-brands.js @@ -0,0 +1,123 @@ +import ContentFeature from '../content-feature'; + +export default class UaChBrands extends ContentFeature { + constructor(featureName, importConfig, args) { + super(featureName, importConfig, args); + + this.cachedBrands = null; + this.originalBrands = null; + } + + init() { + const configuredBrands = this.getFeatureSetting('brands'); + + if (!configuredBrands || configuredBrands.length === 0) { + this.log.info('No client hint brands correctly configured, feature disabled'); + return; + } + this.shimUserAgentDataBrands(); + } + + /** + * Override navigator.userAgentData.brands to match the Sec-CH-UA header + */ + shimUserAgentDataBrands() { + try { + // @ts-expect-error - userAgentData not yet standard + if (!navigator.userAgentData || !navigator.userAgentData.brands) { + return; + } + + if (!this.originalBrands) { + // @ts-expect-error - userAgentData not yet standard + this.originalBrands = [...navigator.userAgentData.brands]; + } + + if (this.cachedBrands) { + this.applyBrandsOverride(this.cachedBrands); + return; + } + + const mutatedBrands = this.applyBrandMutations(); + + if (mutatedBrands) { + this.cachedBrands = mutatedBrands; + this.applyBrandsOverride(mutatedBrands); + } + } catch (error) { + this.log.error('Error in shimUserAgentDataBrands:', error); + } + } + + /** + * Find the GREASE brand value from original brands + * @returns {{brand: string, version: string}|null} - GREASE brand or null if not found + */ + findGreaseBrand() { + if (!this.originalBrands || this.originalBrands.length === 0) { + return null; + } + + return this.originalBrands.find((brand) => { + const name = brand.brand; + // Check if it starts with "Not" or " Not" or contains special chars + return name.trim().startsWith('Not') || /[^\w\s.]/.test(name); + }); + } + + /** + * Apply brand mutations using the configured brands from feature settings + * Preserve the GREASE value from original brands at its original position + * @returns {Array<{brand: string, version: string}>|null} - Configured brands or null if no changes + */ + applyBrandMutations() { + const configuredBrands = this.getFeatureSetting('brands'); + + if (!configuredBrands || configuredBrands.length === 0) { + this.log.info('No CH brands configured, skipping mutations'); + return null; + } + + // Find GREASE value in original brands and preserve it + const greaseBrand = this.findGreaseBrand(); + const greaseIndex = greaseBrand && this.originalBrands ? this.originalBrands.findIndex((b) => b.brand === greaseBrand.brand) : -1; + + if (greaseBrand && greaseIndex !== -1) { + const result = [...configuredBrands]; + // Insert GREASE at its original position or end if out of bounds + const insertAt = Math.min(greaseIndex, result.length); + result.splice(insertAt, 0, greaseBrand); + const brandNames = result.map((b) => `"${b.brand}" v${b.version}`).join(', '); + this.log.info(`Applying configured brands with GREASE at index ${insertAt}: [${brandNames}]`); + return result; + } + + const brandNames = configuredBrands.map((b) => `"${b.brand}" v${b.version}`).join(', '); + this.log.info(`Applying configured brands (no GREASE found): [${brandNames}]`); + return configuredBrands; + } + + /** + * Apply the brand override to navigator.userAgentData.brands + * @param {Array<{brand: string, version: string}>} newBrands - Brands to apply + */ + applyBrandsOverride(newBrands) { + // @ts-expect-error - userAgentData not yet standard + const proto = Object.getPrototypeOf(navigator.userAgentData); + + this.wrapProperty(proto, 'brands', { + get: () => newBrands, + }); + + if (proto.getHighEntropyValues) { + this.wrapMethod(proto, 'getHighEntropyValues', async (originalFn, ...args) => { + // @ts-expect-error - userAgentData not yet standard + const result = await originalFn.apply(navigator.userAgentData, args); + if (args[0] && args[0].includes('brands')) { + result.brands = newBrands; + } + return result; + }); + } + } +}