Skip to content
Open
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
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@
"alarms",
"storage",
"scripting",
"declarativeNetRequest"
"declarativeNetRequestWithHostAccess"
],
"$what_the_specified_permissions_are_for": {
"tabs": "Obtaining the current tab URL to verify if the extension is enabled for the specific URL in the popup",
"alarms": "Scheduling user-agent renewal and other periodic tasks",
"storage": "Managing and synchronizing user settings across browser sessions",
"scripting": "Injecting user-agent modification code into web pages",
"declarativeNetRequest": "Modifying HTTP headers"
"declarativeNetRequestWithHostAccess": "Modifying HTTP headers (requires host_permissions)"
},
"incognito": "spanning",
"minimum_chrome_version": "120"
Expand Down
91 changes: 56 additions & 35 deletions src/entrypoints/background/hooks/http-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ enum HeaderNames {
// the following domains are always excluded from the rules
const alwaysExcludedFor: ReadonlyArray<string> = ['challenges.cloudflare.com'].map(canonizeDomain)

// hardcoded fallback for chrome.declarativeNetRequest.ResourceType — the enum may not be available
// in the service worker context in some Chrome versions, which would cause rules to match zero requests
const allResourceTypes: ReadonlyArray<string> = [
'main_frame',
'sub_frame',
'stylesheet',
'script',
'image',
'font',
'object',
'xmlhttprequest',
'ping',
'csp_report',
'media',
'websocket',
'webtransport',
'webbundle',
'other',
]

/**
* Enables the request headers modification.
*
Expand All @@ -64,50 +84,33 @@ export async function setRequestHeaders(
sendPayload: boolean = false
): Promise<Array<chrome.declarativeNetRequest.Rule>> {
const condition: chrome.declarativeNetRequest.RuleCondition = {
resourceTypes: Object.values(chrome?.declarativeNetRequest?.ResourceType || {}),
resourceTypes: Object.values(chrome?.declarativeNetRequest?.ResourceType || {}).length
? Object.values(chrome.declarativeNetRequest.ResourceType)
: ([...allResourceTypes] as chrome.declarativeNetRequest.ResourceType[]),
}

if (filter?.applyToDomains && filter.applyToDomains.length > 0) {
// initiatorDomains: The rule only matches network requests originating from this list of domains. If the list
// is omitted, the rule is applied to requests from all domains. An empty list is not allowed.
// A canonical domain should be used. This matches against the request initiator and not the
// request URL.
// requestDomains: The rule only matches network requests when the domain matches one from this list. If the
// list is omitted, the rule is applied to requests from all domains. An empty list is not
// allowed. A canonical domain should be used.
//
// https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest#type-MatchedRulesFilter
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest/RuleCondition
const list = filter.applyToDomains.map(canonizeDomain).filter(validateDomainOrIP)

if (list.length) {
condition.initiatorDomains = condition.requestDomains = list
// only set requestDomains — using both initiatorDomains AND requestDomains creates an AND condition
// in Chrome's declarativeNetRequest, meaning BOTH must match. For whitelist mode, we want requests
// TO whitelisted domains to be modified, regardless of the initiator page
condition.requestDomains = list
}
}

if (filter?.exceptDomains && filter.exceptDomains.length > 0) {
// excludedInitiatorDomains: The rule does not match network requests originating from this list of domains.
// If the list is empty or omitted, no domains are excluded. This takes precedence
// over initiatorDomains. A canonical domain should be used. This matches against
// the request initiator and not the request URL.
// excludedRequestDomains: The rule does not match network requests when the domains matches one from this
// list. If the list is empty or omitted, no domains are excluded. This takes
// precedence over requestDomains. A canonical domain should be used.
const list = filter.exceptDomains.map(canonizeDomain).filter(validateDomainOrIP)

if (list.length) {
condition.excludedInitiatorDomains = condition.excludedRequestDomains = list
// only set excludedRequestDomains (not excludedInitiatorDomains) — matches the reference
// extension behavior and avoids potential Chrome 130+ validation issues with both set
condition.excludedRequestDomains = list
}
}

// add the always excluded domains to the condition
if (condition.excludedInitiatorDomains) {
condition.excludedInitiatorDomains = [...new Set(condition.excludedInitiatorDomains.concat(alwaysExcludedFor))]
} else {
condition.excludedInitiatorDomains = [...alwaysExcludedFor]
}

// and do the same for the request domains
// add the always excluded domains to the condition (only excludedRequestDomains)
if (condition.excludedRequestDomains) {
condition.excludedRequestDomains = [...new Set(condition.excludedRequestDomains.concat(alwaysExcludedFor))]
} else {
Expand Down Expand Up @@ -222,17 +225,35 @@ export async function setRequestHeaders(
})
}

await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: Object.values(RuleIDs), // remove existing rules
addRules: rules,
})
try {
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: Object.values(RuleIDs), // remove existing rules
addRules: rules,
})
} catch (err) {
console.warn('RUA: Failed to update dynamic rules:', err)
// try once more after clearing all rules
try {
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: Object.values(RuleIDs),
addRules: rules,
})
} catch (retryErr) {
console.error('RUA: Failed to update dynamic rules on retry:', retryErr)
throw retryErr
}
}

return rules
}

/** Unsets the request headers. */
export async function unsetRequestHeaders(): Promise<void> {
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: Object.values(RuleIDs), // remove existing rules
})
try {
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: Object.values(RuleIDs), // remove existing rules
})
} catch (err) {
console.warn('RUA: Failed to unset dynamic rules:', err)
}
}
3 changes: 3 additions & 0 deletions src/entrypoints/background/hooks/scripting.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import RegisteredContentScript = chrome.scripting.RegisteredContentScript

// the common properties for the content scripts
// matchOriginAsFallback: true allows scripts to run in sandboxed iframes (those without allow-same-origin),
// which is essential for spoofing navigator properties inside sandboxed iframe contexts
const common: Omit<RegisteredContentScript, 'id'> = {
matches: ['<all_urls>'],
allFrames: true,
matchOriginAsFallback: true,
runAt: 'document_start',
}

Expand Down
23 changes: 23 additions & 0 deletions src/entrypoints/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,29 @@ const m2s = (millis: number): number => Math.round(millis / 1000)
},
})

// ensure declarativeNetRequest rules are re-applied on extension install/update — Chrome 130+ may
// terminate the service worker before the IIFE completes, so explicit handlers guarantee rule setup
chrome.runtime.onInstalled.addListener(async (details) => {
debug('onInstalled', details.reason)

if (details.reason === 'install' || details.reason === 'update') {
// renew the user-agent and re-apply rules on install/update
await renewUserAgent(settings, currentUserAgent, remoteUserAgentList, hostOS, latestBrowserVersions)
debug('rules re-applied on install/update', await currentUserAgent.get())
}
})

chrome.runtime.onStartup.addListener(async () => {
debug('onStartup')

// ensure rules are re-applied on browser startup
const current = await currentUserAgent.get()
if (current) {
const reloaded = await reloadRequestHeaders(await settings.get(), current)
debug('rules re-applied on startup', reloaded)
}
})

// set the extension icon state on startup
await setExtensionIcon(initSettings.enabled)

Expand Down
72 changes: 72 additions & 0 deletions src/entrypoints/content/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,78 @@ import type { DeepWriteable } from '~/types'
// patch the current navigator object
patchNavigator(navigator)

// intercept fetch() and XMLHttpRequest to override User-Agent header BEFORE service workers see it
// declarativeNetRequest modifies headers at the network layer (after SW interception), so we need
// to patch at the page level to defeat the "UA Header via service worker" detection method
{
const spoofedUA = ((): string => {
switch (payload.current.browser) {
case 'chrome':
case 'opera':
case 'edge': {
const masked = payload.current.userAgent.replaceAll(
payload.current.version.browser.full,
payload.current.version.browser.major +
'.0'.repeat(Math.max(0, payload.current.version.browser.full.split('.').length - 1))
)
if (payload.current.version.underHood) {
return masked.replaceAll(
payload.current.version.underHood.full || '',
payload.current.version.underHood.major +
'.0'.repeat(Math.max(0, payload.current.version.underHood.full.split('.').length - 1))
)
}
return masked
}
}
return payload.current.userAgent
})()

// override fetch()
const originalFetch = window.fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
window.fetch = function (this: any, input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.set('User-Agent', spoofedUA)
} else if (Array.isArray(init.headers)) {
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== 'user-agent')
init.headers.push(['User-Agent', spoofedUA])
} else if (typeof init.headers === 'object') {
;(init.headers as Record<string, string>)['User-Agent'] = spoofedUA
}
} else {
init = { ...init, headers: { 'User-Agent': spoofedUA } }
}
return originalFetch.call(this, input, init)
} as typeof fetch

// override XMLHttpRequest to inject User-Agent header
const origOpen = XMLHttpRequest.prototype.open
const origSend = XMLHttpRequest.prototype.send
const RUA_KEY = '__rua_ua__'

Object.defineProperty(XMLHttpRequest.prototype, 'open', {
value: function (this: XMLHttpRequest & Record<string, string>, ...args: unknown[]) {
this[RUA_KEY] = spoofedUA
return (origOpen as Function).apply(this, args as never)
},
writable: true,
configurable: true,
})

Object.defineProperty(XMLHttpRequest.prototype, 'send', {
value: function (this: XMLHttpRequest & Record<string, string>, ...args: unknown[]) {
if (this[RUA_KEY]) {
this.setRequestHeader('User-Agent', this[RUA_KEY])
}
return (origSend as Function).apply(this, args as never)
},
writable: true,
configurable: true,
})
}

// patch iframes navigators
{
// currently existing
Expand Down