Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: respect aliases in scriptlet exceptions #4152

Closed
Show file tree
Hide file tree
Changes from all 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
9 changes: 7 additions & 2 deletions packages/adblocker/src/engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,10 @@ export default class FilterEngine extends EventEmitter<EngineEventHandlers> {
) {
injectionsDisabled = true;
}
unhideExceptions.set(unhide.getSelector(), unhide);
unhideExceptions.set(
unhide.getNormalizedSelector(this.resources.js) ?? unhide.getSelector(),
unhide,
);
}

const injections: CosmeticFilter[] = [];
Expand All @@ -894,7 +897,9 @@ export default class FilterEngine extends EventEmitter<EngineEventHandlers> {
// Apply unhide rules + dispatch
for (const filter of filters) {
// Make sure `rule` is not un-hidden by a #@# filter
const exception = unhideExceptions.get(filter.getSelector());
const exception = unhideExceptions.get(
filter.getNormalizedSelector(this.resources.js) ?? filter.getSelector(),
);

if (exception !== undefined) {
continue;
Expand Down
28 changes: 25 additions & 3 deletions packages/adblocker/src/filters/cosmetic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from '../utils.js';
import IFilter from './interface.js';
import { HTMLSelector, extractHTMLSelectorFromRule } from '../html-filtering.js';
import { Resource } from '../resources.js';

const EMPTY_TOKENS: [Uint32Array] = [EMPTY_UINT32_ARRAY];
export const DEFAULT_HIDDING_STYLE: string = 'display: none !important;';
Expand Down Expand Up @@ -774,16 +775,17 @@ export default class CosmeticFilter implements IFilter {
return { name: parts[0], args };
}

public getScript(js: Map<string, string>): string | undefined {
public getScript(js: Map<string, Resource>): string | undefined {
const parsed = this.parseScript();
if (parsed === undefined) {
return undefined;
}

const { name, args } = parsed;

let script = js.get(name);
if (script !== undefined) {
const resource = js.get(name);
if (resource !== undefined) {
let script = resource.body;
for (let i = 0; i < args.length; i += 1) {
// escape some characters so they wont get evaluated with escape characters during script injection
const arg = args[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
Expand All @@ -796,6 +798,26 @@ export default class CosmeticFilter implements IFilter {
return undefined;
}

public getNormalizedSelector(js: Map<string, Resource>): string | undefined {
if (this.isScriptInject() === false) {
return undefined;
}

const selector = this.getSelector();

let firstCommaIndex = selector.indexOf(',');
if (firstCommaIndex === -1) {
firstCommaIndex = selector.length;
}

const originResourceName = js.get(selector.slice(0, firstCommaIndex))?.aliasOf;
if (originResourceName === undefined) {
return undefined;
}

return originResourceName + selector.slice(firstCommaIndex);
}

public hasHostnameConstraint(): boolean {
return this.domains !== undefined;
}
Expand Down
123 changes: 81 additions & 42 deletions packages/adblocker/src/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { getResourceForMime } from '@remusao/small';

import { StaticDataView, sizeOfUTF8, sizeOfASCII, sizeOfByte } from './data-view.js';
import { StaticDataView, sizeOfUTF8, sizeOfASCII, sizeOfByte, sizeOfBool } from './data-view.js';

// Polyfill for `btoa`
function btoaPolyfill(buffer: string): string {
Expand All @@ -20,12 +20,12 @@ function btoaPolyfill(buffer: string): string {
return buffer;
}

interface Resource {
export interface Resource {
contentType: string;
body: string;
aliasOf?: string | undefined;
}

// TODO - support # alias
// TODO - support empty resource body

/**
Expand All @@ -41,17 +41,42 @@ export default class Resources {
const resources: Map<string, Resource> = new Map();
const numberOfResources = buffer.getUint16();
for (let i = 0; i < numberOfResources; i += 1) {
resources.set(buffer.getASCII(), {
contentType: buffer.getASCII(),
body: buffer.getUTF8(),
});
const name = buffer.getASCII();
const isAlias = buffer.getBool();
if (isAlias === true) {
resources.set(name, {
contentType: '',
body: '',
aliasOf: buffer.getASCII(),
});
} else {
resources.set(name, {
contentType: buffer.getASCII(),
body: buffer.getUTF8(),
});
}
}

// Fill aliases after deserializing everything
for (const resource of resources.values()) {
if (resource.aliasOf === undefined) {
continue;
}

const origin = resources.get(resource.aliasOf);
if (origin === undefined) {
continue;
}

resource.body = origin.body;
resource.contentType = origin.contentType;
}

// Deserialize `js`
const js: Map<string, string> = new Map();
resources.forEach(({ contentType, body }, name) => {
if (contentType === 'application/javascript') {
js.set(name, body);
const js: Map<string, Resource> = new Map();
resources.forEach((resource, name) => {
if (resource.contentType === 'application/javascript') {
js.set(name, resource);
}
});

Expand All @@ -63,7 +88,7 @@ export default class Resources {
}

public static parse(data: string, { checksum }: { checksum: string }): Resources {
const typeToResource: Map<string, Map<string, string>> = new Map();
const resources: Map<string, Resource> = new Map();
const trimComments = (str: string) => str.replace(/^\s*#.*$/gm, '');
const chunks = data.split('\n\n');

Expand All @@ -72,52 +97,55 @@ export default class Resources {
if (resource.length !== 0) {
const firstNewLine = resource.indexOf('\n');
const split = resource.slice(0, firstNewLine).split(/\s+/);
const name = split[0];
const type = split[1];
const [name, type] = split;
const aliases = (split[2] || '')
.split(',')
.map((alias) => alias.trim())
.filter((alias) => alias.length !== 0);
const body = resource.slice(firstNewLine + 1);

if (name === undefined || type === undefined || body === undefined) {
continue;
}

let resources = typeToResource.get(type);
if (resources === undefined) {
resources = new Map();
typeToResource.set(type, resources);
resources.set(name, {
contentType: type,
body,
});
for (const alias of aliases) {
resources.set(alias, {
contentType: type,
body,
aliasOf: name,
});
}
if (type === 'application/javascript' && name.endsWith('.js')) {
resources.set(name.slice(0, -3), {
contentType: type,
body,
aliasOf: name,
});
}
resources.set(name, body);
}
}

// The resource containing javascirpts to be injected
const js: Map<string, string> = typeToResource.get('application/javascript') || new Map();
for (const [key, value] of js.entries()) {
if (key.endsWith('.js')) {
js.set(key.slice(0, -3), value);
const js: Map<string, Resource> = new Map();
for (const [name, resource] of resources.entries()) {
if (resource.contentType === 'application/javascript') {
js.set(name, resource);
}
}

// Create a mapping from resource name to { contentType, data }
// used for request redirection.
const resourcesByName: Map<string, Resource> = new Map();
typeToResource.forEach((resources, contentType) => {
resources.forEach((resource: string, name: string) => {
resourcesByName.set(name, {
contentType,
body: resource,
});
});
});

return new Resources({
checksum,
js,
resources: resourcesByName,
resources,
});
}

public readonly checksum: string;
public readonly js: Map<string, string>;
public readonly js: Map<string, Resource>;
public readonly resources: Map<string, Resource>;

constructor({ checksum = '', js = new Map(), resources = new Map() }: Partial<Resources> = {}) {
Expand All @@ -142,8 +170,13 @@ export default class Resources {
public getSerializedSize(): number {
let estimatedSize = sizeOfASCII(this.checksum) + 2 * sizeOfByte(); // resources.size

this.resources.forEach(({ contentType, body }, name) => {
estimatedSize += sizeOfASCII(name) + sizeOfASCII(contentType) + sizeOfUTF8(body);
this.resources.forEach(({ contentType, body, aliasOf }, name) => {
estimatedSize += sizeOfASCII(name) + sizeOfBool();
if (aliasOf === undefined) {
estimatedSize += sizeOfASCII(contentType) + sizeOfUTF8(body);
} else {
estimatedSize += sizeOfASCII(aliasOf);
}
});

return estimatedSize;
Expand All @@ -155,10 +188,16 @@ export default class Resources {

// Serialize `resources`
buffer.pushUint16(this.resources.size);
this.resources.forEach(({ contentType, body }, name) => {
this.resources.forEach(({ contentType, body, aliasOf }, name) => {
buffer.pushASCII(name);
buffer.pushASCII(contentType);
buffer.pushUTF8(body);
const isAlias = aliasOf !== undefined;
buffer.pushBool(aliasOf !== undefined);
if (isAlias) {
buffer.pushASCII(aliasOf);
} else {
buffer.pushASCII(contentType);
buffer.pushUTF8(body);
}
});
}
}
51 changes: 42 additions & 9 deletions packages/adblocker/test/engine/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,13 +387,14 @@ $csp=baz,domain=bar.com
url: 'https://foo.com',
});

const createEngineWithResource = (filters: string[], resource: string) => {
const createEngineWithResource = (filters: string[], resourceBody: string) => {
const engine = createEngine(filters.join('\n'));
engine.resources.js.set(resource, resource);
engine.resources.resources.set(resource, {
body: resource,
const resource = {
body: resourceBody,
contentType: 'application/javascript',
});
};
engine.resources.js.set(resourceBody, resource);
engine.resources.resources.set(resourceBody, resource);
return engine;
};

Expand Down Expand Up @@ -713,7 +714,10 @@ foo.com###selector

it('disabling specific hides does not impact scriptlets', () => {
const engine = Engine.parse(['@@||foo.com^$specifichide', 'foo.com##+js(foo)'].join('\n'));
engine.resources.js.set('foo', '');
engine.resources.js.set('foo', {
contentType: 'application/javascript',
body: '',
});
expect(
engine.getCosmeticsFilters({
domain: 'foo.com',
Expand Down Expand Up @@ -882,6 +886,20 @@ foo.com###selector
injections: [],
matches: [],
},
{
filters: ['foo.com##+js(alias)', 'foo.com#@#+js(scriptlet)'],
hostname: 'foo.com',
hrefs: [],
injections: [],
matches: [],
},
{
filters: ['foo.com##+js(scriptlet)', 'foo.com#@#+js(alias)'],
hostname: 'foo.com',
hrefs: [],
injections: [],
matches: [],
},

// = unhide +js() disable
{
Expand Down Expand Up @@ -1300,9 +1318,24 @@ foo.com###selector
it(JSON.stringify({ filters, hostname, matches, injections }), () => {
// Initialize engine with all rules from test case
const engine = createEngine(filters.join('\n'));
engine.resources.js.set('scriptlet', 'scriptlet');
engine.resources.js.set('scriptlet1', 'scriptlet1');
engine.resources.js.set('scriptlet2', 'scriptlet2');
engine.resources.js.set('scriptlet', {
contentType: 'application/javascript',
body: 'scriptlet',
});
engine.resources.js.set('scriptlet1', {
contentType: 'application/javascript',
body: 'scriptlet1',
});
engine.resources.js.set('scriptlet2', {
contentType: 'application/javascript',
body: 'scriptlet2',
});

engine.resources.js.set('alias', {
contentType: 'application/javascript',
body: 'scriptlet',
aliasOf: 'scriptlet',
});

// #getCosmeticsFilters
const { styles, scripts } = engine.getCosmeticsFilters({
Expand Down
Loading