Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions injected/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ The exposed API is a global called contentScopeFeatures and has three methods:
- 'allowlisted' true if the user has disabled protections.
- 'domain' the hostname of the site in the URL bar
- 'enabledFeatures' this is an array of features/ to enable
- urlChanged
- Called when the top frame URL is changed (for Single Page Apps)
- Also ensures that path changes for config 'conditional matching' are applied.
- update
- Calls the update method on all the features

Expand Down
9 changes: 9 additions & 0 deletions injected/integration-test/pages.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ test.describe('Test integration pages', () => {
}
}

test('Test infra', async ({ page }, testInfo) => {
await testPage(
page,
testInfo,
'/infra/pages/conditional-matching.html',
'./integration-test/test-pages/infra/config/conditional-matching.json',
);
});

test('Test manipulating APIs', async ({ page }, testInfo) => {
await testPage(
page,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"features": {
"apiManipulation": {
"state": "enabled",
"settings": {
"apiChanges": {
"Navigator.prototype.hardwareConcurrency": {
"type": "descriptor",
"getterValue": {
"type": "number",
"value": 222
}
}
},
"conditionalChanges": [
{
"condition": {
"urlPattern": "/test/*"
},
"patchSettings": [
{
"op": "replace",
"path": "/apiChanges/Navigator.prototype.hardwareConcurrency/getterValue/value",
"value": 333
}
]
}
]
}
}
},
"unprotectedTemporary": []
}

15 changes: 15 additions & 0 deletions injected/integration-test/test-pages/infra/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/conditional-matching.html">Conditional matching</a> - <a href="./config/conditional-matching.json">Config</a></li>
</ul>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Conditional Matching</title>
<link rel="stylesheet" href="../../shared/style.css">
</head>
<body>
<script src="../../shared/utils.js"></script>
<p><a href="../index.html">[Infra]</a></p>

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

<script>
test('Conditional matching', async () => {
const results = [
{
name: "APIs changing, expecting to always match",
result: navigator.hardwareConcurrency,
expected: 222
}
];
const oldPathname = window.location.pathname;
const newUrl = new URL(window.location.href);
newUrl.pathname = "/test/test/path";
window.history.pushState(null, '', newUrl.href);
await new Promise(resolve => requestIdleCallback(resolve));
results.push({
name: "Expect URL to be changed",
result: window.location.pathname,
expected: '/test/test/path'
})
results.push({
name: "APIs changing, expecting to match only when the URL is correct",
result: navigator.hardwareConcurrency,
expected: 333
})
const popStatePromise = new Promise(resolve => {
window.addEventListener('popstate', resolve, { once: true });
});
// Call pop state to revert the URL
window.history.back();
await popStatePromise;
results.push({
name: "Expect URL to be reverted",
result: window.location.pathname,
expected: oldPathname
})
results.push({
name: "APIs changing, expecting to match only when the URL is correct",
result: navigator.hardwareConcurrency,
expected: 222
})

return results;
});


// eslint-disable-next-line no-undef
renderResults();
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion injected/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"test-int-x": "xvfb-run --server-args='-screen 0 1024x768x24' npm run test-int",
"test-int-snapshots": "playwright test --grep '@screenshots'",
"test-int-snapshots-update": "playwright test --grep '@screenshots' --update-snapshots --last-failed",
"test": "npm run lint && npm run test-unit && npm run test-int && npm run playwright",
"test": "npm run test-unit && npm run test-int && npm run playwright",
"serve": "http-server -c-1 --port 3220 integration-test/test-pages",
"playwright": "playwright test --grep-invert '@screenshots'",
"playwright-screenshots": "playwright test --grep '@screenshots'",
Expand Down
12 changes: 11 additions & 1 deletion injected/src/config-feature.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { immutableJSONPatch } from 'immutable-json-patch';
import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings, computeLimitedSiteObject } from './utils.js';
import { URLPattern } from 'urlpattern-polyfill';

export default class ConfigFeature {
Expand Down Expand Up @@ -29,6 +29,16 @@ export default class ConfigFeature {
}
}

/**
* Call this when the top URL has changed, to recompute the site object.
* This is used to update the path matching for urlPattern.
*/
recomputeSiteObject() {
if (this.#args) {
this.#args.site = computeLimitedSiteObject();
}
}

get args() {
return this.#args;
}
Expand Down
5 changes: 5 additions & 0 deletions injected/src/content-feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export default class ContentFeature extends ConfigFeature {
#messaging;
/** @type {boolean} */
#isDebugFlagSet = false;
/**
* Set this to true if you wish to listen to top level URL changes for config matching.
* @type {boolean}
*/
listenForUrlChanges = false;

/** @type {ImportMeta} */
#importConfig;
Expand Down
11 changes: 11 additions & 0 deletions injected/src/content-scope-features.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { initStringExemptionLists, isFeatureBroken, isGloballyDisabled, platform
import { platformSupport } from './features';
import { PerformanceMonitor } from './performance';
import platformFeatures from 'ddg:platformFeatures';
import { registerForURLChanges } from './url-change';

let initArgs = null;
const updates = [];
Expand Down Expand Up @@ -74,6 +75,16 @@ export async function init(args) {
resolvedFeatures.forEach(({ featureInstance, featureName }) => {
if (!isFeatureBroken(args, featureName) || alwaysInitExtensionFeatures(args, featureName)) {
featureInstance.callInit(args);
// Either listenForUrlChanges or urlChanged ensures the feature listens.
if (featureInstance.listenForUrlChanges || featureInstance.urlChanged) {
registerForURLChanges(() => {
// The rationale for the two separate call here is to ensure that
// extensions to the class don't need to call super.urlChanged()
featureInstance.recomputeSiteObject();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbajpeyi this is now split out so feature authors don't need to call super() as before.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: do we forsee any major issues if we have both recomputeSiteObject and urlChanged being called? Can there be any inconsistencies?

// Called if the feature instance has a urlChanged method
featureInstance?.urlChanged();
});
}
}
});
// Fire off updates that came in faster than the init
Expand Down
6 changes: 6 additions & 0 deletions injected/src/features/api-manipulation.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { processAttr } from '../utils';
* @internal
*/
export default class ApiManipulation extends ContentFeature {
listenForUrlChanges = true;

init() {
const apiChanges = this.getFeatureSetting('apiChanges');
if (apiChanges) {
Expand All @@ -26,6 +28,10 @@ export default class ApiManipulation extends ContentFeature {
}
}

urlChanged() {
this.init();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means we'll rerun rules on navigation change.

}

/**
* Checks if the config API change is valid.
* @param {any} change
Expand Down
22 changes: 8 additions & 14 deletions injected/src/features/autofill-password-import.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ContentFeature from '../content-feature';
import { DDGProxy, DDGReflect, withExponentialBackoff } from '../utils';
import { isBeingFramed, withExponentialBackoff } from '../utils';

export const ANIMATION_DURATION_MS = 1000;
export const ANIMATION_ITERATIONS = Infinity;
Expand Down Expand Up @@ -490,23 +490,17 @@ export default class AutofillPasswordImport extends ContentFeature {
this.#settingsButtonSettings = this.getFeatureSetting('settingsButton');
}

urlChanged() {
this.handlePath(window.location.pathname);
}

init() {
if (isBeingFramed()) {
return;
}
this.setButtonSettings();

const handlePath = this.handlePath.bind(this);
const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', {
async apply(target, thisArg, args) {
const path = args[1] === '' ? args[2].split('?')[0] : args[1];
await handlePath(path);
return DDGReflect.apply(target, thisArg, args);
},
});
historyMethodProxy.overload();
// listen for popstate events in order to run on back/forward navigations
window.addEventListener('popstate', async () => {
const path = window.location.pathname;
await handlePath(path);
});

this.#domLoaded = new Promise((resolve) => {
if (document.readyState !== 'loading') {
Expand Down
22 changes: 8 additions & 14 deletions injected/src/features/element-hiding.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ContentFeature from '../content-feature';
import { isBeingFramed, DDGProxy, DDGReflect, injectGlobalStyles } from '../utils';
import { isBeingFramed, injectGlobalStyles } from '../utils';

let adLabelStrings = [];
const parser = new DOMParser();
Expand Down Expand Up @@ -360,19 +360,13 @@ export default class ElementHiding extends ContentFeature {
} else {
applyRules(activeRules);
}
// single page applications don't have a DOMContentLoaded event on navigations, so
// we use proxy/reflect on history.pushState to call applyRules on page navigations
const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', {
apply(target, thisArg, args) {
applyRules(activeRules);
return DDGReflect.apply(target, thisArg, args);
},
});
historyMethodProxy.overload();
// listen for popstate events in order to run on back/forward navigations
window.addEventListener('popstate', () => {
applyRules(activeRules);
});
this.activeRules = activeRules;
}

urlChanged() {
if (this.activeRules) {
this.applyRules(this.activeRules);
}
}

/**
Expand Down
51 changes: 51 additions & 0 deletions injected/src/url-change.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { DDGProxy, DDGReflect, isBeingFramed } from './utils.js';
import ContentFeature from './content-feature.js';

const urlChangeListeners = new Set();
/**
* Register a listener to be called when the URL changes.
* @param {function} listener
*/
export function registerForURLChanges(listener) {
if (urlChangeListeners.size === 0) {
listenForURLChanges();
}
urlChangeListeners.add(listener);
}

function handleURLChange() {
for (const listener of urlChangeListeners) {
listener();
}
}

function listenForURLChanges() {
const urlChangedInstance = new ContentFeature('urlChanged', {}, {});
if ('navigation' in globalThis && 'addEventListener' in globalThis.navigation) {
// if the browser supports the navigation API, we can use that to listen for URL changes
// Listening to navigatesuccess instead of navigate to ensure the navigation is committed.
globalThis.navigation.addEventListener('navigatesuccess', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbajpeyi the navigate event actually fired too early, I validated this works as expected. The API has a way to modify and deny navigations before they happen.

handleURLChange();
});
// Exit early if the navigation API is supported
return;
}
if (isBeingFramed()) {
// don't run if we're in an iframe
return;
}
// single page applications don't have a DOMContentLoaded event on navigations, so
// we use proxy/reflect on history.pushState to call applyRules on page navigations
const historyMethodProxy = new DDGProxy(urlChangedInstance, History.prototype, 'pushState', {
apply(target, thisArg, args) {
const changeResult = DDGReflect.apply(target, thisArg, args);
handleURLChange();
return changeResult;
},
});
historyMethodProxy.overload();
// listen for popstate events in order to run on back/forward navigations
window.addEventListener('popstate', () => {
handleURLChange();
});
}
6 changes: 3 additions & 3 deletions injected/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -546,10 +546,10 @@ export function isUnprotectedDomain(topLevelHostname, featureList) {
* Used to inialize extension code in the load phase
*/
export function computeLimitedSiteObject() {
const topLevelHostname = getTabHostname();
const tabURL = getTabUrl();
return {
domain: topLevelHostname,
url: getTabUrl()?.href || null,
domain: tabURL?.hostname || null,
url: tabURL?.href || null,
};
}

Expand Down
Loading