Skip to content

Commit

Permalink
Add API manipulation feature (#1207)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jonathanKingston authored Nov 7, 2024
1 parent b46d850 commit 07d9f81
Show file tree
Hide file tree
Showing 10 changed files with 360 additions and 2 deletions.
8 changes: 8 additions & 0 deletions injected/integration-test/pages.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ test.describe('Test integration pages', () => {
}
}

test('Test manipulating APIs', async ({ page }) => {
await testPage(
page,
'api-manipulation/pages/apis.html',
`${process.cwd()}/integration-test/test-pages/api-manipulation/config/apis.json`,
);
});

test('Web compat shims correctness', async ({ page }) => {
await testPage(page, 'webcompat/pages/shims.html', `${process.cwd()}/integration-test/test-pages/webcompat/config/shims.json`);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"features": {
"apiManipulation": {
"state": "enabled",
"settings": {
"apiChanges": {
"Navigator.prototype.hardwareConcurrency": {
"type": "descriptor",
"getterValue": {
"type": "number",
"value": 222
}
},
"Navigator.prototype.userAgent": {
"type": "remove"
},
"Navigator.prototype.thisDoesNotExist": {
"type": "remove"
},
"Navigator.prototype.newAPI": {
"type": "descriptor",
"getterValue": {
"type": "number",
"value": 222
}
},
"window.name": {
"type": "descriptor",
"getterValue": {
"type": "string",
"value": "newName"
}
},
"Navigator.prototype.joinAdInterestGroup": {
"type": "remove"
},
"Navigator.prototype.leaveAdInterestGroup": {
"type": "remove"
},
"Navigator.prototype.clearOriginJoinedAdInterestGroups": {
"type": "remove"
},
"Navigator.prototype.updateAdInterestGroups": {
"type": "remove"
},
"Navigator.prototype.createAuctionNonce": {
"type": "remove"
},
"Navigator.prototype.runAdAuction": {
"type": "remove"
},
"Navigator.prototype.adAuctionComponents": {
"type": "remove"
},
"Navigator.prototype.deprecatedURNToURL": {
"type": "remove"
},
"Navigator.prototype.deprecatedReplaceInURN": {
"type": "remove"
},
"Navigator.prototype.getInterestGroupAdAuctionData": {
"type": "remove"
},
"Navigator.prototype.createAdRequest": {
"type": "remove"
},
"Navigator.prototype.finalizeAd": {
"type": "remove"
},
"Navigator.prototype.canLoadAdAuctionFencedFrame": {
"type": "remove"
},
"Navigator.prototype.deprecatedRunAdAuctionEnforcesKAnonymity": {
"type": "remove"
},
"Navigator.prototype.protectedAudience": {
"type": "descriptor",
"getterValue": {
"type": "undefined"
}
}
}
}
}
}
}
15 changes: 15 additions & 0 deletions injected/integration-test/test-pages/api-manipulation/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>API Interventions</title>
<link rel="stylesheet" href="../shared/style.css">
</head>
<body>
<p><a href="../../index.html">[Home]</a></p>
<ul>
<li><a href="./pages/apis.html">Message Handlers</a> - <a href="./config/apis.json">Config</a></li>
</ul>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Webcompat shims</title>
<link rel="stylesheet" href="../../shared/style.css">
</head>
<body>
<script src="../../shared/utils.js"></script>
<p><a href="../index.html">[Webcompat shims]</a></p>

<p>This page verifies that APIs get modified</p>

<script>
test('API removal', async () => {
return [
{
name: "APIs removal",
result: navigator.userAgent,
expected: undefined
},
{
name: "New API definition deletion does nothing",
result: navigator.thisDoesNotExist,
expected: undefined
},
];
});

test('Existing API modified', async () => {
return [
{
name: "New API definition doesn't work",
result: navigator.newAPI,
expected: undefined
},
{
name: "APIs modified",
result: navigator.hardwareConcurrency,
expected: 222
},
{
name: "Returns expected value",
result: window.name,
expected: "newName"
},
{
name: "Defaults to configurable",
result: Object.getOwnPropertyDescriptor(window, 'name').configurable,
expected: true
},
{
name: "Defaults to enumerable",
result: Object.getOwnPropertyDescriptor(window, 'name').enumerable,
expected: true
}
]
});


test('Validate all expected APIs can be removed', async () => {
// These APIs might not exist in all browsers however we should ensure they are removed after the code runs
const result = []
const APIs = [
navigator.joinAdInterestGroup,
navigator.leaveAdInterestGroup,
navigator.clearOriginJoinedAdInterestGroups,
navigator.updateAdInterestGroups,
navigator.createAuctionNonce,
navigator.runAdAuction,
navigator.adAuctionComponents,
navigator.deprecatedURNToURL,
navigator.deprecatedReplaceInURN,
navigator.getInterestGroupAdAuctionData,
navigator.createAdRequest,
navigator.finalizeAd,
navigator.canLoadAdAuctionFencedFrame,
navigator.deprecatedRunAdAuctionEnforcesKAnonymity,
navigator.protectedAudience,
]
APIs.forEach(api => {
result.push({
name: `API ${api} removed`,
result: api,
expected: undefined
});
});
return result;
});

// eslint-disable-next-line no-undef
renderResults();
</script>
</body>
</html>
1 change: 1 addition & 0 deletions injected/src/captured-globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export const Proxy = globalThis.Proxy;
export const functionToString = Function.prototype.toString;
export const TypeError = globalThis.TypeError;
export const Symbol = globalThis.Symbol;
export const hasOwnProperty = Object.prototype.hasOwnProperty;
1 change: 1 addition & 0 deletions injected/src/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const baseFeatures = /** @type {const} */ ([
'navigatorInterface',
'elementHiding',
'exceptionHandler',
'apiManipulation',
]);

const otherFeatures = /** @type {const} */ ([
Expand Down
139 changes: 139 additions & 0 deletions injected/src/features/api-manipulation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* This feature allows remote configuration of APIs that exist within the DOM.
* We support removal of APIs and returning different values from getters.
*
* @module API manipulation
*/
import ContentFeature from '../content-feature';
// eslint-disable-next-line no-redeclare
import { hasOwnProperty } from '../captured-globals';
import { processAttr } from '../utils';

/**
* @internal
*/
export default class ApiManipulation extends ContentFeature {
init() {
const apiChanges = this.getFeatureSetting('apiChanges');
if (apiChanges) {
for (const scope in apiChanges) {
const change = apiChanges[scope];
if (!this.checkIsValidAPIChange(change)) {
continue;
}
this.applyApiChange(scope, change);
}
}
}

/**
* Checks if the config API change is valid.
* @param {any} change
* @returns {change is APIChange}
*/
checkIsValidAPIChange(change) {
if (typeof change !== 'object') {
return false;
}
if (change.type === 'remove') {
return true;
}
if (change.type === 'descriptor') {
if (change.enumerable && typeof change.enumerable !== 'boolean') {
return false;
}
if (change.configurable && typeof change.configurable !== 'boolean') {
return false;
}
return typeof change.getterValue !== 'undefined';
}
return false;
}

// TODO move this to schema definition imported from the privacy-config
// Additionally remove checkIsValidAPIChange when this change happens.
// See: https://app.asana.com/0/1201614831475344/1208715421518231/f
/**
* @typedef {Object} APIChange
* @property {"remove"|"descriptor"} type
* @property {import('../utils.js').ConfigSetting} [getterValue] - The value returned from a getter.
* @property {boolean} [enumerable] - Whether the property is enumerable.
* @property {boolean} [configurable] - Whether the property is configurable.
*/

/**
* Applies a change to DOM APIs.
* @param {string} scope
* @param {APIChange} change
* @returns {void}
*/
applyApiChange(scope, change) {
const response = this.getGlobalObject(scope);
if (!response) {
return;
}
const [obj, key] = response;
if (change.type === 'remove') {
this.removeApiMethod(obj, key);
} else if (change.type === 'descriptor') {
this.wrapApiDescriptor(obj, key, change);
}
}

/**
* Removes a method from an API.
* @param {object} api
* @param {string} key
*/
removeApiMethod(api, key) {
try {
if (hasOwnProperty.call(api, key)) {
delete api[key];
}
} catch (e) {}
}

/**
* Wraps a property with descriptor.
* @param {object} api
* @param {string} key
* @param {APIChange} change
*/
wrapApiDescriptor(api, key, change) {
const getterValue = change.getterValue;
if (getterValue) {
const descriptor = {
get: () => processAttr(getterValue, undefined),
};
if ('enumerable' in change) {
descriptor.enumerable = change.enumerable;
}
if ('configurable' in change) {
descriptor.configurable = change.configurable;
}
this.wrapProperty(api, key, descriptor);
}
}

/**
* Looks up a global object from a scope, e.g. 'Navigator.prototype'.
* @param {string} scope the scope of the object to get to.
* @returns {[object, string]|null} the object at the scope.
*/
getGlobalObject(scope) {
const parts = scope.split('.');
// get the last part of the scope
const lastPart = parts.pop();
if (!lastPart) {
return null;
}
let obj = window;
for (const part of parts) {
obj = obj[part];
if (!obj) {
return null;
}
}
return [obj, lastPart];
}
}
Loading

0 comments on commit 07d9f81

Please sign in to comment.