Skip to content

Commit 07d9f81

Browse files
Add API manipulation feature (#1207)
* Change value modification to use get instead * Rename keys based on tech design discussion * Add docs, change defaults of enumerable and configurable * Add typing info * Add simple test cases and for APIs we care about * Change default to rely on wrapProperty code defaults * Change to not passing props if not defined * Add removal task notice * Remove integration config example * Add hasOwnProperty check to remove * Move to using captured global
1 parent b46d850 commit 07d9f81

File tree

10 files changed

+360
-2
lines changed

10 files changed

+360
-2
lines changed

injected/integration-test/pages.spec.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ test.describe('Test integration pages', () => {
6262
}
6363
}
6464

65+
test('Test manipulating APIs', async ({ page }) => {
66+
await testPage(
67+
page,
68+
'api-manipulation/pages/apis.html',
69+
`${process.cwd()}/integration-test/test-pages/api-manipulation/config/apis.json`,
70+
);
71+
});
72+
6573
test('Web compat shims correctness', async ({ page }) => {
6674
await testPage(page, 'webcompat/pages/shims.html', `${process.cwd()}/integration-test/test-pages/webcompat/config/shims.json`);
6775
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
{
2+
"features": {
3+
"apiManipulation": {
4+
"state": "enabled",
5+
"settings": {
6+
"apiChanges": {
7+
"Navigator.prototype.hardwareConcurrency": {
8+
"type": "descriptor",
9+
"getterValue": {
10+
"type": "number",
11+
"value": 222
12+
}
13+
},
14+
"Navigator.prototype.userAgent": {
15+
"type": "remove"
16+
},
17+
"Navigator.prototype.thisDoesNotExist": {
18+
"type": "remove"
19+
},
20+
"Navigator.prototype.newAPI": {
21+
"type": "descriptor",
22+
"getterValue": {
23+
"type": "number",
24+
"value": 222
25+
}
26+
},
27+
"window.name": {
28+
"type": "descriptor",
29+
"getterValue": {
30+
"type": "string",
31+
"value": "newName"
32+
}
33+
},
34+
"Navigator.prototype.joinAdInterestGroup": {
35+
"type": "remove"
36+
},
37+
"Navigator.prototype.leaveAdInterestGroup": {
38+
"type": "remove"
39+
},
40+
"Navigator.prototype.clearOriginJoinedAdInterestGroups": {
41+
"type": "remove"
42+
},
43+
"Navigator.prototype.updateAdInterestGroups": {
44+
"type": "remove"
45+
},
46+
"Navigator.prototype.createAuctionNonce": {
47+
"type": "remove"
48+
},
49+
"Navigator.prototype.runAdAuction": {
50+
"type": "remove"
51+
},
52+
"Navigator.prototype.adAuctionComponents": {
53+
"type": "remove"
54+
},
55+
"Navigator.prototype.deprecatedURNToURL": {
56+
"type": "remove"
57+
},
58+
"Navigator.prototype.deprecatedReplaceInURN": {
59+
"type": "remove"
60+
},
61+
"Navigator.prototype.getInterestGroupAdAuctionData": {
62+
"type": "remove"
63+
},
64+
"Navigator.prototype.createAdRequest": {
65+
"type": "remove"
66+
},
67+
"Navigator.prototype.finalizeAd": {
68+
"type": "remove"
69+
},
70+
"Navigator.prototype.canLoadAdAuctionFencedFrame": {
71+
"type": "remove"
72+
},
73+
"Navigator.prototype.deprecatedRunAdAuctionEnforcesKAnonymity": {
74+
"type": "remove"
75+
},
76+
"Navigator.prototype.protectedAudience": {
77+
"type": "descriptor",
78+
"getterValue": {
79+
"type": "undefined"
80+
}
81+
}
82+
}
83+
}
84+
}
85+
}
86+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width">
6+
<title>API Interventions</title>
7+
<link rel="stylesheet" href="../shared/style.css">
8+
</head>
9+
<body>
10+
<p><a href="../../index.html">[Home]</a></p>
11+
<ul>
12+
<li><a href="./pages/apis.html">Message Handlers</a> - <a href="./config/apis.json">Config</a></li>
13+
</ul>
14+
</body>
15+
</html>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width">
6+
<title>Webcompat shims</title>
7+
<link rel="stylesheet" href="../../shared/style.css">
8+
</head>
9+
<body>
10+
<script src="../../shared/utils.js"></script>
11+
<p><a href="../index.html">[Webcompat shims]</a></p>
12+
13+
<p>This page verifies that APIs get modified</p>
14+
15+
<script>
16+
test('API removal', async () => {
17+
return [
18+
{
19+
name: "APIs removal",
20+
result: navigator.userAgent,
21+
expected: undefined
22+
},
23+
{
24+
name: "New API definition deletion does nothing",
25+
result: navigator.thisDoesNotExist,
26+
expected: undefined
27+
},
28+
];
29+
});
30+
31+
test('Existing API modified', async () => {
32+
return [
33+
{
34+
name: "New API definition doesn't work",
35+
result: navigator.newAPI,
36+
expected: undefined
37+
},
38+
{
39+
name: "APIs modified",
40+
result: navigator.hardwareConcurrency,
41+
expected: 222
42+
},
43+
{
44+
name: "Returns expected value",
45+
result: window.name,
46+
expected: "newName"
47+
},
48+
{
49+
name: "Defaults to configurable",
50+
result: Object.getOwnPropertyDescriptor(window, 'name').configurable,
51+
expected: true
52+
},
53+
{
54+
name: "Defaults to enumerable",
55+
result: Object.getOwnPropertyDescriptor(window, 'name').enumerable,
56+
expected: true
57+
}
58+
]
59+
});
60+
61+
62+
test('Validate all expected APIs can be removed', async () => {
63+
// These APIs might not exist in all browsers however we should ensure they are removed after the code runs
64+
const result = []
65+
const APIs = [
66+
navigator.joinAdInterestGroup,
67+
navigator.leaveAdInterestGroup,
68+
navigator.clearOriginJoinedAdInterestGroups,
69+
navigator.updateAdInterestGroups,
70+
navigator.createAuctionNonce,
71+
navigator.runAdAuction,
72+
navigator.adAuctionComponents,
73+
navigator.deprecatedURNToURL,
74+
navigator.deprecatedReplaceInURN,
75+
navigator.getInterestGroupAdAuctionData,
76+
navigator.createAdRequest,
77+
navigator.finalizeAd,
78+
navigator.canLoadAdAuctionFencedFrame,
79+
navigator.deprecatedRunAdAuctionEnforcesKAnonymity,
80+
navigator.protectedAudience,
81+
]
82+
APIs.forEach(api => {
83+
result.push({
84+
name: `API ${api} removed`,
85+
result: api,
86+
expected: undefined
87+
});
88+
});
89+
return result;
90+
});
91+
92+
// eslint-disable-next-line no-undef
93+
renderResults();
94+
</script>
95+
</body>
96+
</html>

injected/src/captured-globals.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export const Proxy = globalThis.Proxy;
1313
export const functionToString = Function.prototype.toString;
1414
export const TypeError = globalThis.TypeError;
1515
export const Symbol = globalThis.Symbol;
16+
export const hasOwnProperty = Object.prototype.hasOwnProperty;

injected/src/features.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const baseFeatures = /** @type {const} */ ([
1111
'navigatorInterface',
1212
'elementHiding',
1313
'exceptionHandler',
14+
'apiManipulation',
1415
]);
1516

1617
const otherFeatures = /** @type {const} */ ([
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* This feature allows remote configuration of APIs that exist within the DOM.
3+
* We support removal of APIs and returning different values from getters.
4+
*
5+
* @module API manipulation
6+
*/
7+
import ContentFeature from '../content-feature';
8+
// eslint-disable-next-line no-redeclare
9+
import { hasOwnProperty } from '../captured-globals';
10+
import { processAttr } from '../utils';
11+
12+
/**
13+
* @internal
14+
*/
15+
export default class ApiManipulation extends ContentFeature {
16+
init() {
17+
const apiChanges = this.getFeatureSetting('apiChanges');
18+
if (apiChanges) {
19+
for (const scope in apiChanges) {
20+
const change = apiChanges[scope];
21+
if (!this.checkIsValidAPIChange(change)) {
22+
continue;
23+
}
24+
this.applyApiChange(scope, change);
25+
}
26+
}
27+
}
28+
29+
/**
30+
* Checks if the config API change is valid.
31+
* @param {any} change
32+
* @returns {change is APIChange}
33+
*/
34+
checkIsValidAPIChange(change) {
35+
if (typeof change !== 'object') {
36+
return false;
37+
}
38+
if (change.type === 'remove') {
39+
return true;
40+
}
41+
if (change.type === 'descriptor') {
42+
if (change.enumerable && typeof change.enumerable !== 'boolean') {
43+
return false;
44+
}
45+
if (change.configurable && typeof change.configurable !== 'boolean') {
46+
return false;
47+
}
48+
return typeof change.getterValue !== 'undefined';
49+
}
50+
return false;
51+
}
52+
53+
// TODO move this to schema definition imported from the privacy-config
54+
// Additionally remove checkIsValidAPIChange when this change happens.
55+
// See: https://app.asana.com/0/1201614831475344/1208715421518231/f
56+
/**
57+
* @typedef {Object} APIChange
58+
* @property {"remove"|"descriptor"} type
59+
* @property {import('../utils.js').ConfigSetting} [getterValue] - The value returned from a getter.
60+
* @property {boolean} [enumerable] - Whether the property is enumerable.
61+
* @property {boolean} [configurable] - Whether the property is configurable.
62+
*/
63+
64+
/**
65+
* Applies a change to DOM APIs.
66+
* @param {string} scope
67+
* @param {APIChange} change
68+
* @returns {void}
69+
*/
70+
applyApiChange(scope, change) {
71+
const response = this.getGlobalObject(scope);
72+
if (!response) {
73+
return;
74+
}
75+
const [obj, key] = response;
76+
if (change.type === 'remove') {
77+
this.removeApiMethod(obj, key);
78+
} else if (change.type === 'descriptor') {
79+
this.wrapApiDescriptor(obj, key, change);
80+
}
81+
}
82+
83+
/**
84+
* Removes a method from an API.
85+
* @param {object} api
86+
* @param {string} key
87+
*/
88+
removeApiMethod(api, key) {
89+
try {
90+
if (hasOwnProperty.call(api, key)) {
91+
delete api[key];
92+
}
93+
} catch (e) {}
94+
}
95+
96+
/**
97+
* Wraps a property with descriptor.
98+
* @param {object} api
99+
* @param {string} key
100+
* @param {APIChange} change
101+
*/
102+
wrapApiDescriptor(api, key, change) {
103+
const getterValue = change.getterValue;
104+
if (getterValue) {
105+
const descriptor = {
106+
get: () => processAttr(getterValue, undefined),
107+
};
108+
if ('enumerable' in change) {
109+
descriptor.enumerable = change.enumerable;
110+
}
111+
if ('configurable' in change) {
112+
descriptor.configurable = change.configurable;
113+
}
114+
this.wrapProperty(api, key, descriptor);
115+
}
116+
}
117+
118+
/**
119+
* Looks up a global object from a scope, e.g. 'Navigator.prototype'.
120+
* @param {string} scope the scope of the object to get to.
121+
* @returns {[object, string]|null} the object at the scope.
122+
*/
123+
getGlobalObject(scope) {
124+
const parts = scope.split('.');
125+
// get the last part of the scope
126+
const lastPart = parts.pop();
127+
if (!lastPart) {
128+
return null;
129+
}
130+
let obj = window;
131+
for (const part of parts) {
132+
obj = obj[part];
133+
if (!obj) {
134+
return null;
135+
}
136+
}
137+
return [obj, lastPart];
138+
}
139+
}

0 commit comments

Comments
 (0)