puppeteer-extra-plugin-stealth
Version:
Stealth mode: Applies various techniques to make detection of headless puppeteer harder.
255 lines (225 loc) • 9.41 kB
JavaScript
const { PuppeteerExtraPlugin } = require('puppeteer-extra-plugin')
const withUtils = require('../_utils/withUtils')
const STATIC_DATA = require('./staticData.json')
/**
* Mock the `chrome.runtime` object if not available (e.g. when running headless) and on a secure site.
*/
class Plugin extends PuppeteerExtraPlugin {
constructor(opts = {}) {
super(opts)
}
get name() {
return 'stealth/evasions/chrome.runtime'
}
get defaults() {
return { runOnInsecureOrigins: false } // Override for testing
}
async onPageCreated(page) {
await withUtils(page).evaluateOnNewDocument(
(utils, { opts, STATIC_DATA }) => {
if (!window.chrome) {
// Use the exact property descriptor found in headful Chrome
// fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')`
Object.defineProperty(window, 'chrome', {
writable: true,
enumerable: true,
configurable: false, // note!
value: {} // We'll extend that later
})
}
// That means we're running headful and don't need to mock anything
const existsAlready = 'runtime' in window.chrome
// `chrome.runtime` is only exposed on secure origins
const isNotSecure = !window.location.protocol.startsWith('https')
if (existsAlready || (isNotSecure && !opts.runOnInsecureOrigins)) {
return // Nothing to do here
}
window.chrome.runtime = {
// There's a bunch of static data in that property which doesn't seem to change,
// we should periodically check for updates: `JSON.stringify(window.chrome.runtime, null, 2)`
...STATIC_DATA,
// `chrome.runtime.id` is extension related and returns undefined in Chrome
get id() {
return undefined
},
// These two require more sophisticated mocks
connect: null,
sendMessage: null
}
const makeCustomRuntimeErrors = (preamble, method, extensionId) => ({
NoMatchingSignature: new TypeError(
preamble + `No matching signature.`
),
MustSpecifyExtensionID: new TypeError(
preamble +
`${method} called from a webpage must specify an Extension ID (string) for its first argument.`
),
InvalidExtensionID: new TypeError(
preamble + `Invalid extension id: '${extensionId}'`
)
})
// Valid Extension IDs are 32 characters in length and use the letter `a` to `p`:
// https://source.chromium.org/chromium/chromium/src/+/master:components/crx_file/id_util.cc;drc=14a055ccb17e8c8d5d437fe080faba4c6f07beac;l=90
const isValidExtensionID = str =>
str.length === 32 && str.toLowerCase().match(/^[a-p]+$/)
/** Mock `chrome.runtime.sendMessage` */
const sendMessageHandler = {
apply: function(target, ctx, args) {
const [extensionId, options, responseCallback] = args || []
// Define custom errors
const errorPreamble = `Error in invocation of runtime.sendMessage(optional string extensionId, any message, optional object options, optional function responseCallback): `
const Errors = makeCustomRuntimeErrors(
errorPreamble,
`chrome.runtime.sendMessage()`,
extensionId
)
// Check if the call signature looks ok
const noArguments = args.length === 0
const tooManyArguments = args.length > 4
const incorrectOptions = options && typeof options !== 'object'
const incorrectResponseCallback =
responseCallback && typeof responseCallback !== 'function'
if (
noArguments ||
tooManyArguments ||
incorrectOptions ||
incorrectResponseCallback
) {
throw Errors.NoMatchingSignature
}
// At least 2 arguments are required before we even validate the extension ID
if (args.length < 2) {
throw Errors.MustSpecifyExtensionID
}
// Now let's make sure we got a string as extension ID
if (typeof extensionId !== 'string') {
throw Errors.NoMatchingSignature
}
if (!isValidExtensionID(extensionId)) {
throw Errors.InvalidExtensionID
}
return undefined // Normal behavior
}
}
utils.mockWithProxy(
window.chrome.runtime,
'sendMessage',
function sendMessage() {},
sendMessageHandler
)
/**
* Mock `chrome.runtime.connect`
*
* @see https://developer.chrome.com/apps/runtime#method-connect
*/
const connectHandler = {
apply: function(target, ctx, args) {
const [extensionId, connectInfo] = args || []
// Define custom errors
const errorPreamble = `Error in invocation of runtime.connect(optional string extensionId, optional object connectInfo): `
const Errors = makeCustomRuntimeErrors(
errorPreamble,
`chrome.runtime.connect()`,
extensionId
)
// Behavior differs a bit from sendMessage:
const noArguments = args.length === 0
const emptyStringArgument = args.length === 1 && extensionId === ''
if (noArguments || emptyStringArgument) {
throw Errors.MustSpecifyExtensionID
}
const tooManyArguments = args.length > 2
const incorrectConnectInfoType =
connectInfo && typeof connectInfo !== 'object'
if (tooManyArguments || incorrectConnectInfoType) {
throw Errors.NoMatchingSignature
}
const extensionIdIsString = typeof extensionId === 'string'
if (extensionIdIsString && extensionId === '') {
throw Errors.MustSpecifyExtensionID
}
if (extensionIdIsString && !isValidExtensionID(extensionId)) {
throw Errors.InvalidExtensionID
}
// There's another edge-case here: extensionId is optional so we might find a connectInfo object as first param, which we need to validate
const validateConnectInfo = ci => {
// More than a first param connectInfo as been provided
if (args.length > 1) {
throw Errors.NoMatchingSignature
}
// An empty connectInfo has been provided
if (Object.keys(ci).length === 0) {
throw Errors.MustSpecifyExtensionID
}
// Loop over all connectInfo props an check them
Object.entries(ci).forEach(([k, v]) => {
const isExpected = ['name', 'includeTlsChannelId'].includes(k)
if (!isExpected) {
throw new TypeError(
errorPreamble + `Unexpected property: '${k}'.`
)
}
const MismatchError = (propName, expected, found) =>
TypeError(
errorPreamble +
`Error at property '${propName}': Invalid type: expected ${expected}, found ${found}.`
)
if (k === 'name' && typeof v !== 'string') {
throw MismatchError(k, 'string', typeof v)
}
if (k === 'includeTlsChannelId' && typeof v !== 'boolean') {
throw MismatchError(k, 'boolean', typeof v)
}
})
}
if (typeof extensionId === 'object') {
validateConnectInfo(extensionId)
throw Errors.MustSpecifyExtensionID
}
// Unfortunately even when the connect fails Chrome will return an object with methods we need to mock as well
return utils.patchToStringNested(makeConnectResponse())
}
}
utils.mockWithProxy(
window.chrome.runtime,
'connect',
function connect() {},
connectHandler
)
function makeConnectResponse() {
const onSomething = () => ({
addListener: function addListener() {},
dispatch: function dispatch() {},
hasListener: function hasListener() {},
hasListeners: function hasListeners() {
return false
},
removeListener: function removeListener() {}
})
const response = {
name: '',
sender: undefined,
disconnect: function disconnect() {},
onDisconnect: onSomething(),
onMessage: onSomething(),
postMessage: function postMessage() {
if (!arguments.length) {
throw new TypeError(`Insufficient number of arguments.`)
}
throw new Error(`Attempting to use a disconnected port object`)
}
}
return response
}
},
{
opts: this.opts,
STATIC_DATA
}
)
}
}
module.exports = function(pluginConfig) {
return new Plugin(pluginConfig)
}