Skip to content

Commit

Permalink
Implement prevent-window-open/nowoif (#228)
Browse files Browse the repository at this point in the history
* WIP prevent-window-open

* Implement rc version of prevent-window-open

* scriptlet/prevent-window-open: Handle undefined URL case

* scriptlets/prevent-window-open: Fix logic for match inversion

* scriptlets/prevent-window-open: Add prevention logging; handle some minor edge cases

* scriptlets/prevent-window-open: Add test suite

* scriptlets/prevent-window-open: Handle blank replacement

* Add preventWindowOpen to scriptlet injector rule mapping; update bundle.js

* scriptlets/prevent-window-open: Improve regex for replacement validation by only matching the whole string

* scriptlets/prevent-window-open: Address CodeQL alert by refactoring replacement parsing without the use of regular expressions
  • Loading branch information
anfragment authored Feb 19, 2025
1 parent ef5381e commit 373d974
Show file tree
Hide file tree
Showing 5 changed files with 363 additions and 1 deletion.
2 changes: 2 additions & 0 deletions internal/scriptlet/addrule.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ var (
"json-prune-fetch-response": "jsonPruneFetchResponse",
"json-prune-xhr-response": "jsonPruneXHRResponse",
"abort-current-inline-script": "abortCurrentInlineScript",
"prevent-window-open": "preventWindowOpen",
"abort-on-property-read": "abortOnPropertyRead",
"abort-on-property-write": "abortOnPropertyWrite",
"abort-on-stack-trace": "abortOnStackTrace",
Expand All @@ -36,6 +37,7 @@ var (
"no-fetch-if": "preventFetch",
"nowebrtc": "nowebrtc",
"set-constant": "setConstant",
"nowoif": "preventWindowOpen",
"aopr": "abortOnPropertyRead",
"aopw": "abortOnPropertyWrite",
"aost": "abortOnStackTrace",
Expand Down
2 changes: 1 addition & 1 deletion internal/scriptlet/bundle.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions scriptlets/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { jsonPrune } from './json-prune';
export { jsonPruneFetchResponse } from './json-prune-fetch-response';
export { jsonPruneXHRResponse } from './json-prune-xhr-response';
export { abortCurrentInlineScript } from './abort-current-inline-script';
export { preventWindowOpen } from './prevent-window-open';
export { abortOnPropertyRead } from './abort-on-property-read';
export { abortOnPropertyWrite } from './abort-on-property-write';
export { abortOnStackTrace } from './abort-on-stack-trace';
154 changes: 154 additions & 0 deletions scriptlets/src/prevent-window-open.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { expect, jest, test, describe, beforeEach, afterEach } from '@jest/globals';

import { preventWindowOpen } from './prevent-window-open';

describe('prevent-window-open', () => {
let openRepl: ReturnType<typeof jest.fn>;
let originalOpen: typeof open;

beforeEach(() => {
originalOpen = window.open;
openRepl = jest.fn();
window.open = openRepl;
});

afterEach(() => {
window.open = originalOpen;
});

test('new syntax: prevents all calls to window.open when called with no arguments', () => {
preventWindowOpen();

window.open();
window.open('https://test.com');
window.open(new URL('https://test.com'));

expect(openRepl).not.toHaveBeenCalled();
});

test('new syntax: correctly handles string "match"', () => {
preventWindowOpen('test');

window.open('https://test.com');
window.open(new URL('https://test.com'));
expect(openRepl).not.toHaveBeenCalled();

window.open('https://example.com');
window.open(new URL('https://example.com'));
expect(openRepl).toHaveBeenCalledTimes(2);
});

test('new syntax: correctly handles regular expression "match"', () => {
preventWindowOpen('/a.+c/');

window.open('https://abc.com');
window.open(new URL('https://abc.com'));
expect(openRepl).not.toHaveBeenCalled();

window.open('https://cba.net');
window.open(new URL('https://cba.net'));
expect(openRepl).toHaveBeenCalledTimes(2);
});

test('new syntax: inverts prevention when "match" is prepended with !', () => {
preventWindowOpen('!test');

window.open('https://example.com');
window.open(new URL('https://example.com'));
expect(openRepl).not.toHaveBeenCalled();

window.open('https://test.com');
window.open(new URL('https://test.com'));
expect(openRepl).toHaveBeenCalledTimes(2);
});

test('new syntax: returns a fake window when called with no "replacement"', () => {
preventWindowOpen();

const w = window.open();
expect(openRepl).not.toHaveBeenCalled();
expect(w).not.toBeNull();

expect(w!.document).toBeInstanceOf(Document);
});

test('new syntax: calls window.open with "about:blank" when "replacement" is "blank"', () => {
preventWindowOpen('', '', 'blank');

window.open('https://test.com', '_self');
expect(openRepl).toHaveBeenCalledWith('about:blank', '_self');
});

test('old syntax: prevents all calls to window.open when called only with "match"', () => {
preventWindowOpen('1');

window.open();
window.open('https://test.com');
window.open(new URL('https://test.com'));

expect(openRepl).not.toHaveBeenCalled();
});

test('old syntax: correctly handles string "search"', () => {
preventWindowOpen('1', 'test');

window.open('https://test.com');
window.open(new URL('https://test.com'));
expect(openRepl).not.toHaveBeenCalled();

window.open('https://example.com');
window.open(new URL('https://example.com'));
expect(openRepl).toHaveBeenCalledTimes(2);
});

test('old syntax: correctly handles regular expression "search"', () => {
preventWindowOpen('1', '/a.+c/');

window.open('https://abc.com');
window.open(new URL('https://abc.com'));
expect(openRepl).not.toHaveBeenCalled();

window.open('https://cba.net');
window.open(new URL('https://cba.net'));
expect(openRepl).toHaveBeenCalledTimes(2);
});

test('old syntax: inverts prevention when "match" is prepended with 0', () => {
preventWindowOpen('0', 'test');

window.open('https://example.com');
window.open(new URL('https://example.com'));
expect(openRepl).not.toHaveBeenCalled();

window.open('https://test.com');
window.open(new URL('https://test.com'));
expect(openRepl).toHaveBeenCalledTimes(2);
});

test('old syntax: returns a noop function when called with no "replacement"', () => {
preventWindowOpen('1', 'test');

const w = window.open('https://test.com') as unknown as Function;
expect(openRepl).not.toHaveBeenCalled();
expect(w).toBeInstanceOf(Function);
expect(w()).toBeUndefined();
});

test('old syntax: returns a true function when "replacement" is "trueFunc"', () => {
preventWindowOpen('1', 'test', 'trueFunc');

const w = window.open('https://test.com') as unknown as Function;
expect(openRepl).not.toHaveBeenCalled();
expect(w).toBeInstanceOf(Function);
expect(w()).toBe(true);
});

test('old syntax: returns a noop function in a property when "replacement" is "{prop=noopFunc}"', () => {
preventWindowOpen('1', 'test', '{prop=noopFunc}');

const w = window.open('https://test.com') as unknown as { prop: Function };
expect(openRepl).not.toHaveBeenCalled();
expect(w).toEqual({ prop: expect.any(Function) });
expect(w.prop()).toBeUndefined();
});
});
205 changes: 205 additions & 0 deletions scriptlets/src/prevent-window-open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { createLogger } from './helpers/logger';
import { parseRegexpFromString, parseRegexpLiteral } from './helpers/parseRegexp';
import { parseValidInt } from './helpers/parseValidInt';

const logger = createLogger('prevent-window-open');

type Handler = ProxyHandler<typeof window.open>['apply'];

export function preventWindowOpen(match?: string, delayOrSearch?: string, replacement?: string) {
let handler: Handler;

try {
if (match === '1' || match === '0') {
handler = makeOldSyntaxHandler(match, delayOrSearch, replacement);
} else {
handler = makeNewSyntaxHandler(match, delayOrSearch, replacement);
}
} catch (error) {
logger.warn('Error while making handler', { ex: error });
return;
}

window.open = new Proxy(window.open, { apply: handler });
}

function makeOldSyntaxHandler(match?: string, search?: string, replacement?: string): Handler {
let invertMatch = false;
if (match === '0') {
invertMatch = true;
}

let matchRe: RegExp | undefined;
if (typeof search === 'string' && search.length > 0) {
matchRe = (parseRegexpLiteral(search) || parseRegexpFromString(search)) ?? undefined;
if (matchRe === undefined) {
throw new Error('Could not parse search');
}
}

let returnValue: (() => void) | (() => true) | Record<string, () => void> = () => {};
if (replacement === 'trueFunc') {
returnValue = () => true;
} else if (typeof replacement === 'string' && replacement.length > 0) {
if (!replacement.startsWith('{') || !replacement.endsWith('}')) {
throw new Error(`Invalid replacement ${replacement}`);
}
const content = replacement.slice(1, -1);
const parts = content.split('=');
if (parts.length !== 2 || parts[0].length === 0 || parts[1] !== 'noopFunc') {
throw new Error(`Invalid replacement ${replacement}`);
}
returnValue = { [parts[0]]: () => {} };
}

return (target, thisArg, args: Parameters<typeof window.open>) => {
let url: string;
if (args.length === 0 || args[0] == undefined) {
// This is a valid case.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/open#url
url = '';
} else if (typeof args[0] === 'string') {
url = args[0];
} else if (args[0] instanceof URL) {
url = args[0].toString();
} else {
// Bad input, let the original function handle it.
return Reflect.apply(target, thisArg, args);
}

if (matchRe !== undefined) {
let prevent = matchRe.test(url);
if (invertMatch) {
prevent = !prevent;
}
if (!prevent) {
return Reflect.apply(target, thisArg, args);
}
}

logger.info('Preventing window.open', { args });

return returnValue;
};
}

function makeNewSyntaxHandler(match?: string, delay?: string, replacement?: string): Handler {
let invertMatch = false;
let matchRe: RegExp | undefined;
if (typeof match === 'string' && match.length > 0) {
invertMatch = match[0] === '!';
if (invertMatch) {
match = match.slice(1);
}

matchRe = (parseRegexpLiteral(match) || parseRegexpFromString(match)) ?? undefined;
if (matchRe === undefined) {
throw new Error('Could not parse match');
}
}

let parsedDelaySeconds: number;
if (typeof delay === 'string' && delay.length > 0) {
parsedDelaySeconds = parseValidInt(delay);
}

if (typeof replacement === 'string' && replacement !== 'obj' && replacement !== 'blank') {
throw new Error(`Replacement type ${replacement} not supported`);
}

return (target, thisArg, args: Parameters<typeof window.open>) => {
let url: string;
if (args.length === 0 || args[0] == undefined) {
// This is a valid case.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/open#url
url = '';
} else if (typeof args[0] === 'string') {
url = args[0];
} else if (args[0] instanceof URL) {
url = args[0].toString();
} else {
// Bad input, let the original function handle it.
return Reflect.apply(target, thisArg, args);
}

if (matchRe !== undefined) {
let prevent = matchRe.test(url);
if (invertMatch) {
prevent = !prevent;
}
if (!prevent) {
return Reflect.apply(target, thisArg, args);
}
}

logger.info('Preventing window.open', { args });

if (replacement === 'blank') {
return Reflect.apply(target, thisArg, ['about:blank', ...args.slice(1)]);
}

let decoy: HTMLObjectElement | HTMLIFrameElement;
switch (replacement) {
case 'obj':
decoy = document.createElement('object');
decoy.data = url;
break;
default:
decoy = document.createElement('iframe');
decoy.src = url;
}
// Move the element far off-screen.
decoy.style.setProperty('height', '1px', 'important');
decoy.style.setProperty('width', '1px', 'important');
decoy.style.setProperty('position', 'absolute', 'important');
decoy.style.setProperty('top', '-9999px', 'important');
document.body.appendChild(decoy);
if (parsedDelaySeconds !== undefined) {
setTimeout(() => {
decoy.remove();
}, parsedDelaySeconds * 1000);
}

let fakeWindow: WindowProxy | null;
switch (replacement) {
case 'obj':
fakeWindow = decoy.contentWindow;
if (fakeWindow === null || typeof fakeWindow !== 'object') {
return null;
}
Object.defineProperties(fakeWindow, {
closed: { value: false },
opener: { value: window },
frameElement: { value: null },
});
break;
default:
// We do not end up using the decoy here, which replicates the behavior of uBo and AdGuard.
// Creating an iframe is likely still essential, either because triggering the URL
// has some significance in the application's logic or because it helps bypass anti-adblock detections.
//
// Below we follow uBo's approach of creating a fake WindowProxy, with a slight modification
// to ignore property assignments:
// - https://github.com/gorhill/uBlock/blob/8629f07138749e7c6088fbfda84a381f2cd3bc66/src/js/resources/scriptlets.js#L2048-L2058
// Also, for reference, see AdGuard's implementation:
// - https://github.com/AdguardTeam/Scriptlets/blob/1324cfab78b9366010e1d9bfe8070dd11dd8421b/src/scriptlets/prevent-window-open.js#L161
fakeWindow = new Proxy(window, {
get: (target, prop, receiver) => {
if (prop === 'closed') {
return false;
}
const r = Reflect.get(target, prop, receiver);
if (typeof r === 'function') {
return () => {};
}
return r;
},
set: () => {
return true;
},
});
}

return fakeWindow;
};
}

0 comments on commit 373d974

Please sign in to comment.