Skip to content
Draft
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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": {
}
}
}
}
18 changes: 18 additions & 0 deletions injected/integration-test/test-pages/ua-ch-brands/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>UA CH Brands</title>
<link rel="stylesheet" href="../shared/style.css">
</head>
<body>
<p><a href="../index.html">[Home]</a></p>

<p>UA CH Brands</p>
<ul>
<li><a href="./pages/brand-override.html">Brand Override</a> - <a href="./config/brand-override.json">Config</a></li>
<li><a href="./pages/brands-missing.html">Brands Missing</a> - <a href="./config/brands-missing.json">Config</a></li>
</ul>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>UA CH Brands - Brand Override</title>
<link rel="stylesheet" href="../../shared/style.css">
</head>
<body>
<script src="../../shared/utils.js"></script>
<p><a href="../index.html">[UA CH Brands]</a></p>

<p>This page verifies that navigator.userAgentData.brands uses the configured brands from the uaChBrands feature settings</p>

<script>
test('Brand override', async () => {
const results = [];

if (navigator.userAgentData && navigator.userAgentData.brands) {
const brands = navigator.userAgentData.brands;
const brandNames = new Set(brands.map(b => b.brand));

results.push({
name: 'contains Chromium brand',
result: brandNames.has('Chromium'),
expected: true
});

results.push({
name: 'contains DuckDuckGo brand',
result: brandNames.has('DuckDuckGo'),
expected: true
});

results.push({
name: 'has correct number of brands',
result: brands.length,
expected: 2
});

results.push({
name: 'does not contain Microsoft Edge brand',
result: !brandNames.has('Microsoft Edge'),
expected: true
});

results.push({
name: 'does not contain Microsoft Edge WebView2 brand',
result: !brandNames.has('Microsoft Edge WebView2'),
expected: true
});

if (navigator.userAgentData.getHighEntropyValues) {
try {
const highEntropyValues = await navigator.userAgentData.getHighEntropyValues(['brands']);
if (highEntropyValues.brands) {
const heBrandNames = new Set(highEntropyValues.brands.map(b => b.brand));
results.push({
name: 'high entropy contains Chromium',
result: heBrandNames.has('Chromium'),
expected: true
});
results.push({
name: 'high entropy contains DuckDuckGo',
result: heBrandNames.has('DuckDuckGo'),
expected: true
});

results.push({
name: 'high entropy does not contain Microsoft Edge',
result: !heBrandNames.has('Microsoft Edge'),
expected: true
});

results.push({
name: 'high entropy does not contain Microsoft Edge WebView2',
result: !heBrandNames.has('Microsoft Edge WebView2'),
expected: true
});
}
} catch (error) {
results.push({
name: 'getHighEntropyValues works',
result: false,
expected: true
});
}
}
} else {
results.push({
name: 'navigator.userAgentData.brands available',
result: false,
expected: true
});
}

return results;
});

renderResults();
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>UA CH Brands - Brands Missing</title>
<link rel="stylesheet" href="../../shared/style.css">
</head>
<body>
<script src="../../shared/utils.js"></script>
<p><a href="../index.html">[UA CH Brands]</a></p>

<p>This page verifies that when brands setting is missing/null/empty, the feature returns early and original brands are preserved</p>

<script>
test('Feature returns early when brands missing', async () => {
const results = [];

if (navigator.userAgentData && navigator.userAgentData.brands) {
// When brands setting is missing, the feature returns early in init()

const brands = navigator.userAgentData.brands;

// Verify brands still works normally
results.push({
name: 'brands array exists and has content',
result: Array.isArray(brands) && brands.length > 0,
expected: true
});

// Verify getHighEntropyValues also works and returns matching values
if (navigator.userAgentData.getHighEntropyValues) {
try {
const highEntropyValues = await navigator.userAgentData.getHighEntropyValues(['brands']);
const heBrands = highEntropyValues.brands;

if (heBrands) {
// Both should return the same unwrapped values
const brandsStr = JSON.stringify(brands);
const heBrandsStr = JSON.stringify(heBrands);

results.push({
name: 'getHighEntropyValues and direct access return matching values',
result: brandsStr === heBrandsStr,
expected: true
});
}
} catch (error) {
results.push({
name: 'getHighEntropyValues works',
result: false,
expected: true
});
}
}
} else {
results.push({
name: 'navigator.userAgentData.brands available',
result: false,
expected: true
});
}

return results;
});

renderResults();
</script>
</body>
</html>
36 changes: 36 additions & 0 deletions injected/integration-test/ua-ch-brands.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
});
1 change: 1 addition & 0 deletions injected/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions injected/src/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const otherFeatures = /** @type {const} */ ([
'harmfulApis',
'webCompat',
'windowsPermissionUsage',
'uaChBrands',
'brokerProtection',
'performanceMetrics',
'breakageReporting',
Expand Down Expand Up @@ -68,6 +69,7 @@ export const platformSupport = {
...baseFeatures,
'webTelemetry',
'windowsPermissionUsage',
'uaChBrands',
'duckPlayer',
'brokerProtection',
'breakageReporting',
Expand Down
91 changes: 91 additions & 0 deletions injected/src/features/ua-ch-brands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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);
}
}

/**
* Apply brand mutations using the configured brands from feature settings
* @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;
}

this.log.info('Applying configured brands:', configuredBrands);
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;
});
}
}
}