diff --git a/manifest.json b/manifest.json index 62a3d0f1..2fec239c 100644 --- a/manifest.json +++ b/manifest.json @@ -32,7 +32,6 @@ }, "default_title": "__MSG_manifest_action_default_title__" }, - "commands": { "renew-useragent": { "description": "__MSG_manifest_command_renew_useragent__", diff --git a/src/entrypoints/background/api/filters.ts b/src/entrypoints/background/api/filters.ts index e7e7b644..1aed4e2a 100644 --- a/src/entrypoints/background/api/filters.ts +++ b/src/entrypoints/background/api/filters.ts @@ -1,13 +1,8 @@ import Rule = chrome.declarativeNetRequest.Rule -import type { ContentScriptPayload, ReadonlySettingsState, ReadonlyUserAgentState } from '~/shared/types' -import { setBridgeData, setRequestHeaders, unsetRequestHeaders } from '../hooks' +import type { ReadonlySettingsState, ReadonlyUserAgentState } from '~/shared/types' +import { setRequestHeaders, unsetRequestHeaders } from '../hooks' -/** - * Returns true if the extension is applicable for the given domain name. - * - * Note: Keep this function implementation in sync with the `isApplicableToDomain()` function (which is defined in the - * `content/content.ts` file). - */ +/** Returns true if the extension is applicable for the given domain name. */ export async function isApplicableForDomain(settings: ReadonlySettingsState, domain: string): Promise { const isInList = settings.blacklist.domains.some((item): boolean => item === domain || domain.endsWith(`.${item}`)) @@ -24,24 +19,17 @@ export async function isApplicableForDomain(settings: ReadonlySettingsState, dom export async function reloadRequestHeadersAndBridge( settings: ReadonlySettingsState, current: ReadonlyUserAgentState | undefined -): Promise, ContentScriptPayload | undefined] | void> { +): Promise | void> { if (settings.enabled && current) { - return await Promise.all([ - // if the extension is disabled or current user-agent is not set, we do not need to update the - // browser request headers - setRequestHeaders( - current, - settings.blacklist.mode === 'blacklist' - ? { exceptDomains: settings.blacklist.domains } - : { applyToDomains: settings.blacklist.domains } - ), + // if the extension is disabled or current user-agent is not set, we do not need to update the + // browser request headers + return await setRequestHeaders( + current, + settings.blacklist.mode === 'blacklist' + ? { exceptDomains: settings.blacklist.domains } + : { applyToDomains: settings.blacklist.domains }, settings.jsProtection.enabled - ? setBridgeData(current, { - applyToDomains: settings.blacklist.mode === 'blacklist' ? undefined : settings.blacklist.domains, - exceptDomains: settings.blacklist.mode === 'blacklist' ? settings.blacklist.domains : undefined, - }) - : undefined, - ]) + ) } // otherwise, we need to unset the request headers diff --git a/src/entrypoints/background/hooks/content-script-bridge.ts b/src/entrypoints/background/hooks/content-script-bridge.ts deleted file mode 100644 index edcac014..00000000 --- a/src/entrypoints/background/hooks/content-script-bridge.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ContentScriptPayload, ReadonlyUserAgentState } from '~/shared/types' -import { browserBrands, isMobile, platform } from '~/shared/client-hint' - -/** - * Sets the bridge data to the local storage, which will be used by the content script. In fact, it enables the - * javascript protection. - */ -export const setBridgeData = async ( - ua: ReadonlyUserAgentState, - filter?: { applyToDomains?: ReadonlyArray; exceptDomains?: ReadonlyArray } -): Promise => { - const payload: ContentScriptPayload = { - current: ua, - brands: { - major: (() => { - switch (ua.browser) { - case 'chrome': - return browserBrands('chrome', ua.version.browser.major) - case 'opera': - return browserBrands('opera', ua.version.browser.major, ua.version.underHood?.major || 0) - case 'edge': - return browserBrands('edge', ua.version.browser.major, ua.version.underHood?.major || 0) - } - - return [] - })(), - full: (() => { - switch (ua.browser) { - case 'chrome': - return browserBrands('chrome', ua.version.browser.full) - case 'opera': - return browserBrands('opera', ua.version.browser.full, ua.version.underHood?.full || '') - case 'edge': - return browserBrands('edge', ua.version.browser.full, ua.version.underHood?.full || '') - } - - return [] - })(), - }, - platform: platform(ua.os), - isMobile: isMobile(ua.os), - filtering: { - applyToDomains: filter?.applyToDomains || [], - exceptDomains: filter?.exceptDomains || [], - }, - } - - await chrome.storage.local.set({ [__UNIQUE_PAYLOAD_KEY_NAME__]: payload }) - - if (chrome.runtime.lastError) { - throw new Error(chrome.runtime.lastError.message) - } - - return payload -} - -/** Unsets the bridge data from the local storage. In fact, it disables the javascript protection. */ -export const unsetBridgeData = async (): Promise => { - await chrome.storage.local.remove([__UNIQUE_PAYLOAD_KEY_NAME__]) - - if (chrome.runtime.lastError) { - throw new Error(chrome.runtime.lastError.message) - } -} diff --git a/src/entrypoints/background/hooks/http-requests.ts b/src/entrypoints/background/hooks/http-requests.ts index ad74bf60..c514ce12 100644 --- a/src/entrypoints/background/hooks/http-requests.ts +++ b/src/entrypoints/background/hooks/http-requests.ts @@ -1,6 +1,6 @@ import { browserBrands, isMobile, platform } from '~/shared/client-hint' import { canonizeDomain } from '~/shared' -import type { ReadonlyUserAgentState } from '~/shared/types' +import type { ContentScriptPayload, ReadonlyUserAgentState } from '~/shared/types' import ResourceType = chrome.declarativeNetRequest.ResourceType import Rule = chrome.declarativeNetRequest.Rule @@ -25,9 +25,10 @@ enum HeaderOperation { // Note: the rule IDs must be unique, and do not change them after the extension is published. // The rule IDs are used to remove the existing rules before adding new ones. -const RuleIDs: { readonly [_ in 'ReplaceUserAgent' | 'ReplaceClientHints']: number } = { +const RuleIDs: { readonly [_ in 'ReplaceUserAgent' | 'ReplaceClientHints' | 'ProvidePayload']: number } = { ReplaceUserAgent: 1, ReplaceClientHints: 2, + ProvidePayload: 3, } enum HeaderNames { @@ -38,6 +39,7 @@ enum HeaderNames { CLIENT_HINT_BRAND_FULL = 'Sec-CH-UA-Full-Version-List', // https://mzl.la/3C3x5TT CLIENT_HINT_PLATFORM = 'Sec-CH-UA-Platform', // https://mzl.la/3EbrbTj CLIENT_HINT_MOBILE = 'Sec-CH-UA-Mobile', // https://mzl.la/3SYTA3f + SERVER_TIMING = 'server-timing', // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing } const allResourceTypes = Object.values(ResourceType) @@ -48,6 +50,8 @@ const allResourceTypes = Object.values(ResourceType) * The filter parameter is optional and can be used to apply the rules only to specific domains. * If filter is not provided, the rules are applied to all domains. * + * Enabling payload sending means that the JS protection is enabled. + * * To debug the rules, you can use the following page: * https://www.whatismybrowser.com/detect/what-http-headers-is-my-browser-sending * @@ -57,7 +61,8 @@ const allResourceTypes = Object.values(ResourceType) */ export async function setRequestHeaders( ua: ReadonlyUserAgentState, - filter?: { applyToDomains?: ReadonlyArray; exceptDomains?: ReadonlyArray } + filter?: { applyToDomains?: ReadonlyArray; exceptDomains?: ReadonlyArray }, + sendPayload: boolean = false ): Promise> { const condition: chrome.declarativeNetRequest.RuleCondition = { resourceTypes: allResourceTypes, @@ -108,8 +113,6 @@ export async function setRequestHeaders( return [] })() - .map((b) => `"${b.brand}";v="${b.version}"`) - .join(', ') const brandsWithFull = (() => { switch (ua.browser) { @@ -123,13 +126,23 @@ export async function setRequestHeaders( return [] })() - .map((b) => `"${b.brand}";v="${b.version}"`) - .join(', ') + + const setPlatform = platform(ua.os) + const setIsMobile = isMobile(ua.os) + + const payload: ContentScriptPayload = { + current: ua, + brands: { + major: brandsWithMajor, + full: brandsWithFull, + }, + platform: setPlatform, + isMobile: setIsMobile, + } const rules: Array = [ { id: RuleIDs.ReplaceUserAgent, - priority: 1, action: { type: RuleActionType.MODIFY_HEADERS, requestHeaders: [ @@ -144,7 +157,6 @@ export async function setRequestHeaders( }, { id: RuleIDs.ReplaceClientHints, - priority: 2, action: { type: RuleActionType.MODIFY_HEADERS, requestHeaders: [ @@ -152,25 +164,25 @@ export async function setRequestHeaders( ? { operation: HeaderOperation.SET, header: HeaderNames.CLIENT_HINT_BRAND_MAJOR, - value: brandsWithMajor, + value: brandsWithMajor.map((b) => `"${b.brand}";v="${b.version}"`).join(', '), } : { operation: HeaderOperation.REMOVE, header: HeaderNames.CLIENT_HINT_BRAND_MAJOR }, brandsWithFull ? { operation: HeaderOperation.SET, header: HeaderNames.CLIENT_HINT_BRAND_FULL, - value: brandsWithFull, + value: brandsWithFull.map((b) => `"${b.brand}";v="${b.version}"`).join(', '), } : { operation: HeaderOperation.REMOVE, header: HeaderNames.CLIENT_HINT_BRAND_FULL }, { operation: HeaderOperation.SET, header: HeaderNames.CLIENT_HINT_PLATFORM, - value: `"${platform(ua.os)}"`, + value: `"${setPlatform}"`, }, { operation: HeaderOperation.SET, header: HeaderNames.CLIENT_HINT_MOBILE, - value: isMobile(ua.os) ? '?1' : '?0', + value: setIsMobile ? '?1' : '?0', }, { operation: HeaderOperation.REMOVE, header: HeaderNames.CLIENT_HINT_FULL_VERSION }, { operation: HeaderOperation.REMOVE, header: HeaderNames.CLIENT_HINT_PLATFORM_VERSION }, @@ -180,6 +192,23 @@ export async function setRequestHeaders( }, ] + if (sendPayload) { + rules.push({ + id: RuleIDs.ProvidePayload, + action: { + type: RuleActionType.MODIFY_HEADERS, + responseHeaders: [ + { + operation: HeaderOperation.SET, + header: HeaderNames.SERVER_TIMING, + value: `${__UNIQUE_HEADER_KEY_NAME__};desc="${btoa(JSON.stringify(payload)).replace(/=/g, '_')}"`, + }, + ], + }, + condition, + }) + } + await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: Object.values(RuleIDs), // remove existing rules addRules: rules, diff --git a/src/entrypoints/background/hooks/index.ts b/src/entrypoints/background/hooks/index.ts index ca1bd4ce..a430d04e 100644 --- a/src/entrypoints/background/hooks/index.ts +++ b/src/entrypoints/background/hooks/index.ts @@ -1,3 +1,2 @@ export { setRequestHeaders, unsetRequestHeaders } from './http-requests' -export { setBridgeData, unsetBridgeData } from './content-script-bridge' export { registerContentScripts } from './scripting' diff --git a/src/entrypoints/background/hooks/scripting.ts b/src/entrypoints/background/hooks/scripting.ts index be2b8c92..908f0090 100644 --- a/src/entrypoints/background/hooks/scripting.ts +++ b/src/entrypoints/background/hooks/scripting.ts @@ -33,7 +33,7 @@ export async function registerContentScripts() { // so we need to register the content scripts without the "world" property // // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/RegisteredContentScript#browser_compatibility - return await chrome.scripting.registerContentScripts([content, inject]) + return await chrome.scripting.registerContentScripts([content]) } throw err diff --git a/src/entrypoints/background/index.ts b/src/entrypoints/background/index.ts index 0d37ebda..6ea23b69 100644 --- a/src/entrypoints/background/index.ts +++ b/src/entrypoints/background/index.ts @@ -2,7 +2,7 @@ import { checkPermissions, detectBrowser, watchPermissionsChange } from '~/share import { type HandlersMap, listen as listenRuntime } from '~/shared/messaging' import { newErrorEvent, newExtensionLoadedEvent } from '~/shared/stats' import { isApplicableForDomain, reloadRequestHeadersAndBridge, renewUserAgent, updateRemoteUserAgentList } from './api' -import { registerContentScripts, unsetBridgeData, unsetRequestHeaders } from './hooks' +import { registerContentScripts, unsetRequestHeaders } from './hooks' import { registerHotkeys } from './hotkeys' import { CurrentUserAgent, RemoteUserAgentList, Settings, StorageArea, UserID } from './persistent' import { type Collector as StatsCollector, GaCollector } from './stats' @@ -148,8 +148,6 @@ let stats: StatsCollector | undefined = undefined await Promise.allSettled([ // disable headers replacement unsetRequestHeaders(), - // disable the javascript protection - unsetBridgeData(), // disable the user-agent renewal timer userAgentRenewTimer.stop(), // disable the remote user-agents list update timer diff --git a/src/entrypoints/content/content.ts b/src/entrypoints/content/content.ts index 70415302..2080585f 100644 --- a/src/entrypoints/content/content.ts +++ b/src/entrypoints/content/content.ts @@ -1,94 +1,27 @@ // โš  DO NOT IMPORT ANYTHING EXCEPT TYPES HERE DUE THE `import()` ERRORS โš  -import type { ContentScriptPayload } from '~/shared/types' - -console.log(`%c๐Ÿ•Š RUA: Content script is running`, 'font-weight:bold') - -/** Injects and executes the script with the payload. The ID of the script tag is the same as the filename. */ -const injectAndExecuteScript = (payload: ContentScriptPayload): void => { - const script = document.createElement('script') - const parent = document.head || document.documentElement - - script.type = 'module' - script.setAttribute('payload', JSON.stringify(payload)) - script.setAttribute('id', __UNIQUE_INJECT_FILENAME__) - script.src = chrome.runtime.getURL(__UNIQUE_INJECT_FILENAME__) - - parent.prepend(script) -} - -/** - * Checks if the script is applicable to the (current) domain. - * - * Note: Keep this function implementation in sync with the one in the `isApplicableForDomain()` and - * `reloadRequestHeadersAndBridge()` functions (which are defined in the `api/filters.ts` file). - */ -const isApplicableToDomain = (currentDomain: string, p: ContentScriptPayload): boolean => { - const applyToDomains = p.filtering?.applyToDomains || [] - const exceptDomains = p.filtering?.exceptDomains || [] - - const isInExceptList = exceptDomains.some( - (except): boolean => except === currentDomain || currentDomain.endsWith(`.${except}`) - ) - - // if the `applyTo` array is NOT empty, the script should be applied ONLY to the domains in the array (including - // subdomains), except the ones in the `exceptDomains` array (including subdomains too) - if (applyToDomains.length) { - const isInApplyToList = applyToDomains.some( - (apply): boolean => apply === currentDomain || currentDomain.endsWith(`.${apply}`) - ) - - return isInApplyToList && !isInExceptList - } - - // otherwise, the script should be applied to all domains, except the ones in the `exceptDomains` array (including - // subdomains) - return !isInExceptList -} - -/** Run the script */ -try { - const key = __UNIQUE_PAYLOAD_KEY_NAME__ - - // get the payload from the storage - chrome.storage.local.get([key], (items) => { - if (chrome.runtime.lastError) { - throw new Error(chrome.runtime.lastError.message) - } - - if (!(key in items)) { - return // no payload = javascript protection is disabled - } - - const payload = items[key] as ContentScriptPayload - const currentDomain = window.location.hostname - - if (!isApplicableToDomain(currentDomain, payload)) { - return // the script is not applicable to the current domain - } +// wrap everything to avoid polluting the global scope +;(() => { + try { // Important Note: // // Chromium-based browsers (like Chrome, Edge, Opera, etc.) support the `world` property in the - // `chrome.scripting.registerContentScripts` API. This property allows content scripts to run in the "isolated" - // world, which is necessary for accessing the payload from the storage using the `chrome.store` API. Additionally, - // they can run in the "main" world as an "inject" script, which modifies the user agent. - // - // However, FireFox does not yet support the `world` property. Therefore, I need to ensure that the "inject" script - // code is executed in both environments. + // `chrome.scripting.registerContentScripts` API. However, FireFox does not. Therefore, I need to ensure that the + // "inject" script code is executed in both environments. // // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/RegisteredContentScript - // - // For Chromium-based browsers, the "inject" script is registered as a content script in the - // `registerContentScripts` function, resulting in faster execution without adding a