puppeteer-extra-plugin-stealth
Version:
Stealth mode: Applies various techniques to make detection of headless puppeteer harder.
710 lines (639 loc) • 23.7 kB
JavaScript
const test = require('ava')
const { vanillaPuppeteer } = require('../../test/util')
const utils = require('.')
const withUtils = require('./withUtils')
/* global HTMLMediaElement WebGLRenderingContext */
test('splitObjPath: will do what it says', async t => {
const { objName, propName } = utils.splitObjPath(
'HTMLMediaElement.prototype.canPlayType'
)
t.is(objName, 'HTMLMediaElement.prototype')
t.is(propName, 'canPlayType')
})
test('makeNativeString: will do what it says', async t => {
utils.init()
t.is(utils.makeNativeString('bob'), 'function bob() { [native code] }')
t.is(
utils.makeNativeString('toString'),
'function toString() { [native code] }'
)
t.is(utils.makeNativeString(), 'function () { [native code] }')
})
test('replaceWithProxy: will work correctly', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
const test1 = await withUtils(page).evaluate(utils => {
const dummyProxyHandler = {
get(target, param) {
if (param && param === 'ping') {
return 'pong'
}
return utils.cache.Reflect.get(...(arguments || []))
},
apply() {
return utils.cache.Reflect.apply(...arguments)
}
}
utils.replaceWithProxy(
HTMLMediaElement.prototype,
'canPlayType',
dummyProxyHandler
)
return {
toString: HTMLMediaElement.prototype.canPlayType.toString(),
ping: HTMLMediaElement.prototype.canPlayType.ping
}
})
t.deepEqual(test1, {
toString: 'function canPlayType() { [native code] }',
ping: 'pong'
})
})
test('replaceObjPathWithProxy: will work correctly', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
const test1 = await withUtils(page).evaluate(utils => {
const dummyProxyHandler = {
get(target, param) {
if (param && param === 'ping') {
return 'pong'
}
return utils.cache.Reflect.get(...(arguments || []))
},
apply() {
return utils.cache.Reflect.apply(...arguments)
}
}
utils.replaceObjPathWithProxy(
'HTMLMediaElement.prototype.canPlayType',
dummyProxyHandler
)
return {
toString: HTMLMediaElement.prototype.canPlayType.toString(),
ping: HTMLMediaElement.prototype.canPlayType.ping
}
})
t.deepEqual(test1, {
toString: 'function canPlayType() { [native code] }',
ping: 'pong'
})
})
test('redirectToString: is battle hardened', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
// Patch all documents including iframes
await withUtils(page).evaluateOnNewDocument(utils => {
// We redirect toString calls targeted at `canPlayType` to `getParameter`,
// so if everything works correctly we expect `getParameter` as response.
const proxyObj = HTMLMediaElement.prototype.canPlayType
const originalObj = WebGLRenderingContext.prototype.getParameter
utils.redirectToString(proxyObj, originalObj)
})
await page.goto('about:blank')
const result = await withUtils(page).evaluate(utils => {
const iframe = document.createElement('iframe')
document.body.appendChild(iframe)
return {
target: {
raw: HTMLMediaElement.prototype.canPlayType + '',
rawiframe:
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType + '',
raw2: HTMLMediaElement.prototype.canPlayType.toString(),
rawiframe2:
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType.toString(),
direct: Function.prototype.toString.call(
HTMLMediaElement.prototype.canPlayType
),
directWithiframe: iframe.contentWindow.Function.prototype.toString.call(
HTMLMediaElement.prototype.canPlayType
),
iframeWithdirect: Function.prototype.toString.call(
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType
),
iframeWithiframe: iframe.contentWindow.Function.prototype.toString.call(
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType
)
},
toString: {
obj: HTMLMediaElement.prototype.canPlayType.toString + '',
objiframe:
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType.toString +
'',
raw: Function.prototype.toString + '',
rawiframe: iframe.contentWindow.Function.prototype.toString + '',
direct: Function.prototype.toString.call(Function.prototype.toString),
directWithiframe: iframe.contentWindow.Function.prototype.toString.call(
Function.prototype.toString
),
iframeWithdirect: Function.prototype.toString.call(
iframe.contentWindow.Function.prototype.toString
),
iframeWithiframe: iframe.contentWindow.Function.prototype.toString.call(
iframe.contentWindow.Function.prototype.toString
)
}
}
})
t.deepEqual(result, {
target: {
raw: 'function getParameter() { [native code] }',
raw2: 'function getParameter() { [native code] }',
rawiframe: 'function getParameter() { [native code] }',
rawiframe2: 'function getParameter() { [native code] }',
direct: 'function getParameter() { [native code] }',
directWithiframe: 'function getParameter() { [native code] }',
iframeWithdirect: 'function getParameter() { [native code] }',
iframeWithiframe: 'function getParameter() { [native code] }'
},
toString: {
obj: 'function toString() { [native code] }',
objiframe: 'function toString() { [native code] }',
raw: 'function toString() { [native code] }',
rawiframe: 'function toString() { [native code] }',
direct: 'function toString() { [native code] }',
directWithiframe: 'function toString() { [native code] }',
iframeWithdirect: 'function toString() { [native code] }',
iframeWithiframe: 'function toString() { [native code] }'
}
})
})
test('redirectToString: has proper errors', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
// Patch all documents including iframes
await withUtils(page).evaluateOnNewDocument(utils => {
// We redirect toString calls targeted at `canPlayType` to `getParameter`,
// so if everything works correctly we expect `getParameter` as response.
const proxyObj = HTMLMediaElement.prototype.canPlayType
const originalObj = WebGLRenderingContext.prototype.getParameter
utils.redirectToString(proxyObj, originalObj)
})
await page.goto('about:blank')
const result = await withUtils(page).evaluate(utils => {
const evalErr = (str = '') => {
try {
// eslint-disable-next-line no-eval
return eval(str)
} catch (err) {
return err.toString()
}
}
return {
blank: evalErr(`Function.prototype.toString.apply()`),
null: evalErr(`Function.prototype.toString.apply(null)`),
undef: evalErr(`Function.prototype.toString.apply(undefined)`),
emptyObject: evalErr(`Function.prototype.toString.apply({})`)
}
})
t.deepEqual(result, {
blank:
"TypeError: Function.prototype.toString requires that 'this' be a Function",
null: "TypeError: Function.prototype.toString requires that 'this' be a Function",
undef:
"TypeError: Function.prototype.toString requires that 'this' be a Function",
emptyObject:
"TypeError: Function.prototype.toString requires that 'this' be a Function"
})
})
test('patchToString: will work correctly', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
// Test verbatim string replacement
const test1 = await withUtils(page).evaluate(utils => {
utils.patchToString(HTMLMediaElement.prototype.canPlayType, 'bob')
return HTMLMediaElement.prototype.canPlayType.toString()
})
t.is(test1, 'bob')
// Test automatic mode derived from `.name`
const test2 = await withUtils(page).evaluate(utils => {
utils.patchToString(HTMLMediaElement.prototype.canPlayType)
return HTMLMediaElement.prototype.canPlayType.toString()
})
t.is(test2, 'function canPlayType() { [native code] }')
// Make sure automatic mode derived from `.name` works with proxies
const test3 = await withUtils(page).evaluate(utils => {
HTMLMediaElement.prototype.canPlayType = new Proxy(
HTMLMediaElement.prototype.canPlayType,
{}
)
utils.patchToString(HTMLMediaElement.prototype.canPlayType)
return HTMLMediaElement.prototype.canPlayType.toString()
})
t.is(test3, 'function canPlayType() { [native code] }')
// Actually verify there's an issue when using vanilla Proxies
const test4 = await withUtils(page).evaluate(utils => {
HTMLMediaElement.prototype.canPlayType = new Proxy(
HTMLMediaElement.prototype.canPlayType,
{}
)
return HTMLMediaElement.prototype.canPlayType.toString()
})
t.is(test4, 'function () { [native code] }')
})
function toStringTest(obj) {
obj = eval(obj) // eslint-disable-line no-eval
return `
- obj.toString(): ${obj.toString()}
- obj.name: ${obj.name}
- obj.toString + "": ${obj.toString + ''}
- obj.toString.name: ${obj.toString.name}
- obj.valueOf + "": ${obj.valueOf + ''}
- obj.valueOf().name: ${obj.valueOf().name}
- Object.prototype.toString.apply(obj): ${Object.prototype.toString.apply(obj)}
- Function.prototype.toString.call(obj): ${Function.prototype.toString.call(
obj
)}
- Function.prototype.valueOf.call(obj) + "": ${
Function.prototype.valueOf.call(obj) + ''
}
- obj.toString === Function.prototype.toString: ${
obj.toString === Function.prototype.toString
}
`.trim()
}
test('patchToString: passes all toString tests', async t => {
const toStringVanilla = await (async function () {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
return page.evaluate(toStringTest, 'HTMLMediaElement.prototype.canPlayType')
})()
const toStringStealth = await (async function () {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
await withUtils(page).evaluate(utils => {
HTMLMediaElement.prototype.canPlayType = function canPlayType() {}
utils.patchToString(HTMLMediaElement.prototype.canPlayType)
})
return page.evaluate(toStringTest, 'HTMLMediaElement.prototype.canPlayType')
})()
// Check that the unmodified results are as expected
t.is(
toStringVanilla,
`
- obj.toString(): function canPlayType() { [native code] }
- obj.name: canPlayType
- obj.toString + "": function toString() { [native code] }
- obj.toString.name: toString
- obj.valueOf + "": function valueOf() { [native code] }
- obj.valueOf().name: canPlayType
- Object.prototype.toString.apply(obj): [object Function]
- Function.prototype.toString.call(obj): function canPlayType() { [native code] }
- Function.prototype.valueOf.call(obj) + "": function canPlayType() { [native code] }
- obj.toString === Function.prototype.toString: true
`.trim()
)
// Make sure our customizations leave no trace
t.is(toStringVanilla, toStringStealth)
})
test('patchToString: passes stack trace tests', async t => {
const toStringStackTrace = () => {
try {
Object.create(
Object.getOwnPropertyDescriptor(Function.prototype, 'toString').get
).toString()
} catch (err) {
return err.stack.split('\n').slice(0, 2).join('|')
}
return 'error not thrown'
}
const toStringVanilla = await (async function () {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
return page.evaluate(toStringStackTrace)
})()
const toStringStealth = await (async function () {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
await withUtils(page).evaluate(utils => {
HTMLMediaElement.prototype.canPlayType = function canPlayType() {}
utils.patchToString(HTMLMediaElement.prototype.canPlayType)
})
return page.evaluate(toStringStackTrace)
})()
// Check that the unmodified results are as expected
t.is(
toStringVanilla,
`TypeError: Object prototype may only be an Object or null: undefined| at Function.create (<anonymous>)`.trim()
)
// Make sure our customizations leave no trace
t.is(toStringVanilla, toStringStealth)
})
test('patchToString: vanilla has iframe issues', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
// Only patch the main window
const result = await withUtils(page).evaluate(utils => {
utils.patchToString(HTMLMediaElement.prototype.canPlayType, 'bob')
const iframe = document.createElement('iframe')
document.body.appendChild(iframe)
return {
direct: Function.prototype.toString.call(
HTMLMediaElement.prototype.canPlayType
),
directWithiframe: iframe.contentWindow.Function.prototype.toString.call(
HTMLMediaElement.prototype.canPlayType
),
iframeWithdirect: Function.prototype.toString.call(
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType
),
iframeWithiframe: iframe.contentWindow.Function.prototype.toString.call(
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType
)
}
})
t.deepEqual(result, {
direct: 'bob',
directWithiframe: 'function canPlayType() { [native code] }',
iframeWithdirect: 'function canPlayType() { [native code] }',
iframeWithiframe: 'function canPlayType() { [native code] }'
})
})
test('patchToString: stealth has no iframe issues', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
// Patch all documents including iframes
await withUtils(page).evaluateOnNewDocument(utils => {
utils.patchToString(HTMLMediaElement.prototype.canPlayType, 'alice')
})
await page.goto('about:blank')
const result = await withUtils(page).evaluate(utils => {
const iframe = document.createElement('iframe')
document.body.appendChild(iframe)
return {
direct: Function.prototype.toString.call(
HTMLMediaElement.prototype.canPlayType
),
directWithiframe: iframe.contentWindow.Function.prototype.toString.call(
HTMLMediaElement.prototype.canPlayType
),
iframeWithdirect: Function.prototype.toString.call(
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType
),
iframeWithiframe: iframe.contentWindow.Function.prototype.toString.call(
iframe.contentWindow.HTMLMediaElement.prototype.canPlayType
)
}
})
t.deepEqual(result, {
direct: 'alice',
directWithiframe: 'alice',
iframeWithdirect: 'alice',
iframeWithiframe: 'alice'
})
})
test('stripProxyFromErrors: will work correctly', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
const results = await withUtils(page).evaluate(utils => {
const getStack = prop => {
try {
prop.caller() // Will throw (HTMLMediaElement.prototype.canPlayType.caller)
return false
} catch (err) {
return err.stack
}
}
/** We need traps to show up in the error stack */
const dummyProxyHandler = {
get() {
return utils.cache.Reflect.get(...(arguments || []))
},
apply() {
return utils.cache.Reflect.apply(...arguments)
}
}
const vanillaProxy = new Proxy(
HTMLMediaElement.prototype.canPlayType,
dummyProxyHandler
)
const stealthProxy = new Proxy(
HTMLMediaElement.prototype.canPlayType,
utils.stripProxyFromErrors(dummyProxyHandler)
)
const stacks = {
vanilla: getStack(HTMLMediaElement.prototype.canPlayType),
vanillaProxy: getStack(vanillaProxy),
stealthProxy: getStack(stealthProxy)
}
return stacks
})
// Check that the untouched stuff behaves as expected
t.true(results.vanilla.includes(`TypeError: 'caller'`))
t.false(results.vanilla.includes(`at Object.get`))
// Regression test: Make sure vanilla JS Proxies leak the stack trace
t.true(results.vanillaProxy.includes(`TypeError: 'caller'`))
t.true(results.vanillaProxy.includes(`at Object.get`))
// Stealth tests
t.true(results.stealthProxy.includes(`TypeError: 'caller'`))
t.false(results.stealthProxy.includes(`at Object.get`))
})
test('replaceProperty: will work without traces', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
const results = await withUtils(page).evaluate(utils => {
utils.replaceProperty(Object.getPrototypeOf(navigator), 'languages', {
get: () => ['de-DE']
})
return {
propNames: Object.getOwnPropertyNames(navigator)
}
})
t.false(results.propNames.includes('languages'))
})
test('cache: will prevent leaks through overriding methods', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
const results = await withUtils(page).evaluate(utils => {
const sniffResults = {
vanilla: false,
stealth: false
}
const vanillaProxy = new Proxy(
{},
{
get() {
return Reflect.get(...arguments)
}
}
)
Reflect.get = () => (sniffResults.vanilla = true)
// trigger get trap
vanillaProxy.foo // eslint-disable-line
const stealthProxy = new Proxy(
{},
{
get() {
return utils.cache.Reflect.get(...arguments) // using cached copy
}
}
)
Reflect.get = () => (sniffResults.stealth = true)
// trigger get trap
stealthProxy.foo // eslint-disable-line
return sniffResults
})
t.deepEqual(results, {
vanilla: true,
stealth: false
})
})
test('replaceWithProxy: will throw prototype errors', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto('about:blank')
const result = await withUtils(page).evaluate(utils => {
utils.replaceWithProxy(HTMLMediaElement.prototype, 'canPlayType', {})
const evalErr = (str = '') => {
try {
// eslint-disable-next-line no-eval
return eval(str)
} catch (err) {
return err.toString()
}
}
return {
same: evalErr(
`Object.setPrototypeOf(HTMLMediaElement.prototype.canPlayType, HTMLMediaElement.prototype.canPlayType) + ""`
),
sameString: evalErr(
`Object.setPrototypeOf(Function.prototype.toString, Function.prototype.toString) + ""`
),
null: evalErr(
`Object.setPrototypeOf(Function.prototype.toString, null) + ""`
),
undef: evalErr(
`Object.setPrototypeOf(Function.prototype.toString, undefined) + ""`
),
none: evalErr(`Object.setPrototypeOf(Function.prototype.toString) + ""`)
}
})
t.deepEqual(result, {
same: 'TypeError: Cyclic __proto__ value',
sameString: 'TypeError: Cyclic __proto__ value',
null: 'TypeError: Cannot convert object to primitive value',
undef:
'TypeError: Object prototype may only be an Object or null: undefined',
none: 'TypeError: Object prototype may only be an Object or null: undefined'
})
})
test('replaceGetterSetter', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto('about:blank')
const results = await withUtils(page).evaluate(utils => {
const getDetails = a => ({
href: a.href,
typeof: typeof a.href,
in: 'href' in a,
keys: Object.keys(a),
// eslint-disable-next-line no-undef
prototypeKeys: Object.keys(HTMLAnchorElement.prototype),
getOwnPropertyNames: Object.getOwnPropertyNames(a),
prototypeGetOwnPropertyNames: Object.getOwnPropertyNames(
// eslint-disable-next-line no-undef
HTMLAnchorElement.prototype
),
ownPropertyDescriptor:
undefined === Object.getOwnPropertyDescriptor(a, 'href'),
prototypeOwnPropertyDescriptor: Object.getOwnPropertyDescriptor(
// eslint-disable-next-line no-undef
HTMLAnchorElement.prototype,
'href'
),
ownPropertyDescriptors: Object.getOwnPropertyDescriptors(a, 'href'),
prototypeOwnPropertyDescriptors: Object.getOwnPropertyDescriptors(
// eslint-disable-next-line no-undef
HTMLAnchorElement.prototype,
'href'
),
getToString: Object.getOwnPropertyDescriptor(
// eslint-disable-next-line no-undef
HTMLAnchorElement.prototype,
'href'
).get.toString(),
setToString: Object.getOwnPropertyDescriptor(
// eslint-disable-next-line no-undef
HTMLAnchorElement.prototype,
'href'
).set.toString()
})
// Use native a.href.
const a1 = document.createElement('a')
a1.href = 'http://foo.com/'
const details1 = getDetails(a1)
// Override a.href.
let href = ''
// eslint-disable-next-line no-undef
utils.replaceGetterSetter(HTMLAnchorElement.prototype, 'href', {
get: function() {
return href
},
set: function(newValue) {
href = newValue
}
})
// Use overrided a.href.
const a2 = document.createElement('a')
a2.href = 'http://foo.com/'
const details2 = getDetails(a2)
return [details1, details2]
})
t.deepEqual(results[1], results[0])
})
test('arrayEquals', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto('about:blank')
const results = await withUtils(page).evaluate(utils => {
const obj = { foo: 'bar' }
return {
a: utils.arrayEquals(['a', 'Alpha'], ['a', 'Alpha']),
b: !utils.arrayEquals(['b', 'Beta'], ['b', 'Blue']),
c: !utils.arrayEquals(['c', { foo: 'bar' }], ['c', { foo: 'bar' }]),
d: utils.arrayEquals(['d', obj], ['d', obj]),
e: utils.arrayEquals([null], [null]),
f: utils.arrayEquals([undefined], [undefined]),
g: utils.arrayEquals([false], [false])
}
})
t.deepEqual(results, {
a: true,
b: true,
c: true,
d: true,
e: true,
f: true,
g: true
})
})
test('memoize', async t => {
const browser = await vanillaPuppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto('about:blank')
const results = await withUtils(page).evaluate(utils => {
const objectify = utils.memoize((valueAdded, valueIgnored) => {
return { valueAdded }
})
const obj = { foo: 'bar' }
/* eslint-disable no-self-compare */
return {
a: objectify('a', 'Alpha') === objectify('a', 'Alpha'),
b: objectify('b', 'Beta') !== objectify('b', 'Blue'),
c: objectify('c', { foo: 'bar' }) !== objectify('c', { foo: 'bar' }),
d: objectify('d', obj) === objectify('d', obj),
e: objectify(null) === objectify(null),
f: objectify(undefined) === objectify(undefined),
g: objectify(false) === objectify(false)
}
/* eslint-enable no-self-compare */
})
t.deepEqual(results, {
a: true,
b: true,
c: true,
d: true,
e: true,
f: true,
g: true
})
})