CDP non è mai stato pensato per essere silenzioso
Puppeteer, Playwright, Selenium 4 e ogni wrapper "stealth" costruito su di essi condividono una sola base: il Chrome DevTools Protocol. È lo stesso canale che il tuo browser apre quando premi F12. Chrome lo espone perché un debugger possa ispezionare e pilotare la pagina, ed è stato progettato con esattamente un non-obiettivo: nascondere il fatto che un debugger sia collegato.
È da qui che nasce il problema. I segnali qui sotto non sono bug di Puppeteer. Quasi tutti sono CDP che si comporta correttamente, in modi in cui un browser guidato da un umano non si comporta mai. A un fornitore di sistemi di rilevamento non serve rompere nulla per trovarli; gli basta leggere valori che la piattaforma regala.
Quello che segue è l'elenco aggiornato al 2026, ordinato grosso modo per quanto è economico verificare ciascun punto.
navigator.webdriver, e perché correggerlo in JS è peggio
Il segnale più vecchio. navigator.webdriver restituisce true ogni volta che Chrome viene avviato con l'automazione attiva. Ogni tutorial ti dice di sovrascriverlo:
Object.defineProperty(navigator, 'webdriver', { get: () => false })
Questa non è una soluzione. È un secondo segnale, più rumoroso. In un browser di serie webdriver è un accessor su Navigator.prototype, non una proprietà propria dell'istanza. Dopo lo snippet qui sopra, tre cose sono vere che non sono vere da nessun'altra parte:
Object.getOwnPropertyDescriptor(navigator, 'webdriver')restituisce un descrittore. Su un browser vero restituisceundefined, perché la proprietà vive sul prototipo.- Il
toString()del getter legge() => falseinvece difunction get webdriver() { [native code] }. - La proprietà ora oscura il prototipo, quindi
'webdriver' in navigatore il descrittore del prototipo non concordano.
Gli script di rilevamento percorrono la catena toString da anni. Un browser che restituisce l'onesto true è un browser che usa l'automazione. Un browser che restituisce un false falsificato è un browser che usa l'automazione e cerca di nasconderlo, che per quasi tutti i motori di rischio è la cosa peggiore fra le due.
L'unica versione di questo che sopravvive a un'ispezione è quella in cui è il getter C++ stesso a restituire false, così non c'è nessuna proprietà propria, nessuna arrow function e niente fuori posto nella catena dei prototipi.
Runtime.enable: il leak che ha riscritto lo stack di tutti
Per eseguire JavaScript in una pagina, quasi tutte le librerie chiamano Runtime.enable. Quel singolo comando ha effetti collaterali che arrivano dentro la pagina: inizia a emettere notifiche Runtime.executionContextCreated e cambia il modo in cui vengono serializzati gli argomenti della console.
L'exploit classico è abbastanza corto da stare in un tweet. Crea un oggetto con un getter, passalo a console.debug e guarda se il getter scatta. Con un debugger collegato e Runtime.enable chiamato, il protocollo serializza l'oggetto per il client e il getter viene eseguito. Senza debugger, niente lo tocca.
let leaked = false;
const probe = { get id() { leaked = true; return 1; } };
console.debug(probe);
// leaked === true → something is listening on CDP
È questo che ha spinto l'ecosistema verso fork con patch — rebrowser-puppeteer e simili — che evitano Runtime.enable e usano invece mondi isolati o addBinding. Quelle patch funzionano e vale la pena usarle. Chiudono anche esattamente un buco su dieci.
Tre difetti nell'input sintetico
Quando l'automazione muove un mouse, non muove un mouse. Chiama Input.dispatchMouseEvent, e Chrome costruisce un oggetto evento che è quasi, ma non del tutto, quello che produce la vera pipeline di input. Tre differenze sono banalmente osservabili da una pagina.
screenX e screenY sono sbagliati
È il bug 40280325 di Chromium. CDP imposta screenX = clientX e screenY = clientY — coordinate del viewport là dove ci vogliono coordinate dello schermo. Per un clic vero i due differiscono per la posizione della finestra più la chrome del browser: barra delle schede, barra degli indirizzi, segnalibri. Su una finestra massimizzata sono circa 90-130 pixel di scostamento verticale.
Così un clic autentico vicino alla cima di una pagina ha screenY intorno a 150. Un clic CDP nello stesso punto riporta screenY = 20. Cloudflare Turnstile tratta un screenY basso come un clic da bot, e fa bene: nessun umano può cliccare sopra la chrome del proprio browser.
UIEvent.detail è zero
Per un clic vero, event.detail porta il conteggio dei clic — 1 per un clic singolo, 2 per un doppio. Gli eventi di clic inviati via CDP arrivano con click_count = 0, quindi detail è 0. Non esiste gesto dell'utente che produca un clic con detail a zero. Turnstile lo controlla dentro il suo iframe di sfida.
getCoalescedEvents() è vuoto
I browser moderni raggruppano i movimenti del puntatore ad alta frequenza e ne espongono i campioni grezzi tramite PointerEvent.getCoalescedEvents(). Un pointermove vero porta sempre almeno un evento raggruppato: sé stesso. Un pointermove inviato via CDP porta un array vuoto, perché non c'è mai stato un campione hardware da raggruppare.
Nessuna di queste cose si può riparare da JavaScript. L'oggetto evento viene costruito in C++ prima che qualsiasi script della pagina possa vederlo, e le proprietà sono di sola lettura.
Le stringhe che l'automazione lascia in giro
Due famiglie di stringhe letterali finiscono dove una pagina può leggerle.
Marcatori di ChromeDriver. ChromeDriver tiene traccia dei risultati delle proprie chiamate definendo proprietà del documento con prefisso cdc_, $cdc_ o $chrome_. Non sono nascoste. Un ciclo di tre righe su Object.getOwnPropertyNames(document) le trova, e gli script di rilevamento cercano esattamente questo dal 2018.
URL sorgente di Puppeteer. Puppeteer aggiunge //# sourceURL=pptr:... agli script che valuta e crea un mondo isolato chiamato __puppeteer_utility_world__. Entrambi emergono in Error.stack se un'eccezione attraversa il confine:
try { null.f() } catch (e) { /* e.stack may contain "pptr:" */ }
Sono i segnali più facili da rimuovere e quelli più spesso lasciati al loro posto, perché compaiono solo in condizioni che uno sviluppatore prova di rado: un errore non catturato dentro una funzione valutata.
Worker: il contesto che nessuno copre
È la falla che frega le configurazioni per il resto più attente.
Web Worker, Service Worker e Shared Worker hanno ciascuno il proprio scope globale con il proprio navigator. Quell'oggetto viene creato dal motore, nel thread del worker, a partire dai valori reali del browser. Gli override CDP a livello di pagina — Emulation.setUserAgentOverride, script iniettati, Page.addScriptToEvaluateOnNewDocument — non lo raggiungono.
Il risultato è un browser che dichiara una cosa sul thread principale e un'altra dentro un worker. A uno script di rilevamento basta chiedere due volte:
// main thread says 8; worker says 24
new Worker(URL.createObjectURL(new Blob([
'postMessage([navigator.hardwareConcurrency, navigator.deviceMemory, navigator.platform])'
], { type: 'text/javascript' })));
Qualsiasi discordanza fra i due è decisiva. I browser veri non possono produrne una, perché entrambi gli scope leggono gli stessi valori sottostanti. Fra i segnali che sfuggono per questa via ci sono hardwareConcurrency, deviceMemory, platform, l'intero elenco di brand di userAgentData e — tramite OffscreenCanvas — il vendor e il renderer WebGL.
Sistemare questo dall'esterno non è possibile in modo completo. I valori devono venire dal motore, il che significa che il navigator del worker va costruito a partire dallo stesso profilo di quello della finestra.
Le piccole cose che comunque si sommano
Altre tre, ciascuna economica da verificare e ciascuna capace di chiudere una sessione da sola.
Dimensioni esterne con i DevTools agganciati
La differenza fra outerWidth e innerWidth è la chrome del browser — circa 16 pixel in orizzontale e 85-90 in verticale su Chrome desktop. Aggancia i DevTools e quel divario salta di centinaia di pixel. Una finestra che riporta outerHeight - innerHeight = 400 è una finestra con un debugger aperto.
Quota di archiviazione
navigator.storage.estimate() restituisce una quota derivata dallo spazio libero su disco. È già di suo un forte segnale hardware, e rivela anche container e contesti in incognito, che riportano numeri tondi caratteristici molto al di sotto di qualsiasi disco reale.
API mancanti per la piattaforma che dichiari
Se il tuo user agent dice Chrome 147 su Windows, la pagina può controllare le API che Chrome 147 su Windows ha davvero. Le build headless storicamente non avevano chrome.runtime, l'intero flusso dei permessi di Notification e il supporto a codec presenti nelle build headful. Dichiarare una piattaforma che non puoi sostenere è peggio che non dichiarare nulla.
Perché la soluzione deve stare nei sorgenti
Guarda l'elenco e uno schema emerge. Quasi ogni segnale nasce sotto JavaScript:
- Le proprietà degli eventi sono impostate in C++ prima che uno script possa toccarle.
- I navigator dei worker sono costruiti su un thread che la pagina non può raggiungere.
- Il getter di webdriver vive su un prototipo la cui forma è a sua volta un segnale.
- Gli effetti collaterali di Runtime.enable avvengono nell'inspector di V8, non nella pagina.
Uno script di contenuto viene eseguito dopo tutto questo. Può solo sovrascrivere ciò che è già stato deciso, e ogni sovrascrittura lascia le tracce descritte sopra: proprietà proprie dove ci vogliono i prototipi, arrow function dove ci vuole codice nativo, disaccordo fra contesti.
È questo l'argomento onesto a favore di una build modificata di Chromium, e vale la pena enunciarne anche i limiti. Applicare patch al motore sistema lo strato sotto JavaScript. Non sistema i fingerprint TLS e HTTP/2, che stanno del tutto sotto il browser, e non fa nulla per il comportamento: una sessione che clicca con regolarità disumana verrà segnalata a prescindere da quanto siano pulite le sue proprietà.
Testare la propria configurazione
Non fidarti della parola di un fornitore, nemmeno della nostra. I test sono pubblici e li puoi eseguire in un pomeriggio:
- rebrowser bot detector — la suite di riferimento specifica per i leak di CDP, compresa la sonda sulla console di
Runtime.enablee i controlli sui mondi di utilità. - CreepJS — l'auditor pubblico di fingerprint più aggressivo. Esegue il confronto worker/finestra descritto sopra e riporta la discordanza in modo esplicito.
- Una tua pagina — il test più rapido sta in venti righe: registra
screenYedetailda un gestore di clic, registragetCoalescedEvents().lengthda un pointermove e rimanda al thread principale il navigator di un worker. Se anche solo uno dei tre non concorda con un browser che guidi a mano, hai trovato il problema.
Eseguili su quello che usi oggi prima di cambiare qualsiasi cosa. Uno strumento che passa nove di questi test e fallisce il decimo non è a nove decimi del percorso: una sola discordanza decisiva basta.