Skip to content

Commit cd2d8ee

Browse files
committed
AG-47723 Add new scriptlet — 'prevent-navigation'. #532
Squashed commit of the following: commit 95b684f Merge: 6dd53b5 35c567f Author: slvvko <v.leleka@adguard.com> Date: Wed Feb 18 09:32:10 2026 -0500 Merge branch 'master' into feature/AG-47723 commit 6dd53b5 Merge: baed80f f010ab7 Author: slvvko <v.leleka@adguard.com> Date: Tue Feb 17 21:54:48 2026 -0500 merge parent branch into current one, resolve conflicts commit baed80f Merge: 668cf25 c390347 Author: Adam Wróblewski <adam@adguard.com> Date: Tue Feb 17 10:24:56 2026 +0100 Merge branch 'master' into feature/AG-47723 commit 668cf25 Author: Adam Wróblewski <adam@adguard.com> Date: Tue Feb 17 10:21:43 2026 +0100 Add JSDoc for types and properties commit 7db19d7 Author: Adam Wróblewski <adam@adguard.com> Date: Tue Feb 17 09:51:40 2026 +0100 Fix shouldLog assignment commit b7f3b47 Author: Adam Wróblewski <adam@adguard.com> Date: Tue Feb 17 09:49:53 2026 +0100 Remove unnecessary type assertion for urlPattern commit dbfc273 Author: Adam Wróblewski <adam@adguard.com> Date: Tue Feb 17 09:49:14 2026 +0100 Remove typof check commit 4f2d837 Author: Adam Wróblewski <adam@adguard.com> Date: Fri Feb 13 10:56:07 2026 +0100 Get rid of "as any" Update tests commit bc2d407 Author: Adam Wróblewski <adam@adguard.com> Date: Fri Feb 13 08:59:59 2026 +0100 Add prevent-navigation scriptlet
1 parent 35c567f commit cd2d8ee

7 files changed

Lines changed: 294 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
1414

1515
### Added
1616

17+
- `prevent-navigation` scriptlet to prevent navigation to another URL, or reload website [#532].
1718
- `prevent-constructor` scriptlet to prevent constructor calls
1819
like `new Promise()` or `new MutationObserver()` [#461].
1920
- `remove-request-query-parameter` scriptlet to remove query parameters
@@ -60,6 +61,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
6061
[#500]: https://github.com/AdguardTeam/Scriptlets/issues/500
6162
[#507]: https://github.com/AdguardTeam/Scriptlets/issues/507
6263
[#528]: https://github.com/AdguardTeam/Scriptlets/issues/528
64+
[#532]: https://github.com/AdguardTeam/Scriptlets/issues/532
6365
[#550]: https://github.com/AdguardTeam/Scriptlets/issues/550
6466

6567
## [v2.2.15] - 2026-01-22

scripts/compatibility-table.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@
174174
"adg": "prevent-innerHTML",
175175
"ubo": "prevent-innerHTML"
176176
},
177+
{
178+
"adg": "prevent-navigation"
179+
},
177180
{
178181
"adg": "prevent-xhr",
179182
"ubo": "prevent-xhr.js (no-xhr-if.js)"
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { hit, logMessage, toRegExp } from '../helpers';
2+
import { type Source } from './scriptlets';
3+
4+
/**
5+
* Extend global Window with a navigation-like API when available.
6+
*/
7+
declare global {
8+
interface Window {
9+
/**
10+
* The navigation read-only property of the Window interface
11+
* returns the current window's associated Navigation object.
12+
*/
13+
navigation?: NavigationLike;
14+
}
15+
}
16+
17+
/**
18+
* Minimal shape of the Navigation API object used by this scriptlet.
19+
*/
20+
type NavigateEventLike = Event & {
21+
/**
22+
* The destination read-only property of the NavigateEventLike interface
23+
* returns a NavigationDestination object representing the destination being navigated to.
24+
*/
25+
destination: {
26+
/**
27+
* The URL of the destination.
28+
*/
29+
url: string;
30+
};
31+
};
32+
33+
/**
34+
* Minimal subset of the Navigation API used by this scriptlet.
35+
* Only includes `addEventListener('navigate', listener)`.
36+
*/
37+
type NavigationLike = {
38+
/**
39+
* Adds an event listener for the `navigate` event.
40+
*
41+
* @param type - Event name. Only `navigate` is used here. Fired when any type of navigation is initiated.
42+
* @param listener - The callback function to execute when the event occurs.
43+
*/
44+
addEventListener: (
45+
type: 'navigate',
46+
listener: (event: NavigateEventLike) => void
47+
) => void;
48+
};
49+
50+
/**
51+
* @scriptlet prevent-navigation
52+
*
53+
* @description
54+
* Prevents navigation to URL matching the specified pattern by intercepting the `navigate` event.
55+
*
56+
* ### Syntax
57+
*
58+
* ```text
59+
* example.org#%#//scriptlet('prevent-navigation'[, urlPattern])
60+
* ```
61+
*
62+
* - `urlPattern` — optional, string, regular expression or `location.href` keyword to match URL.
63+
*
64+
* > Usage with no arguments will log navigation attempts to browser console.
65+
*
66+
* ### Examples
67+
*
68+
* 1. Prevent navigation to URL containing `ads`:
69+
*
70+
* ```adblock
71+
* example.org#%#//scriptlet('prevent-navigation', 'ads')
72+
* ```
73+
*
74+
* 1. Prevent navigation to URLs matching regex:
75+
*
76+
* ```adblock
77+
* example.org#%#//scriptlet('prevent-navigation', '/foo.*bar/')
78+
* ```
79+
*
80+
* 1. Prevent `location.reload`:
81+
*
82+
* ```adblock
83+
* example.org#%#//scriptlet('prevent-navigation', 'location.href')
84+
* ```
85+
*
86+
* 1. Log all navigation attempts without blocking:
87+
*
88+
* ```adblock
89+
* example.org#%#//scriptlet('prevent-navigation')
90+
* ```
91+
*
92+
* @added unknown.
93+
*/
94+
export function preventNavigation(source: Source, urlPattern?: string | RegExp | undefined): void {
95+
const nav = window.navigation;
96+
if (!nav) {
97+
return;
98+
}
99+
100+
const currentUrlKeyword = 'location.href';
101+
const shouldLog: boolean = typeof urlPattern === 'undefined';
102+
let pattern: string | RegExp | null = null;
103+
104+
if (urlPattern === currentUrlKeyword) {
105+
pattern = window.location.href;
106+
} else if (typeof urlPattern === 'string') {
107+
pattern = toRegExp(urlPattern);
108+
}
109+
110+
const shouldPrevent = (patternUrl: string | RegExp | null, url: string): boolean => {
111+
if (!patternUrl) {
112+
return false;
113+
}
114+
// Match whole URL if pattern "location.href" is used, otherwise test regex pattern
115+
if (typeof patternUrl === 'string') {
116+
return url === patternUrl;
117+
}
118+
return patternUrl.test(url);
119+
};
120+
121+
nav.addEventListener('navigate', (event: NavigateEventLike) => {
122+
const destinationURL = event?.destination?.url;
123+
124+
if (!destinationURL) {
125+
return;
126+
}
127+
128+
if (shouldLog) {
129+
hit(source);
130+
logMessage(source, `Navigating to: ${destinationURL}`);
131+
return;
132+
}
133+
134+
if (shouldPrevent(pattern, destinationURL)) {
135+
event.preventDefault();
136+
hit(source);
137+
logMessage(source, `Blocked navigation to: ${destinationURL}`);
138+
}
139+
});
140+
}
141+
142+
export const preventNavigationNames = [
143+
'prevent-navigation',
144+
];
145+
146+
// eslint-disable-next-line prefer-destructuring
147+
preventNavigation.primaryName = preventNavigationNames[0];
148+
149+
preventNavigation.injections = [
150+
hit,
151+
logMessage,
152+
toRegExp,
153+
];

src/scriptlets/scriptlets-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export { trustedReplaceArgument } from './trusted-replace-argument';
7878
export { preventInnerHTML } from './prevent-innerHTML';
7979
export { preventConstructor } from './prevent-constructor';
8080
export { removeRequestQueryParameter } from './remove-request-query-parameter';
81+
export { preventNavigation } from './prevent-navigation';
8182
// redirects as scriptlets
8283
// https://github.com/AdguardTeam/Scriptlets/issues/300
8384
export { AmazonApstag } from './amazon-apstag';

src/scriptlets/scriptlets-names-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export { trustedReplaceArgumentNames } from './trusted-replace-argument';
7878
export { preventInnerHTMLNames } from './prevent-innerHTML';
7979
export { preventConstructorNames } from './prevent-constructor';
8080
export { removeRequestQueryParameterNames } from './remove-request-query-parameter';
81+
export { preventNavigationNames } from './prevent-navigation';
8182
// redirects as scriptlets
8283
// https://github.com/AdguardTeam/Scriptlets/issues/300
8384
export { AmazonApstagNames } from './amazon-apstag';

tests/scriptlets/index.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,4 @@ import './trusted-create-element.test';
7575
import './trusted-dispatch-event.test';
7676
import './trusted-replace-outbound-text.test';
7777
import './trusted-set-attr.test';
78+
import './prevent-navigation.test';
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/* eslint-disable no-underscore-dangle, no-console */
2+
import { runScriptlet, clearGlobalProps } from '../helpers';
3+
4+
const { test, module } = QUnit;
5+
const name = 'prevent-navigation';
6+
7+
const nativeConsoleLog = window.console.log;
8+
9+
const beforeEach = () => {
10+
window.__debug = () => {
11+
window.hit = 'FIRED';
12+
};
13+
};
14+
15+
const afterEach = () => {
16+
clearGlobalProps('hit', '__debug');
17+
window.console.log = nativeConsoleLog;
18+
};
19+
20+
module(name, { beforeEach, afterEach });
21+
22+
const isSupported = typeof navigation !== 'undefined';
23+
24+
if (!isSupported) {
25+
test('unsupported', (assert) => {
26+
assert.ok(true, 'Browser does not support it');
27+
});
28+
} else {
29+
test('Prevent website reload', (assert) => {
30+
assert.expect(1);
31+
const url = 'location.href';
32+
33+
const scriptletArgs = [url];
34+
runScriptlet(name, scriptletArgs);
35+
36+
try {
37+
window.location.reload();
38+
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
39+
} catch (error) {
40+
assert.ok(false, `Navigation should be prevented, but an error was thrown: ${error}`);
41+
}
42+
});
43+
44+
test('Prevent navigation to "advert" URL', (assert) => {
45+
assert.expect(1);
46+
const url = 'advert';
47+
48+
const scriptletArgs = [url];
49+
runScriptlet(name, scriptletArgs);
50+
51+
try {
52+
window.location.href = '/advert';
53+
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
54+
} catch (error) {
55+
assert.ok(false, `Navigation should be prevented, but an error was thrown: ${error}`);
56+
}
57+
});
58+
59+
test('Prevent navigation - regex', (assert) => {
60+
const url = '/adblock.*enabled/';
61+
62+
const scriptletArgs = [url];
63+
runScriptlet(name, scriptletArgs);
64+
65+
window.location.href = '/adblock-test-foo-bar-enabled';
66+
67+
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
68+
});
69+
70+
test('Prevent navigation - URL does not match', (assert) => {
71+
assert.expect(1);
72+
const url = 'SHOULD_NOT_MATCH';
73+
const redirectUrl = '/test-foo';
74+
75+
const scriptletArgs = [url];
76+
runScriptlet(name, scriptletArgs);
77+
78+
// Prevent navigation to "redirectUrl", if it's not blocked by the scriptlet
79+
// When it's blocked by the scriptlet, the test will fail as "hit" will be set to "FIRED"
80+
window.navigation.addEventListener('navigate', (event) => {
81+
const { url } = event.destination;
82+
if (url.includes(redirectUrl)) {
83+
event.preventDefault();
84+
}
85+
});
86+
87+
try {
88+
window.location.href = redirectUrl;
89+
assert.strictEqual(window.hit, undefined, 'hit should NOT fire');
90+
} catch (error) {
91+
assert.ok(false, `Navigation should be prevented, but an error was thrown: ${error}`);
92+
}
93+
});
94+
95+
test('Log navigation URL to console', (assert) => {
96+
assert.expect(2);
97+
const redirectUrl = '/log-navigation-test';
98+
const expectedMessage = 'log-navigation-test';
99+
let testPassed = false;
100+
101+
runScriptlet(name);
102+
103+
// Need to prevent navigation to "redirectUrl", because it's not blocked by the scriptlet
104+
window.navigation.addEventListener('navigate', (event) => {
105+
const { url } = event.destination;
106+
if (url.includes(redirectUrl)) {
107+
event.preventDefault();
108+
}
109+
});
110+
111+
// Override "console.log" to check if the log contains the expected messages
112+
const wrapperLog = (target, thisArg, args) => {
113+
const logContent = args[0];
114+
if (logContent.includes(expectedMessage)) {
115+
testPassed = true;
116+
}
117+
return Reflect.apply(target, thisArg, args);
118+
};
119+
const handlerLog = {
120+
apply: wrapperLog,
121+
};
122+
window.console.log = new Proxy(window.console.log, handlerLog);
123+
124+
try {
125+
window.location.href = redirectUrl;
126+
127+
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
128+
assert.ok(testPassed, 'log should contain the expected message');
129+
} catch (error) {
130+
assert.ok(false, `Navigation should be prevented, but an error was thrown: ${error}`);
131+
}
132+
});
133+
}

0 commit comments

Comments
 (0)