Skip to content

Commit

Permalink
Aggressive User-Agent detection fixed (#165)
Browse files Browse the repository at this point in the history
  • Loading branch information
tarampampam authored Oct 28, 2021
1 parent eeb2b68 commit 4ec6a2c
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 79 deletions.
3 changes: 3 additions & 0 deletions .codecov.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Docs: <https://docs.codecov.io/docs/commit-status>

github_checks: # https://docs.codecov.com/docs/github-checks#disabling-github-checks-patch-annotations-via-yaml
annotations: false

coverage:
# coverage lower than 50 is red, higher than 90 green
range: 30..80
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog][keepachangelog] and this project adheres to [Semantic Versioning][semver].

## UNRELEASED

### Added

- Watching for the dynamically created iframes and pathing them

### Fixed

- Aggressive User-Agent detection (now even the inline scripts cannot detect the real User-Agent; thanks to [@neroux](https://github.com/neroux) for the idea) [#26], [#36]

[#26]:https://github.com/tarampampam/random-user-agent/issues/26
[#36]:https://github.com/tarampampam/random-user-agent/issues/36

## v3.1.1

### Fixed
Expand Down
20 changes: 0 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,26 +37,6 @@ means and then combine that with your randomly changing `User-Agent` to pretty e
see [this GitHub issue](https://github.com/tarampampam/random-user-agent/issues/47).
</details>

<details>
<summary>User-agent can't be replaced (for now) in Google Chrome for pages with aggressive (inline JavaScript) detection</summary>

Example:

```html
<!doctype html>
<html>
<head>
<script>
console.log(navigator.userAgent) // Real user-agent will be detected
</script>
</head>
</html>
```

This method is quite rare (usually JavaScript code is wrapped in `Promises`, `setTimeout` or event listeners), but so
far no way around this kind of checking has been invented.
</details>

## 🧩 Install

Follow up by one of the links at the top 👆 of this page, or download directly the latest release from the
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"filemanager-webpack-plugin": "^6.1.7",
"jest": "^27.3.1",
"json-minimizer-webpack-plugin": "^3.1.0",
"randomstring": "^1.2.1",
"sass": "^1.43.3",
"sass-loader": "^12.2.0",
"terser-webpack-plugin": "^5.2.4",
Expand Down
4 changes: 4 additions & 0 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import GetSettings from './messaging/handlers/get-settings'
import Useragent, {UseragentStateEvent} from './useragent/useragent'
import GetUseragent from './messaging/handlers/get-useragent'
import UpdateUseragent from './messaging/handlers/update-useragent'
import HeadersReceived from './hooks/headers-received'

// define default errors handler for the background page
const errorsHandler: (err: Error) => void = console.error
Expand Down Expand Up @@ -103,6 +104,9 @@ useragent.load().then((): void => { // load useragent state

// this hook is required for the HTTP headers modification
new BeforeSendHeaders(settings, useragent, filterService).listen()

// this hook allows to send important data to the content script without using sendMessage()
new HeadersReceived(settings, useragent, filterService).listen()
}).catch(errorsHandler)
}).catch(errorsHandler)
}).catch(errorsHandler)
150 changes: 91 additions & 59 deletions src/content-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,115 @@ import {RuntimeSender} from './messaging/runtime'
import {applicableToURI, ApplicableToURIResponse} from './messaging/handlers/applicable-to-uri'
import {getSettings, GetSettingsResponse} from './messaging/handlers/get-settings'
import {getUseragent, GetUseragentResponse} from './messaging/handlers/get-useragent'
import {CookieName, decode, Payload} from './hooks/headers-received'

new RuntimeSender()
.send( // order is important!
applicableToURI(window.location.href),
getSettings(),
getUseragent(),
)
.then((resp): void => { // <-- the promise is the main problem for hiding from inline scripts detection
const applicable = (resp[0] as ApplicableToURIResponse).payload.applicable
const settings = (resp[1] as GetSettingsResponse).payload
const useragent = (resp[2] as GetUseragentResponse).payload.useragent

if (applicable && settings.jsProtection.enabled) {
const script = document.createElement('script'), parent = document.head || document.documentElement

script.textContent = '(' + function (useragent: string): void {
// allows to overload object property with a getter function (without potential exceptions)
const overloadPropertyWithGetter = (object: any, property: string, value: any): void => {
if (typeof object === 'object') {
if (Object.getOwnPropertyDescriptor(object, property) === undefined) {
Object.defineProperty(object, property, {get: (): any => value})
}
new Promise((resolve: (p: Payload) => void, reject: (e: Error) => void) => {
// make an attempt to fetch the payload from the cookies
const cookies = document.cookie.split(';')

for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trimLeft()

if (cookie.startsWith(CookieName + '=')) {
const parts = cookie.split('=')

if (parts.length >= 2) {
document.cookie = `${CookieName}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/` // remove the cookie

return resolve(decode(parts[1]))
}
}
}

// and as a fallback - sending requests to the background script
new RuntimeSender()
.send( // order is important!
applicableToURI(window.location.href),
getSettings(),
getUseragent(),
)
.then((resp): void => { // <-- the promise is the main problem for hiding from inline scripts detection
const applicable = (resp[0] as ApplicableToURIResponse).payload.applicable
const settings = (resp[1] as GetSettingsResponse).payload
const useragent = (resp[2] as GetUseragentResponse).payload.useragent

if (applicable && settings.jsProtection.enabled && typeof useragent === 'string') {
return resolve({
useragent: useragent,
})
}
})
.catch(reject)
})
.then((p: Payload): string => '(' + function (p: Payload): void {
// allows to overload object property with a getter function (without potential exceptions)
const overloadPropertyWithGetter = (object: object, property: string, value: any): void => {
if (typeof object === 'object') {
if (Object.getOwnPropertyDescriptor(object, property) === undefined) {
Object.defineProperty(object, property, {get: (): any => value})
}
}
}

// makes required navigator object modifications
const patchNavigator = (navigator: Navigator): void => {
if (typeof navigator === 'object') {
overloadPropertyWithGetter(navigator, 'userAgent', useragent)
// makes required navigator object modifications
const patchNavigator = (navigator: Navigator): void => {
if (typeof navigator === 'object') {
overloadPropertyWithGetter(navigator, 'userAgent', p.useragent)

// app version should not contain "Mozilla/" prefix
overloadPropertyWithGetter(navigator, 'appVersion', useragent.replace(/^Mozilla\//i, ''))
// app version should not contain "Mozilla/" prefix
overloadPropertyWithGetter(navigator, 'appVersion', p.useragent.replace(/^Mozilla\//i, ''))

// firefox always with an empty vendor
if (useragent.toLowerCase().includes('firefox\/')) {
overloadPropertyWithGetter(navigator, 'vendor', '')
}
// firefox always with an empty vendor
if (p.useragent.toLowerCase().includes('firefox\/')) {
overloadPropertyWithGetter(navigator, 'vendor', '')
}
}
}

// patch current window navigator
patchNavigator(window.navigator)
// patch current window navigator
patchNavigator(window.navigator)

// handler for patching navigator object for the iframes
// issue: <https://github.com/tarampampam/random-user-agent/issues/142>
const patchIFramesHandler = (): void => {
try {
const iframes = document.getElementsByTagName('iframe')
// handler for patching navigator object for the iframes
// issue: <https://github.com/tarampampam/random-user-agent/issues/142>
window.addEventListener('load', (): void => {
const iframes = document.getElementsByTagName('iframe')

for (let i = 0; i < iframes.length; i++) {
const contentWindow = iframes[i].contentWindow
for (let i = 0; i < iframes.length; i++) {
const contentWindow = iframes[i].contentWindow

if (typeof contentWindow === 'object' && contentWindow !== null) {
patchNavigator(contentWindow.navigator)
}
}
}, {once: true, passive: true})

// watch for the new iframes dynamic creation
new MutationObserver((mutations): void => {
mutations.forEach((mutation): void => {
mutation.addedNodes.forEach((addedNode): void => {
if (addedNode.nodeName === 'IFRAME') {
const iframe = addedNode as HTMLIFrameElement, contentWindow = iframe.contentWindow

if (typeof contentWindow === 'object' && contentWindow !== null) {
patchNavigator(contentWindow.navigator)
}
}
} finally {
window.removeEventListener('load', patchIFramesHandler)
}
}
})
})
}).observe(document, {childList: true, subtree: true})
} + `)(${JSON.stringify(p)})`,
)
.then((scriptContent: string): void => {
const script = document.createElement('script'), parent = document.head || document.documentElement

window.addEventListener('load', patchIFramesHandler)
} + `)("${useragent}")`
script.textContent = scriptContent

// script.defer = false
// script.async = false
parent.appendChild(script) // execute the script
// script.defer = false
// script.async = false
parent.appendChild(script) // execute the script

setTimeout(() => {
parent.removeChild(script)
}) // and remove them on a next tick
}
setTimeout(() => {
parent.removeChild(script)
}) // and remove them on a next tick
})
.catch(console.warn)

// Duty, but workable hack:
// const when = Date.now() + 500
// while (Date.now() < when) {
// // do nothing
// }
83 changes: 83 additions & 0 deletions src/hooks/headers-received.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Settings from '../settings/settings'
import Useragent from '../useragent/useragent'
import FilterService from '../services/filter-service'
import BlockingResponse = chrome.webRequest.BlockingResponse
import WebResponseHeadersDetails = chrome.webRequest.WebResponseHeadersDetails

declare var __UNIQUE_RUA_COOKIE_NAME__: string // see the webpack config, section "plugins" (webpack.DefinePlugin)
export const CookieName: string = __UNIQUE_RUA_COOKIE_NAME__

export interface Payload {
useragent: string
}

export function encode(payload: Payload): string {
return window.btoa(
unescape(
encodeURIComponent(
JSON.stringify(payload),
),
),
).replace(/=/g, '-')
}

export function decode(str: string): Payload {
return JSON.parse(
decodeURIComponent(
escape(
window.atob(
str.replace(/-/g, '='),
),
),
),
)
}

export default class HeadersReceived {
private readonly settings: Settings
private readonly useragent: Useragent
private readonly filterService: FilterService

constructor(settings: Settings, useragent: Useragent, filterService: FilterService) {
this.settings = settings
this.useragent = useragent
this.filterService = filterService
}

/**
* Great thanks to <https://github.com/neroux> (your idea is amazing!)
*
* @link https://developer.chrome.com/docs/extensions/reference/webRequest/ chrome.webRequest
*/
listen(): void {
chrome.webRequest.onHeadersReceived.addListener(
(details: WebResponseHeadersDetails): BlockingResponse | void => {
if (details.type === 'main_frame' || details.type === 'sub_frame') {
const settings = this.settings.get()

if (settings.enabled && settings.jsProtection.enabled && this.filterService.applicableToURI(details.url)) {
const useragent = this.useragent.get().useragent

if (details.responseHeaders && typeof useragent === 'string') {
const date = new Date()
date.setTime(date.getTime() + 60 * 1000) // +60 seconds

const payload: Payload = {
useragent: useragent,
}

details.responseHeaders.push({
name: 'Set-Cookie',
value: `${CookieName}=${encode(payload)}; expires=${date.toUTCString()}; path=/`,
})

return {responseHeaders: details.responseHeaders}
}
}
}
},
{urls: ['<all_urls>']},
['blocking', 'responseHeaders', 'extraHeaders'], // extraHeaders - https://stackoverflow.com/a/66558910/2252921
)
}
}
7 changes: 7 additions & 0 deletions webpack/webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const CopyPlugin = require('copy-webpack-plugin')
const ManifestVersionSyncPlugin = require('./plugins/manifest-version-sync')
const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const randomstring = require('randomstring')
const {VueLoaderPlugin} = require('vue-loader')
const srcDir = path.join(__dirname, '..', 'src')

Expand Down Expand Up @@ -63,6 +64,12 @@ module.exports = {
extensions: ['.ts', '.js'],
},
plugins: [
new webpack.DefinePlugin({
__UNIQUE_RUA_COOKIE_NAME__: JSON.stringify(randomstring.generate({
length: Math.floor(Math.random() * 12 + 5),
charset: 'alphabetic',
})),
}),
new webpack.DefinePlugin({ // https://github.com/vuejs/vue-next/tree/master/packages/vue#bundler-build-feature-flags
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
Expand Down
Loading

0 comments on commit 4ec6a2c

Please sign in to comment.