From bdee8a545bc2405676df7d95c602ae0f3a8aed8f Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Sat, 17 Oct 2026 00:06:33 +0800 Subject: [PATCH] Register blocking header rewrites only on manifest v2 Skip ineffective Bing and Claude listeners on manifest v3, where the existing DNR rules handle header rewrites. Preserve manifest v2 rewrites, remove per-request debug logging, and handle rewrite failures consistently. Add tests for listener registration, rewrites, and DNR rule consistency. --- src/background/index.mjs | 69 +-- src/background/webrequest-listeners.mjs | 118 +++++ .../background/webrequest-listeners.test.mjs | 429 ++++++++++++++++++ 3 files changed, 551 insertions(+), 65 deletions(-) create mode 100644 src/background/webrequest-listeners.mjs create mode 100644 tests/unit/background/webrequest-listeners.test.mjs diff --git a/src/background/index.mjs b/src/background/index.mjs index 79b4f0c0d..a15fd9d92 100644 --- a/src/background/index.mjs +++ b/src/background/index.mjs @@ -46,6 +46,7 @@ import { } from '../services/wrappers.mjs' import { refreshMenu } from './menus.mjs' import { registerCommands } from './commands.mjs' +import { registerBlockingHeaderListeners } from './webrequest-listeners.mjs' import { generateAnswersWithBardWebApi } from '../services/apis/bard-web.mjs' import { generateAnswersWithClaudeWebApi } from '../services/apis/claude-web.mjs' import { generateAnswersWithMoonshotWebApi } from '../services/apis/moonshot-web.mjs' @@ -879,71 +880,9 @@ try { ['requestBody'], ) - Browser.webRequest.onBeforeSendHeaders.addListener( - (details) => { - try { - console.debug('[background] onBeforeSendHeaders triggered for URL:', details.url) - const headers = details.requestHeaders - let modified = false - for (let i = 0; i < headers.length; i++) { - const header = headers[i] - if (!header || !header.name) { - continue - } - const headerNameLower = header.name.toLowerCase() - if (headerNameLower === 'origin') { - header.value = 'https://www.bing.com' - modified = true - } else if (headerNameLower === 'referer') { - header.value = 'https://www.bing.com/search?q=Bing+AI&showconv=1&FORM=hpcodx' - modified = true - } - } - if (modified) { - console.debug( - '[background] Modified headers for Bing (names only):', - headers.map((header) => header?.name).filter(Boolean), - ) - } - return { requestHeaders: headers } - } catch (error) { - console.error( - '[background] Error in onBeforeSendHeaders listener callback:', - error, - details, - ) - return { requestHeaders: details.requestHeaders } - } - }, - { - urls: ['wss://sydney.bing.com/*', 'https://www.bing.com/*'], - types: ['xmlhttprequest', 'websocket'], - }, - ['requestHeaders', ...(Browser.runtime.getManifest().manifest_version < 3 ? ['blocking'] : [])], - ) - - Browser.webRequest.onBeforeSendHeaders.addListener( - (details) => { - const headers = details.requestHeaders - for (let i = 0; i < headers.length; i++) { - const header = headers[i] - if (!header || !header.name) { - continue - } - const headerNameLower = header.name.toLowerCase() - if (headerNameLower === 'origin') { - header.value = 'https://claude.ai' - } else if (headerNameLower === 'referer') { - header.value = 'https://claude.ai' - } - } - return { requestHeaders: headers } - }, - { - urls: ['https://claude.ai/*'], - types: ['xmlhttprequest'], - }, - ['requestHeaders', ...(Browser.runtime.getManifest().manifest_version < 3 ? ['blocking'] : [])], + registerBlockingHeaderListeners( + Browser.webRequest, + Browser.runtime.getManifest().manifest_version, ) Browser.tabs.onUpdated.addListener(async (tabId, info, tab) => { diff --git a/src/background/webrequest-listeners.mjs b/src/background/webrequest-listeners.mjs new file mode 100644 index 000000000..a3d9ac248 --- /dev/null +++ b/src/background/webrequest-listeners.mjs @@ -0,0 +1,118 @@ +export const bingHeaderRewrites = { + origin: 'https://www.bing.com', + referer: 'https://www.bing.com/search?q=Bing+AI&showconv=1&FORM=hpcodx', +} + +export const claudeHeaderRewrites = { + origin: 'https://claude.ai', + referer: 'https://claude.ai', +} + +const rewriteHeaders = (headers, rewrites) => { + let modified = false + for (let i = 0; i < headers.length; i++) { + const header = headers[i] + if (!header || !header.name) { + continue + } + const headerNameLower = header.name.toLowerCase() + if (headerNameLower === 'origin') { + header.value = rewrites.origin + modified = true + } else if (headerNameLower === 'referer') { + header.value = rewrites.referer + modified = true + } + } + return modified +} + +const createHeaderRewriteListener = (label, rewrites, logModified) => (details) => { + // The browser hands out an array, so anything else means there is nothing + // safe to rewrite; returning nothing leaves the request untouched instead of + // handing the browser a requestHeaders value it would reject. + if (!details || !Array.isArray(details.requestHeaders)) { + return undefined + } + try { + const modified = rewriteHeaders(details.requestHeaders, rewrites) + if (!modified) return undefined + if (logModified) { + console.debug( + `[background] Modified headers for ${label} (names only):`, + details.requestHeaders.map((header) => header?.name).filter(Boolean), + ) + } + return { requestHeaders: details.requestHeaders } + } catch (error) { + // Only the URL is logged: `details` carries the full request headers, whose + // values must never reach the logs. + console.error( + `[background] Error in ${label} onBeforeSendHeaders listener callback:`, + error, + details?.url, + ) + // Discard the partial rewrite by returning no modification. The request + // continues without applying this listener's rewritten headers. + return undefined + } +} + +/** + * Register the blocking onBeforeSendHeaders header rewrite listeners for Bing + * and Claude. + * + * This project's manifest v2 grants `webRequestBlocking` and declares no DNR + * rules. Its manifest v3 omits that permission and uses rules 1 and 3 in + * rules.json to set Origin/Referer for Bing and Claude instead. + * + * The two mechanisms have different coverage: + * - DNR matches `requestDomains` on any scheme and on sub-domains, so rule 1 + * also rewrites requests the manifest v2 filters never match, such as + * `https://sydney.bing.com/*` or `wss://www.bing.com/*`, and rule 3 likewise + * covers sub-domains such as `https://api.claude.ai/*` on manifest v3. + * - DNR's `set` operation also adds a header when the request has none, while + * these listeners only rewrite the entries a request already carries. + * + * Both listeners log rewrite errors and return no modification on failure or + * invalid header arrays, leaving the request to continue. Bing additionally + * logs header names after successful rewrites; header values are not logged. + * + * `extraHeaders` is deliberately absent from the extraInfoSpec even though + * Chromium hides `Origin`/`Referer` from `onBeforeSendHeaders` without it: + * Firefox rejects the value outright ("Invalid enumeration value"), and these + * listeners only register on manifest v2, which is what manifest.v2.json and + * the Safari build (derived from the Firefox build) ship. Manifest v3 targets + * are served by the DNR rules, which do rewrite the headers. + * + * @param {object} webRequest the `Browser.webRequest` API object + * @param {number} manifestVersion the active manifest version + * @returns {number} how many listeners were registered + */ +export function registerBlockingHeaderListeners(webRequest, manifestVersion) { + if (manifestVersion >= 3) return 0 + // A manifest that grants neither webRequest nor webRequestBlocking would have + // no API here; the caller reports that through its own try/catch. + const onBeforeSendHeaders = webRequest?.onBeforeSendHeaders + if (typeof onBeforeSendHeaders?.addListener !== 'function') return 0 + + onBeforeSendHeaders.addListener( + createHeaderRewriteListener('Bing', bingHeaderRewrites, true), + { + urls: ['wss://sydney.bing.com/*', 'https://www.bing.com/*'], + types: ['xmlhttprequest', 'websocket'], + }, + ['requestHeaders', 'blocking'], + ) + + onBeforeSendHeaders.addListener( + createHeaderRewriteListener('Claude', claudeHeaderRewrites, false), + { + urls: ['https://claude.ai/*'], + types: ['xmlhttprequest'], + }, + ['requestHeaders', 'blocking'], + ) + + return 2 +} diff --git a/tests/unit/background/webrequest-listeners.test.mjs b/tests/unit/background/webrequest-listeners.test.mjs new file mode 100644 index 000000000..09bec5e1f --- /dev/null +++ b/tests/unit/background/webrequest-listeners.test.mjs @@ -0,0 +1,429 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { + bingHeaderRewrites, + claudeHeaderRewrites, + registerBlockingHeaderListeners, +} from '../../../src/background/webrequest-listeners.mjs' + +const readSrcJson = (relativePath) => + JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')) + +const createRecordingWebRequest = () => { + const registrations = [] + return { + registrations, + webRequest: { + onBeforeSendHeaders: { + addListener: (listener, filter, extraInfoSpec) => { + registrations.push({ listener, filter, extraInfoSpec }) + }, + }, + }, + } +} + +const registerManifestV2Listeners = () => { + const { registrations, webRequest } = createRecordingWebRequest() + const count = registerBlockingHeaderListeners(webRequest, 2) + return { registrations, count } +} + +const headerValue = (headers, name) => + headers.find((header) => header?.name?.toLowerCase() === name)?.value + +const hostFromUrlPattern = (pattern) => pattern.match(/^[a-z]+:\/\/([^/]+)/i)[1] + +// Look listeners up by the host their filter matches, so the tests do not +// depend on the registration order of the two addListener calls. +const registrationFor = (registrations, host) => { + const registration = registrations.find((candidate) => + candidate.filter.urls.some((pattern) => hostFromUrlPattern(pattern) === host), + ) + assert.ok(registration, `expected a registered listener for ${host}`) + return registration +} + +const bingRegistration = (registrations) => registrationFor(registrations, 'www.bing.com') +const claudeRegistration = (registrations) => registrationFor(registrations, 'claude.ai') + +test('registerBlockingHeaderListeners registers nothing on manifest v3 and above', () => { + for (const manifestVersion of [3, 4]) { + const { registrations, webRequest } = createRecordingWebRequest() + const count = registerBlockingHeaderListeners(webRequest, manifestVersion) + assert.equal(count, 0) + assert.equal(registrations.length, 0) + } +}) + +test('registerBlockingHeaderListeners registers nothing without the webRequest API', () => { + for (const webRequest of [undefined, null, {}, { onBeforeSendHeaders: {} }]) { + assert.equal(registerBlockingHeaderListeners(webRequest, 2), 0) + } +}) + +test('registerBlockingHeaderListeners registers both blocking listeners on manifest v2', () => { + const { registrations, count } = registerManifestV2Listeners() + assert.equal(count, registrations.length) + assert.equal(count, 2) + + for (const registration of registrations) { + assert.deepEqual(registration.extraInfoSpec, ['requestHeaders', 'blocking']) + // Chromium hides Origin/Referer unless `extraHeaders` is requested, but + // Firefox throws on that value and manifest v2 is served to Firefox (and + // to Safari via the Firefox build), so it must stay out. + assert.equal(registration.extraInfoSpec.includes('extraHeaders'), false) + } + assert.deepEqual(bingRegistration(registrations).filter, { + urls: ['wss://sydney.bing.com/*', 'https://www.bing.com/*'], + types: ['xmlhttprequest', 'websocket'], + }) + assert.deepEqual(claudeRegistration(registrations).filter, { + urls: ['https://claude.ai/*'], + types: ['xmlhttprequest'], + }) +}) + +test('Bing listener rewrites existing origin and referer headers', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'debug', () => {}) + const { registrations } = registerManifestV2Listeners() + + const headers = [ + { name: 'Origin', value: 'https://example.com' }, + { name: 'Referer', value: 'https://example.com/page' }, + { name: 'Accept', value: '*/*' }, + ] + const result = bingRegistration(registrations).listener({ + url: 'https://www.bing.com/', + requestHeaders: headers, + }) + + assert.equal(headerValue(result.requestHeaders, 'origin'), bingHeaderRewrites.origin) + assert.equal(headerValue(result.requestHeaders, 'referer'), bingHeaderRewrites.referer) + assert.equal(headerValue(result.requestHeaders, 'accept'), '*/*') + assert.equal(consoleError.mock.callCount(), 0) +}) + +test('Claude listener rewrites existing origin and referer headers', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'debug', () => {}) + const { registrations } = registerManifestV2Listeners() + + const headers = [ + { name: 'origin', value: 'https://example.com' }, + { name: 'referer', value: 'https://example.com/page' }, + ] + const result = claudeRegistration(registrations).listener({ + url: 'https://claude.ai/', + requestHeaders: headers, + }) + + assert.equal(headerValue(result.requestHeaders, 'origin'), claudeHeaderRewrites.origin) + assert.equal(headerValue(result.requestHeaders, 'referer'), claudeHeaderRewrites.referer) + assert.equal(consoleError.mock.callCount(), 0) +}) + +test('listeners neither touch nor add headers the request does not have', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'debug', () => {}) + const { registrations } = registerManifestV2Listeners() + + // Documented difference: the manifest v3 DNR rules use `set`, which also adds + // Origin/Referer when the request has none, while these manifest v2 listeners + // only replace existing headers. Only the manifest v2 side is asserted here, + // because the v3 side is enforced declaratively by rules.json. + for (const registration of registrations) { + const headers = [{ name: 'Accept', value: '*/*' }] + const result = registration.listener({ url: 'https://example.com/', requestHeaders: headers }) + // Nothing was rewritten, so no header set is handed back at all. + assert.equal(result, undefined) + assert.deepEqual(headers, [{ name: 'Accept', value: '*/*' }]) + } + assert.equal(consoleError.mock.callCount(), 0) +}) + +test('a listener called without details reports no modification', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'debug', () => {}) + const { registrations } = registerManifestV2Listeners() + + for (const registration of registrations) { + assert.equal(registration.listener(), undefined) + } + assert.equal(consoleError.mock.callCount(), 0) +}) + +test('a partial rewrite is discarded when a later header cannot be written', (t) => { + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'debug', () => {}) + const { registrations } = registerManifestV2Listeners() + + for (const [host, rewrites] of [ + ['www.bing.com', bingHeaderRewrites], + ['claude.ai', claudeHeaderRewrites], + ]) { + const headers = [ + { name: 'Origin', value: 'https://example.com' }, + Object.freeze({ name: 'Referer', value: 'https://example.com/page' }), + ] + const result = registrationFor(registrations, host).listener({ + url: `https://${host}/`, + requestHeaders: headers, + }) + // The rewrite happens in place, so the first header is already changed when + // the second one throws. Returning nothing is what makes the browser keep + // its own copy of the original header set instead of the partial one. + assert.equal(result, undefined) + assert.equal(headers[0].value, rewrites.origin) + assert.equal(headers[1].value, 'https://example.com/page') + } +}) + +test('listeners report no modification when requestHeaders is missing', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'debug', () => {}) + const { registrations } = registerManifestV2Listeners() + + for (const registration of registrations) { + // Returning nothing means "leave the request unmodified", which is the + // valid shape a blocking listener returns when it has no headers to + // rewrite. + assert.equal(registration.listener({ url: 'https://example.com/' }), undefined) + } + assert.equal(consoleError.mock.callCount(), 0) +}) + +test('only the Bing listener logs the modified header names', (t) => { + const consoleDebug = t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'error', () => {}) + const { registrations } = registerManifestV2Listeners() + + const headers = [ + { name: 'Origin', value: 'https://example.com' }, + { name: 'Referer', value: 'https://example.com/page' }, + ] + for (const registration of registrations) { + registration.listener({ + url: 'https://example.com/', + requestHeaders: structuredClone(headers), + }) + } + + assert.equal(consoleDebug.mock.callCount(), 1) + const loggedArguments = consoleDebug.mock.calls[0].arguments + assert.equal(loggedArguments[0], '[background] Modified headers for Bing (names only):') + assert.deepEqual(loggedArguments[1], ['Origin', 'Referer']) + // Exactly the message and the header names: nothing else may be logged. + assert.equal(loggedArguments.length, 2) + const logged = JSON.stringify(loggedArguments) + for (const value of ['https://example.com', 'https://example.com/page']) { + assert.equal(logged.includes(value), false, `header value ${value} must not be logged`) + } +}) + +test('listeners skip header entries without a name', (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'error', () => {}) + const { registrations } = registerManifestV2Listeners() + + for (const [host, rewrites] of [ + ['www.bing.com', bingHeaderRewrites], + ['claude.ai', claudeHeaderRewrites], + ]) { + const result = registrationFor(registrations, host).listener({ + url: `https://${host}/`, + requestHeaders: [null, { value: 'orphan' }, { name: 'Origin', value: 'https://example.com' }], + }) + assert.deepEqual(result.requestHeaders, [ + null, + { value: 'orphan' }, + { name: 'Origin', value: rewrites.origin }, + ]) + } +}) + +test('listeners report no modification when rewriting throws', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'debug', () => {}) + const { registrations } = registerManifestV2Listeners() + + for (const registration of registrations) { + // A frozen header makes the in-place value assignment throw. + const result = registration.listener({ + url: 'https://example.com/failing-rewrite', + requestHeaders: [Object.freeze({ name: 'Origin', value: 'https://header-value.example' })], + }) + assert.equal(result, undefined, 'a failed rewrite must leave the request untouched') + } + + assert.equal(consoleError.mock.callCount(), registrations.length) + for (const call of consoleError.mock.calls) { + // String(argument) as well as JSON.stringify, because a value that only + // reaches the log through an Error message would serialize as {}. + const logged = call.arguments + .map((argument) => `${JSON.stringify(argument)}${String(argument)}`) + .join(' ') + assert.ok( + logged.includes('https://example.com/failing-rewrite'), + 'the request URL should be logged', + ) + assert.equal( + logged.includes('https://header-value.example'), + false, + 'header values must not be logged', + ) + } +}) + +test('manifest v2 relies on blocking listeners and manifest v3 on the DNR ruleset', () => { + const manifestV2 = readSrcJson('../../../src/manifest.v2.json') + const manifestV3 = readSrcJson('../../../src/manifest.json') + + assert.equal(manifestV2.manifest_version, 2) + assert.ok(manifestV2.permissions.includes('webRequest')) + assert.ok(manifestV2.permissions.includes('webRequestBlocking')) + assert.equal(manifestV2.declarative_net_request, undefined) + + assert.equal(manifestV3.manifest_version, 3) + // Without `webRequestBlocking` a manifest v3 listener could never rewrite a + // header, which is the premise for skipping the JS listeners there, and + // without a declarativeNetRequest permission the ruleset below would be inert. + assert.equal(manifestV3.permissions.includes('webRequestBlocking'), false) + assert.ok( + manifestV3.permissions.some((permission) => + ['declarativeNetRequest', 'declarativeNetRequestWithHostAccess'].includes(permission), + ), + ) + const rulesets = manifestV3.declarative_net_request?.rule_resources ?? [] + assert.deepEqual( + rulesets.map((ruleResource) => ruleResource.path), + ['rules.json'], + ) + assert.ok(rulesets.every((ruleResource) => ruleResource.enabled)) +}) + +test('declarative rules keep the manifest v2 Bing and Claude rewrites in sync', () => { + const rules = readSrcJson('../../../src/rules.json') + const { registrations } = registerManifestV2Listeners() + const bing = bingRegistration(registrations) + const claude = claudeRegistration(registrations) + + const ruleFor = (hosts, resourceTypes) => + rules.find( + (rule) => + Array.isArray(rule.condition?.requestDomains) && + Array.isArray(rule.condition?.resourceTypes) && + hosts.every((host) => rule.condition.requestDomains.includes(host)) && + resourceTypes.every((type) => rule.condition.resourceTypes.includes(type)), + ) + + const bingHosts = bing.filter.urls.map(hostFromUrlPattern) + const claudeHosts = claude.filter.urls.map(hostFromUrlPattern) + const bingRule = ruleFor(bingHosts, bing.filter.types) + const claudeRule = ruleFor(claudeHosts, claude.filter.types) + assert.ok(bingRule, 'a rules.json rule must cover every registered Bing host') + assert.ok(claudeRule, 'a rules.json rule must cover every registered Claude host') + assert.equal(bingRule.id, 1, 'rules.json must keep the Bing rule at id 1') + assert.equal(claudeRule.id, 3, 'rules.json must keep the Claude rule at id 3') + + for (const [rule, registration] of [ + [bingRule, bing], + [claudeRule, claude], + ]) { + const hosts = registration.filter.urls.map(hostFromUrlPattern) + for (const domain of rule.condition.requestDomains) { + assert.ok( + hosts.includes(domain), + `rule ${rule.id} covers ${domain}, which no manifest v2 listener filter matches`, + ) + } + for (const resourceType of rule.condition.resourceTypes) { + assert.ok( + registration.filter.types.includes(resourceType), + `rule ${rule.id} covers ${resourceType}, which no manifest v2 listener filter matches`, + ) + } + // The DNR rules must stay domain-based: a filter, a method, an initiator, a + // header condition, a top-level domain or an exclusion would narrow them + // below the manifest v2 scheme and host set. The list is the narrowing half + // of declarativeNetRequest.RuleCondition, as documented on MDN, plus the + // top-level-domain conditions Chromium adds. Only + // requestDomains and resourceTypes are the keys the listener filters are + // compared against, so they are absent here by design. + const narrowingConditions = [ + 'urlFilter', + 'regexFilter', + 'isUrlFilterCaseSensitive', + 'requestMethods', + 'excludedRequestMethods', + 'domainType', + 'domains', + 'excludedDomains', + 'initiatorDomains', + 'excludedInitiatorDomains', + 'excludedRequestDomains', + 'excludedResourceTypes', + 'topDomains', + 'excludedTopDomains', + 'responseHeaders', + 'excludedResponseHeaders', + 'tabIds', + 'excludedTabIds', + ] + for (const key of narrowingConditions) { + assert.equal( + rule.condition[key], + undefined, + `rule ${rule.id} must not narrow its coverage with ${key}`, + ) + } + } + + const headerValueInRule = (rule, header) => { + const entry = rule.action.requestHeaders.find((candidate) => candidate.header === header) + // DNR's `set` operation applies the intended values and also adds absent + // headers. These listeners only replace existing headers; that difference + // is documented in webrequest-listeners.mjs and covered by the + // "listeners neither touch nor add headers the request does not have" test. + // The sync is deliberately partial: the chatgpt.com rule has no manifest v2 + // listener counterpart, because ChatGPT web requests run in a real tab that + // already sends an acceptable Origin. + assert.equal(entry?.operation, 'set') + return entry?.value + } + + for (const rule of [bingRule, claudeRule]) { + assert.equal(rule.action.type, 'modifyHeaders') + assert.deepEqual( + rule.action.requestHeaders.map((entry) => entry.header).sort(), + ['origin', 'referer'], + `rule ${rule.id} must only rewrite Origin and Referer`, + ) + assert.ok( + rule.action.requestHeaders.every((entry) => entry.operation === 'set'), + `rule ${rule.id} must only set headers`, + ) + } + + assert.equal(headerValueInRule(bingRule, 'origin'), bingHeaderRewrites.origin) + assert.equal(headerValueInRule(bingRule, 'referer'), bingHeaderRewrites.referer) + assert.equal(headerValueInRule(claudeRule, 'origin'), claudeHeaderRewrites.origin) + assert.equal(headerValueInRule(claudeRule, 'referer'), claudeHeaderRewrites.referer) +}) + +test('the background entry point routes header rewrites through the gated helper', () => { + const backgroundSource = readFileSync( + new URL('../../../src/background/index.mjs', import.meta.url), + 'utf8', + ) + + // Keep registration in the version-gated helper and pass the active version. + assert.equal(backgroundSource.includes('Browser.webRequest.onBeforeSendHeaders'), false) + const registrationPattern = new RegExp( + String.raw`registerBlockingHeaderListeners\(\s*Browser\.webRequest,\s*` + + String.raw`Browser\.runtime\.getManifest\(\)\.manifest_version,?\s*\)`, + ) + assert.match(backgroundSource, registrationPattern) +})