Proxy Puppeteer : authentification, SOCKS5 et signaux
Diagnostiquez les échecs d'authentification et SOCKS5 de Puppeteer, puis comparez deux sorties autorisées avec le même profil, fuseau, langue et canvas.
BotBrowser Team
Vous voulez la documentation structurée pour Démarrage ?
Cet article fait partie de la bibliothèque éditoriale. Pour les étapes de configuration, la référence et les mises à jour continues, passez directement à la section docs.
Un proxy Puppeteer peut changer l'adresse de sortie observée alors que le navigateur conserve son fuseau horaire, sa langue et son rendu canvas. Il faut d'abord distinguer une connexion en échec d'un écart d'environnement : la correction relève d'une autre couche. BotBrowser permet de configurer les identifiants du proxy et des valeurs explicites de fuseau horaire, de paramètres régionaux et de langue avec un profil, mais il ne peut pas obliger un fournisseur à accepter les identifiants ni garantir l'interprétation de ces signaux par une destination.
Résumé
- Configurez une entrée proxy prise en charge par Puppeteer ou Chromium, puis vérifiez que la requête a réellement suivi cette route.
launch({ proxy: ... })n'est pas la configuration proxy documentée de Puppeteer. page.authenticate()fournit des identifiants HTTP. Cette méthode n'ajoute pas la négociation SOCKS5 par nom d'utilisateur et mot de passe à Chrome standard.- Une destination HTTPS via un proxy HTTP et une connexion TLS à un proxy HTTPS sont deux configurations différentes. Distinguez le schéma du proxy de l'URL de destination.
- Comparez les valeurs du navigateur seulement après qu'un observateur que vous contrôlez a confirmé deux sorties publiques différentes. Un changement d'IP n'implique pas un changement de fuseau horaire, de langue, de canvas ou de cookies.
Sommaire
- Classer l'échec avant de modifier les identifiants
- Reproduire localement les limites HTTP et SOCKS5
- Séparer les limites de HTTPS, du DNS et de l'authentification
- Comparer deux sorties avec une entrée de profil immuable
- Lire la comparaison des signaux avant de modifier le profil
- Conclusion pratique
Classer l'échec avant de modifier les identifiants
Conservez la première opération en échec : démarrage du navigateur, connexion au proxy, défi HTTP, TLS de la destination, réponse HTTP ou comparaison dans la page. Une réponse 407 Proxy Authentication Required concerne le proxy. Un 401 de la destination concerne cette origine. Une erreur de navigation sans réponse HTTP peut survenir avant que l'une ou l'autre application ait pu répondre. Augmenter le délai d'attente de la page ne fournit pas une méthode d'authentification non prise en charge.
| Observation | Première vérification | Preuve nécessaire avant de poursuivre |
|---|---|---|
| Le navigateur démarre, mais l'observateur indique la route directe | Portée effective du proxy et exclusions de route directe | Le proxy prévu reçoit cette requête et l'observateur indique sa sortie |
| Le proxy HTTP demande l'authentification à répétition | Identifiants, schéma du défi et moment de page.authenticate() | Le proxy accepte les identifiants et transmet la requête prévue |
| L'endpoint SOCKS5 fonctionne avec un autre client, mais pas avec Chrome | Méthodes d'authentification SOCKS négociées | Une négociation client/serveur prise en charge, et pas seulement un port TCP ouvert |
| CONNECT réussit, puis la navigation échoue | TLS de la destination et transmission dans le tunnel | Une connexion valide à la destination et une véritable réponse HTTP |
| La sortie change, mais pas le fuseau horaire ou la langue | Réglages de l'environnement du navigateur | La valeur mesurée et sa source attendue, indépendamment de l'IP |
| Le rendu canvas est identique après rotation | Profil, moteur de rendu et paramètres du dessin | Une répétition contrôlée du même dessin ; aucun verdict d'identité n'en découle |
Les options de contexte Puppeteer comprennent proxyServer et proxyBypassList. Chromium peut aussi recevoir un argument de lancement tel que --proxy-server=http://127.0.0.1:8080. Ces interfaces de configuration ont des portées différentes ; aucune n'est l'option de lancement proxy de premier niveau, inexistante dans Puppeteer. Vérifiez l'API prise en charge par la version installée au lieu de copier un objet de lancement Playwright dans Puppeteer.
Définissez les identifiants HTTP avant la navigation protégée. La documentation de page.authenticate() de Puppeteer précise aussi que cette méthode active l'interception des requêtes en interne, ce qui peut affecter les performances. N'étudiez pas ce coût temporel en désactivant l'authentification au milieu d'une comparaison dont les autres variables ne sont pas contrôlées. Évitez d'ajouter systématiquement un en-tête supplémentaire Proxy-Authorization : les identifiants du proxy et les en-têtes de la requête à l'origine ont des destinataires différents.
Pour le contrat des flags propres au produit et des réglages géographiques explicites, consultez la configuration proxy BotBrowser. La différence porte ici sur le chemin d'échec dans un navigateur standard piloté par Puppeteer : une route acceptée, des identifiants acceptés et une valeur d'environnement visible dans la page sont trois observations, pas un seul événement de configuration réussi.
Reproduire localement les limites HTTP et SOCKS5
Un cas de test local doit donner une conséquence visible à l'authentification. Le proxy HTTP enregistre s'il a reçu une requête, si les identifiants correspondaient et s'il a transmis la requête à la cible que vous contrôlez. La cible renvoie un marqueur propre au cas. Un échec de connexion ou de négociation ne doit pas produire accidentellement ce marqueur par une connexion directe.
Utilisez des cas HTTP distincts pour les identifiants corrects, absents et incorrects. Le cas réussi exige à la fois une requête acceptée par le proxy et le marqueur de la cible. Les cas rejetés exigent un défi du proxy et aucune réponse transmise depuis la cible. Un message d'erreur du navigateur ne suffit pas : le même échec de navigation peut provenir d'un port fermé, d'un TLS invalide ou d'une méthode proxy non prise en charge.
Pour SOCKS5, comparez un serveur qui accepte la méthode sans authentification à un autre qui exige un nom d'utilisateur et un mot de passe. Répétez le second cas avec page.authenticate() activé. Le journal de la négociation initiale du proxy distingue une méthode non prise en charge d'une application qui a simplement renvoyé une page vide. Gardez le proxy et la cible sur l'interface de bouclage, et prenez explicitement en compte l'exclusion implicite des adresses locales de Chromium pour vérifier si le trafic a atteint le proxy.
Ne déduisez pas une sortie publique de ces tests. Deux écouteurs HTTP locaux peuvent transmettre par la même interface réseau. Deux négociations SOCKS réussies prouvent que deux écouteurs ont répondu ; elles ne prouvent ni des adresses publiques différentes ni des fournisseurs distincts. La comparaison de routes publiques ci-dessous comporte une assertion d'observateur séparée.
La reproduction suivante, limitée à HTTP, crée sa propre origine sur l'interface de bouclage et un proxy protégé par mot de passe. Enregistrez-la sous local-auth.mjs, installez [email protected] et exécutez CHROME=/absolute/path/to/chrome node local-auth.mjs. La destination .test est transmise localement ; le proxy rejette toute autre destination. Les trois cas s'exécutent en parallèle, et un cas rejeté ne doit provoquer aucune requête vers l'origine.
import assert from 'node:assert/strict';
import http from 'node:http';
import { randomBytes } from 'node:crypto';
import puppeteer from 'puppeteer-core';
const executablePath = process.env.CHROME;
assert.ok(executablePath, 'Set CHROME to the browser executable');
const flags = [
'--password-store=basic',
'--use-mock-keychain',
'--disable-background-networking',
'--disable-component-update',
'--disable-sync',
'--no-first-run',
'--no-default-browser-check',
];
async function listen(server) {
const sockets = new Set();
server.on('connection', socket => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('error', () => {});
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
return {
port: server.address().port,
async close() {
for (const socket of sockets) socket.destroy();
await new Promise(resolve => server.close(resolve));
},
};
}
async function runCase(name, password) {
let origin, proxy, browser;
const marker = randomBytes(12).toString('hex');
const counts = { proxyRequests: 0, rejected: 0, accepted: 0, forwarded: 0, originHits: 0 };
try {
origin = await listen(
http.createServer((req, res) => {
counts.originHits++;
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<p>${marker}</p>`);
})
);
const targetURL = `http://fixture.test:${origin.port}/`;
const authorization = `Basic ${Buffer.from('local-user:local-password').toString('base64')}`;
const server = http.createServer((req, res) => {
if (req.url !== targetURL) {
res.writeHead(403).end();
return;
}
counts.proxyRequests++;
if (req.headers['proxy-authorization'] !== authorization) {
counts.rejected++;
res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="local-test"' }).end();
return;
}
counts.accepted++;
counts.forwarded++;
const upstream = http.request(
{ host: '127.0.0.1', port: origin.port, path: '/', headers: { Host: 'fixture.test' } },
remote => {
res.writeHead(remote.statusCode, remote.headers);
remote.pipe(res);
}
);
upstream.on('error', () => {
if (!res.headersSent) res.writeHead(502);
res.end();
});
req.pipe(upstream);
});
server.on('connect', (req, socket) => socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'));
proxy = await listen(server);
browser = await puppeteer.launch({
executablePath,
headless: true,
args: [...flags, `--proxy-server=http://127.0.0.1:${proxy.port}`],
});
const browserVersion = await browser.version();
const page = await browser.newPage();
if (password !== null) await page.authenticate({ username: 'local-user', password });
await page.setRequestInterception(true);
page.on('request', request => void (request.url() === targetURL ? request.continue() : request.abort()));
let status = null,
networkError = null;
try {
status = (
await page.goto(targetURL, {
waitUntil: 'domcontentloaded',
timeout: 12000,
})
).status();
} catch (error) {
networkError = String(error.message).match(/net::ERR_[A-Z_]+/)?.[0] || error.name;
}
const markerObserved = (await page.content()).includes(marker);
const success = name === 'correct';
assert.equal(markerObserved, success);
assert.equal(counts.originHits, success ? 1 : 0);
assert.equal(counts.forwarded, success ? 1 : 0);
assert.equal(counts.accepted, success ? 1 : 0);
assert.ok(counts.proxyRequests > 0 && counts.rejected > 0);
if (success) assert.equal(status, 200);
return { case: name, browserVersion, status, networkError, markerObserved, ...counts };
} finally {
if (browser) await browser.close();
if (proxy) await proxy.close();
if (origin) await origin.close();
}
}
try {
const cases = await Promise.allSettled([
runCase('correct', 'local-password'),
runCase('wrong', 'incorrect'),
runCase('absent', null),
]);
assert.ok(
cases.every(result => result.status === 'fulfilled'),
'A local assertion failed'
);
console.log(JSON.stringify({ passed: true, cases: cases.map(result => result.value) }, null, 2));
} catch (error) {
console.error(JSON.stringify({ passed: false, error: error.name }));
process.exitCode = 1;
}
Ce script isole l'authentification HTTP Basic. La limite de méthode SOCKS5 exige toujours de capturer une négociation initiale SOCKS : une réussite HTTP n'est pas un test de mot de passe SOCKS.
Séparer les limites de HTTPS, du DNS et de l'authentification
La documentation des protocoles proxy de Chromium distingue le transport vers le proxy du schéma de destination demandé. Un proxy HTTP peut transporter une destination HTTPS par CONNECT. Un proxy HTTPS protège en plus la connexion entre navigateur et proxy par TLS. Le seul passage de l'URL de destination à https:// ne transforme pas un proxy HTTP en proxy HTTPS.
| Configuration | Trajet entre navigateur et proxy | Limite pertinente |
|---|---|---|
| Proxy HTTP, destination HTTPS | Connexion au proxy HTTP, puis tunnel CONNECT | L'authentification du proxy et le TLS de la destination restent séparés |
| Proxy HTTPS, destination HTTPS | TLS vers le proxy, puis tunnel vers la destination | La connexion au proxy comme celle à la destination exigent un TLS valide |
| Requête d'URL SOCKS5 de Chrome standard | Transport SOCKS5 pour les requêtes TCP | Chrome documente la résolution des noms par le proxy et aucune méthode d'authentification SOCKS5 prise en charge |
La dernière ligne décrit une limite d'implémentation du navigateur, pas une absence d'authentification dans le protocole SOCKS5. Un client SOCKS Python ou Node peut prendre en charge des méthodes par nom d'utilisateur et mot de passe que Chrome standard ne négocie pas. Modifier page.authenticate() ou placer un mot de passe dans un argument proxy Chromium n'ajoute pas cette implémentation du protocole. À l'inverse, le chemin SOCKS5 de Chrome délègue déjà au proxy la résolution du nom de destination ; copier la notation socks5h:// d'un autre client ne remplace pas la vérification du schéma documenté de Chrome.
Si un service SOCKS5 autorisé exige des identifiants, choisissez un client qui prend en charge cette méthode ou utilisez un adaptateur local approuvé dont le client amont la prend en charge. Consignez cet adaptateur dans l'expérience : Chrome se connecte alors à l'adaptateur, qui établit la connexion SOCKS authentifiée. Gardez son écouteur privé, limitez les destinations et masquez les identifiants. Une exécution réussie avec adaptateur ne démontre pas la prise en charge native des mots de passe SOCKS5 par Chrome.
La résolution DNS d'une URL TCP passant par un proxy ne décrit pas non plus entièrement WebRTC. Le chemin d'URL SOCKS5 documenté de Chromium ne relaie pas UDP. Une requête de page atteignant la sortie prévue ne peut donc pas certifier le trajet d'une opération STUN ou TURN ultérieure. Testez ces chemins selon leur propre politique réseau approuvée au lieu d'extrapoler à partir de CONNECT ou du statut HTTP d'une page.
Comparer deux sorties avec une entrée de profil immuable
Gardez constants le binaire du navigateur, les réglages de lancement, l'entrée de profil, l'endpoint que vous contrôlez et la méthode de requête, et consignez les mêmes champs dans les deux exécutions. Changez uniquement la route proxy autorisée. Le workflow de vérification de sortie de Launcher couvre la discipline correspondante de preuves de route dans une interface graphique ; les mêmes variables doivent rester explicites dans une comparaison Puppeteer.
Un répertoire de données utilisateur Chromium ordinaire est modifiable et verrouillé par le processus qui l'utilise. Pour des branches A/B simultanées, fermez d'abord le navigateur de référence, puis créez des copies identiques octet par octet de cet instantané immuable dans deux nouveaux répertoires de test. Vérifiez les empreintes avant le lancement. Cela compare une seule entrée de profil sur deux routes sans ouvrir le même répertoire modifiable dans deux processus. Deux répertoires vides sans lien entre eux ne fourniraient pas le même état initial du stockage.
Utilisez un endpoint d'observation HTTPS sur un domaine contrôlé par le responsable du workflow. Lorsque Cloudflare dessert ce domaine, son endpoint documenté /cdn-cgi/trace peut fournir une observation réseau délimitée ; un autre déploiement peut exposer sa propre réponse avec l'adresse du pair. Validez le statut, le format de réponse et l'hôte attendu avant de faire confiance à un champ d'adresse. Gardez les adresses brutes privées et publiez des libellés de route dans les preuves.
Exigez deux observations valides d'adresses publiques et vérifiez par assertion qu'elles diffèrent. Si une route échoue, renvoie une adresse privée ou produit la même sortie que l'autre, le résultat n'est pas une comparaison de deux sorties. Une étiquette de pays du fournisseur, un port distinct ou un nom de session ne peuvent pas remplacer cette vérification. Un adaptateur doit aussi compter et limiter ses requêtes amont pour que le trafic de fond du navigateur n'élargisse pas silencieusement le test.
Recueillez les valeurs visibles dans la page seulement après la réponse vérifiée. Utilisez les mêmes dimensions de dessin, texte, demande de police et méthode de comparaison canvas dans les deux branches. Consignez séparément Intl.DateTimeFormat().resolvedOptions().timeZone, les paramètres régionaux Intl résolus, navigator.languages et l'agent utilisateur. Conservez les valeurs d'origine pour l'examen ; calculez leur égalité après la fin des deux branches au lieu de supposer ce qui doit rester stable.
Enregistrez cette comparaison indépendante sous two-exits.mjs, à côté de la même version installée de Puppeteer. Définissez CHROME, PROXY_A, PROXY_B, OBSERVER_URL et PROFILE_DIR dans l'environnement, puis exécutez node two-exits.mjs --pool 20. Le contrat de l'observateur exige HTTPS, HTTP 200, text/plain et une ligne ip=, comme sur un endpoint de trace Cloudflare que vous contrôlez. Le répertoire de profil doit appartenir à un navigateur entièrement fermé ; la vérification du fichier de verrouillage ne remplace pas sa fermeture. Les proxies HTTP peuvent contenir des identifiants ; les entrées SOCKS5 ne doivent pas en contenir. Un serveur amont SOCKS5 authentifié exige un adaptateur approuvé séparément.
import assert from 'node:assert/strict';
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer-core';
const sha = value => createHash('sha256').update(value).digest('hex');
const safeError = error => ({
name: error.name,
networkCode: String(error.message).match(/net::ERR_[A-Z_]+/)?.[0] || null,
});
const flags = [
'--password-store=basic',
'--use-mock-keychain',
'--disable-background-networking',
'--disable-component-update',
'--disable-sync',
'--no-first-run',
'--no-default-browser-check',
'--window-size=1280,900',
];
async function resources() {
const sample = () =>
os.cpus().reduce(
(sum, cpu) => {
sum.idle += cpu.times.idle;
sum.total += Object.values(cpu.times).reduce((a, b) => a + b, 0);
return sum;
},
{ idle: 0, total: 0 }
);
const before = sample();
await new Promise(resolve => setTimeout(resolve, 250));
const after = sample(),
total = after.total - before.total;
const unusedCpuCores = (os.cpus().length * (after.idle - before.idle)) / Math.max(1, total);
let availableMemoryBytes = os.freemem();
if (process.platform === 'darwin') {
const vm = execFileSync('vm_stat', { encoding: 'utf8' });
const pageSize = Number(vm.match(/page size of (\d+) bytes/)[1]);
availableMemoryBytes = [...vm.matchAll(/^(?:Pages free|Pages inactive|Pages speculative):\s+(\d+)\./gm)].reduce(
(sum, match) => sum + Number(match[1]) * pageSize,
0
);
} else if (process.platform === 'linux') {
availableMemoryBytes =
Number((await fs.readFile('/proc/meminfo', 'utf8')).match(/^MemAvailable:\s+(\d+)/m)[1]) * 1024;
}
return {
capturedAt: new Date().toISOString(),
cpuCount: os.cpus().length,
unusedCpuCores: Number(unusedCpuCores.toFixed(2)),
availableMemoryBytes,
};
}
async function treeHash(root) {
const entries = [];
async function walk(relative) {
const children = await fs.readdir(path.join(root, relative), { withFileTypes: true });
for (const entry of children.sort((a, b) => a.name.localeCompare(b.name))) {
const name = path.join(relative, entry.name),
file = path.join(root, name);
if (entry.isDirectory()) await walk(name);
else if (entry.isFile()) entries.push([name, sha(await fs.readFile(file))]);
else if (entry.isSymbolicLink()) entries.push([name, 'symlink', await fs.readlink(file)]);
else throw new Error('Unsupported profile entry');
}
}
await walk('');
return { sha256: sha(JSON.stringify(entries)), entries: entries.length };
}
function publicAddress(ip) {
if (net.isIP(ip) === 4) {
const value = ip.split('.').reduce((n, octet) => n * 256 + Number(octet), 0);
const excluded = [
['0.0.0.0', 8],
['10.0.0.0', 8],
['100.64.0.0', 10],
['127.0.0.0', 8],
['169.254.0.0', 16],
['172.16.0.0', 12],
['192.0.0.0', 24],
['192.0.2.0', 24],
['192.88.99.0', 24],
['192.168.0.0', 16],
['198.18.0.0', 15],
['198.51.100.0', 24],
['203.0.113.0', 24],
['224.0.0.0', 3],
];
return !excluded.some(
([base, bits]) =>
Math.floor(value / 2 ** (32 - bits)) ===
Math.floor(base.split('.').reduce((n, octet) => n * 256 + Number(octet), 0) / 2 ** (32 - bits))
);
}
if (net.isIP(ip) !== 6) return false;
const [first, second] = ip.split(':').map(part => parseInt(part || '0', 16));
return (
first >= 0x2000 &&
first <= 0x3fff &&
first !== 0x2002 &&
!(first === 0x2001 && (second < 0x200 || second === 0xdb8)) &&
!(first === 0x3fff && second < 0x1000)
);
}
async function signals(page) {
const measured = await page.evaluate(async () => {
const canvas = document.createElement('canvas');
canvas.width = 160;
canvas.height = 50;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#e5ebf3';
ctx.fillRect(0, 0, 160, 50);
ctx.fillStyle = '#17243a';
ctx.font = '16px Arial';
ctx.fillText('Proxy fixture 2026', 5, 30);
const webrtc = { candidateTypes: {}, candidateCount: 0, gatheringComplete: false, error: null };
let pc;
try {
pc = new RTCPeerConnection({ iceServers: [] });
pc.createDataChannel('owned-comparison');
await new Promise(resolve => {
const timer = setTimeout(resolve, 1800);
pc.onicecandidate = event => {
if (event.candidate) {
const type = event.candidate.type || 'unknown';
webrtc.candidateTypes[type] = (webrtc.candidateTypes[type] || 0) + 1;
webrtc.candidateCount++;
} else {
webrtc.gatheringComplete = true;
clearTimeout(timer);
resolve();
}
};
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.catch(error => {
webrtc.error = error.name;
clearTimeout(timer);
resolve();
});
});
} catch (error) {
webrtc.error = error.name;
} finally {
pc?.close();
}
return {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
intlLocale: Intl.DateTimeFormat().resolvedOptions().locale,
languages: [...navigator.languages],
userAgent: navigator.userAgent,
canvas: canvas.toDataURL(),
webrtc,
};
});
const { canvas, ...rest } = measured;
return { ...rest, canvasSha256: sha(canvas) };
}
async function main() {
const { CHROME, PROFILE_DIR, OBSERVER_URL, PROXY_A, PROXY_B } = process.env;
assert.ok(CHROME && PROFILE_DIR && OBSERVER_URL && PROXY_A && PROXY_B, 'Missing required input');
const observer = new URL(OBSERVER_URL);
assert.ok(observer.protocol === 'https:' && !observer.username && !observer.password && !observer.hash);
const index = process.argv.indexOf('--pool');
const requested = index < 0 ? 20 : Number(process.argv[index + 1]);
assert.ok(Number.isInteger(requested) && requested >= 2, 'Two concurrent lanes need pool >= 2');
const snapshot = await resources();
const effective = Math.min(
requested,
2,
Math.floor(snapshot.unusedCpuCores),
Math.floor(snapshot.availableMemoryBytes / 1024 ** 3)
);
console.log(JSON.stringify({ resources: snapshot, pool: { requested, effective } }));
assert.equal(effective, 2, 'Insufficient resources for two concurrent lanes; no observer request sent');
const names = await fs.readdir(PROFILE_DIR);
assert.ok(!names.some(name => /^Singleton(?:Lock|Socket|Cookie)$/.test(name)), 'Close the baseline browser');
const input = await treeHash(PROFILE_DIR);
const work = await fs.mkdtemp(path.join(os.tmpdir(), 'two-exits-'));
const copies = ['A', 'B'].map(label => path.join(work, label));
await Promise.all(
copies.map(copy =>
fs.cp(PROFILE_DIR, copy, {
recursive: true,
dereference: false,
verbatimSymlinks: true,
errorOnExist: true,
force: false,
})
)
);
const prelaunch = await Promise.all(copies.map(treeHash));
for (const copy of prelaunch) assert.deepEqual(copy, input);
assert.deepEqual(await treeHash(PROFILE_DIR), input);
const ipKey = randomBytes(32);
let ready = 0,
unblock,
active = 0,
maximumActive = 0;
const gate = new Promise(resolve => {
unblock = resolve;
});
async function lane(label, proxyInput, userDataDir) {
let browser;
const startedAt = new Date().toISOString();
maximumActive = Math.max(maximumActive, ++active);
try {
const proxy = new URL(proxyInput);
assert.ok(['http:', 'socks5:'].includes(proxy.protocol));
assert.ok(proxy.hostname && (!proxy.pathname || proxy.pathname === '/') && !proxy.search && !proxy.hash);
assert.ok(proxy.protocol !== 'socks5:' || (!proxy.username && !proxy.password), 'SOCKS5 auth needs an adapter');
browser = await puppeteer.launch({
executablePath: CHROME,
userDataDir,
headless: true,
timeout: 25000,
args: [...flags, `--proxy-server=${proxy.protocol}//${proxy.host}`],
});
const browserVersion = await browser.version(),
page = await browser.newPage();
if (proxy.username || proxy.password)
await page.authenticate({
username: decodeURIComponent(proxy.username),
password: decodeURIComponent(proxy.password),
});
let observerRequests = 0;
await page.setRequestInterception(true);
page.on('request', request => {
if (request.url() === observer.href && observerRequests === 0) {
observerRequests++;
void request.continue();
} else void request.abort();
});
if (++ready === 2) unblock();
await gate;
assert.equal(ready, 2, 'Peer lane failed before navigation');
const navigationStartedAt = new Date().toISOString();
const response = await page.goto(observer.href, { waitUntil: 'domcontentloaded', timeout: 25000 });
assert.ok(response && response.status() === 200 && response.url() === observer.href);
assert.equal(response.request().redirectChain().length, 0);
assert.ok(/^text\/plain(?:;|$)/i.test(response.headers()['content-type'] || ''));
const body = await response.text();
assert.ok(body.length <= 8192);
const addresses = [...body.matchAll(/^ip=(.+)$/gm)];
assert.equal(addresses.length, 1);
const address = addresses[0][1].trim();
assert.ok(publicAddress(address), 'Non-public observer address');
const addressDigest = createHmac('sha256', ipKey).update(address).digest('hex');
const observed = await signals(page);
assert.equal(observerRequests, 1);
return {
label,
startedAt,
navigationStartedAt,
finishedAt: new Date().toISOString(),
browserVersion,
observer: { status: 200, requests: observerRequests, publicAddressValidated: true, addressDigest },
signals: observed,
};
} finally {
unblock();
if (browser) await browser.close();
active--;
}
}
const completed = await Promise.allSettled([lane('A', PROXY_A, copies[0]), lane('B', PROXY_B, copies[1])]);
assert.deepEqual(await treeHash(PROFILE_DIR), input);
if (completed.some(result => result.status === 'rejected')) {
console.log(
JSON.stringify({
passed: false,
lanes: completed.map((result, i) => ({
label: ['A', 'B'][i],
status: result.status,
...(result.status === 'rejected' ? { error: safeError(result.reason) } : { result: result.value }),
})),
})
);
process.exitCode = 1;
return;
}
const routes = completed.map(result => result.value);
assert.equal(maximumActive, 2);
assert.equal(routes[0].browserVersion, routes[1].browserVersion);
assert.notEqual(routes[0].observer.addressDigest, routes[1].observer.addressDigest, 'Both routes used one exit');
const equality = Object.fromEntries(
['timezone', 'intlLocale', 'languages', 'canvasSha256', 'userAgent'].map(key => [
key,
JSON.stringify(routes[0].signals[key]) === JSON.stringify(routes[1].signals[key]),
])
);
console.log(
JSON.stringify(
{
passed: true,
differentPublicExits: true,
maximumActive,
input,
prelaunch,
baselineUnchanged: true,
equality,
routes,
temporaryProfilesRetained: true,
},
null,
2
)
);
}
main().catch(error => {
console.error(JSON.stringify({ passed: false, error: safeError(error) }));
process.exitCode = 1;
});
La règle de ressources réserve un cœur CPU inactif estimé et 1 GiB de mémoire disponible par navigateur. Elle refuse de transformer la comparaison en exécution séquentielle si deux emplacements ne sont pas disponibles. Les copies du profil restent dans le répertoire temporaire du système d'exploitation pour inspection. La vérification des adresses rejette prudemment les plages à usage spécial ; ce n'est pas une recherche de géolocalisation. La clé HMAC propre à l'exécution reste en mémoire ; les empreintes affichées ne permettent donc de comparer l'égalité qu'au sein de cette exécution.
Comparaison mesurée de deux routes
Le 11 novembre 2026, la comparaison a été exécutée avec Node 26.9.0, Puppeteer Core 25.10.0 et Chrome 157.0.8103.79 sur macOS arm64, Darwin 24.6.0. Le nouvel échantillon a relevé 2.21 cœurs CPU inactifs estimés et 12,294,160,384 octets de mémoire disponible, ce qui a réduit le pool demandé de 20 à deux branches simultanées. Leurs navigations ont commencé à une milliseconde d'intervalle ; le script s'est exécuté en 4.702 secondes. L'entrée de profil fermé et les deux copies avant lancement contenaient 147 entrées identiques avec le SHA-256 4faf879627b7cff3c24aa9cded30e2f892d7772323939232061e72a036d33829, et l'entrée est restée inchangée après l'exécution.
| Observation | Route A | Route B | Comparaison vérifiée |
|---|---|---|---|
| Adresse vue par l'observateur contrôlé | Sortie A masquée | Sortie B masquée | Toutes deux publiques et différentes ; une réponse HTTP 200 par route |
| Fuseau horaire | America/Toronto | America/Toronto | Identiques |
| Paramètres régionaux Intl | en-US | en-US | Identiques |
| Langues | ["en-US", "en"] | ["en-US", "en"] | Identiques |
| Préfixe SHA-256 du canvas | 7d6ee35f6cebcbf3 | 7d6ee35f6cebcbf3 | Empreintes complètes identiques |
| Jeton du navigateur dans l'agent utilisateur | HeadlessChrome/157.0.0.0 | HeadlessChrome/157.0.0.0 | Agents utilisateurs complets identiques |
WebRTC avec iceServers: [] | Un candidat hôte | Un candidat hôte | Collecte terminée ; aucun test de sortie STUN/UDP |
La sortie standard finale contenait "differentPublicExits": true et "maximumActive": 2. Les deux serveurs amont autorisés exigeaient des identifiants SOCKS5 ; le wrapper d'exécution a donc fourni des adaptateurs HTTP CONNECT privés limités à botbrowser.io:443. Chacun a admis exactement une connexion amont. Les adaptateurs ont refusé les destinations de fond et un CONNECT supplémentaire vers la même cible par branche. Chrome n'a pas négocié les identifiants SOCKS5 amont. Aucune adresse brute, aucun identifiant ni aucune adresse de candidat n'ont été conservés.
L'exécution externe enregistrée comprenait un argument supplémentaire de routage de bouclage Chromium. La version affichée omet cet argument inutile pour l'observateur hors bouclage. Son exécution finale s'est arrêtée à la vérification des ressources : 1.28 cœur CPU inactif estimé n'a fourni qu'un emplacement, si bien qu'aucune route n'a envoyé de requête à l'observateur. Le script HTTP local dans son format final a réussi les trois cas : les identifiants corrects ont renvoyé 200 et le marqueur, tandis que les identifiants incorrects et absents n'ont produit aucune requête transmise ni aucun accès à l'origine. Le tableau consigne la mesure externe antérieure réussie ; aucune mesure externe n'a été menée à terme après la suppression finale de l'argument de lancement.
L'observation WebRTC a sa propre portée
Un RTCPeerConnection local avec iceServers: [] peut relever les candidats hôtes exposés pendant cette exécution sans contacter un service STUN tiers. Consignez les types et le nombre de candidats, pas les adresses privées ni le SDP complet. Cela n'exerce pas un chemin STUN ou TURN public, ne prouve pas qu'UDP suit le proxy HTTP et n'établit pas qu'une adresse WebRTC publique ne peut pas apparaître avec une autre configuration. Un résultat vide ou des candidats hôtes masqués par mDNS ne constituent pas un verdict universel « sans fuite ».
Lire la comparaison des signaux avant de modifier le profil
Le résultat utile est une comparaison par signal à côté du résultat de sortie vérifié indépendamment. Le fuseau horaire et les paramètres régionaux Intl dépendent normalement d'entrées du navigateur ou du système d'exploitation ; un nouveau proxy ne réécrit pas à lui seul ces entrées. Le rendu canvas dépend aussi du dessin demandé, des polices et de l'environnement de rendu. Les cookies et les autres données stockées relèvent de l'instantané du profil et de l'activité ultérieure de l'application. Aucun de ces éléments ne doit être traité comme une conséquence automatique d'un mot de passe proxy ou d'une étiquette de pays.
Si la sortie a changé mais pas navigator.languages, vérifiez si le workflow exige réellement une autre langue. Les utilisateurs multilingues et les voyageurs existent ; un désaccord géographique ne constitue à lui seul ni une erreur ni un résultat de détection distante. Pour un test régional contrôlé, déclarez explicitement les paramètres régionaux et le fuseau horaire attendus, modifiez ce réglage comme variable séparée, puis répétez. Sinon, une rotation d'IP accompagnée de plusieurs changements d'environnement ne fournit aucune preuve de l'entrée ayant causé un résultat applicatif différent.
Des empreintes canvas identiques constituent une preuve plus limitée que « le site sait qu'il s'agit de la même machine ». Deux profils peuvent produire des pixels identiques, et un même environnement peut changer de rendu après une mise à jour de police ou de moteur de rendu. Une empreinte canvas différente ne prouve pas non plus une nouvelle identité. Conservez la version du navigateur et les paramètres du dessin afin de reproduire l'observation sans transformer une empreinte en attribution non étayée.
La configuration proxy documentée de BotBrowser accepte les identifiants via --proxy-server ; les réglages explicites du fuseau horaire, des paramètres régionaux et de la langue restent des entrées indépendantes. Ces contrôles peuvent prendre en charge un environnement déclaré lorsque le test l'exige. Ils ne peuvent pas corriger le mot de passe d'un fournisseur, certifier son attribution de pays ni promettre la manière dont une destination associe les sessions. Le cas de test Chrome standard ne teste ni BotBrowser, ni le modèle de risque d'une destination, ni un quelconque résultat d'autorisation de compte.
Choisissez la correction à partir de la première couche en échec. Corrigez une option proxy invalide avant de tester l'authentification ; résolvez un défi HTTP avant de comparer la sortie ; exigez deux sorties réelles avant d'interpréter les signaux A/B. Lorsque la route fonctionne et que seule une attente d'environnement diffère, changez l'environnement déclaré au lieu de renouveler les identifiants à répétition. Gardez les changements sans rapport du navigateur et du fournisseur hors de cette reproduction.
Questions fréquentes
Puppeteer accepte-t-il launch({ proxy }) ?
Ce n'est pas l'interface de lancement documentée de Puppeteer. Vérifiez BrowserContextOptions.proxyServer dans la version installée, ou passez l'argument de lancement proxy documenté de Chromium. Le démarrage réussi d'un navigateur ne prouve pas qu'une option JavaScript inconnue a configuré sa route réseau.
Pourquoi page.authenticate() ne corrige-t-il pas mon mot de passe SOCKS5 ?
Cette méthode fournit des identifiants HTTP. Chromium ne documente aucune méthode d'authentification prise en charge sur son chemin SOCKS5 natif. Vérifiez la méthode exigée par le proxy, puis utilisez un client compatible ou un adaptateur approuvé au lieu d'ajouter des en-têtes HTTP à une négociation SOCKS.
Un proxy HTTPS Puppeteer exige-t-il une autre URL de destination ?
Le schéma du proxy décrit la connexion au proxy ; l'URL cible décrit la destination. Un proxy HTTP peut établir un tunnel vers une cible HTTPS. Un proxy HTTPS ajoute TLS sur le segment entre navigateur et proxy, ce qui exige son propre certificat valide et une prise en charge adaptée.
Changer de proxy modifie-t-il l'empreinte de mon navigateur ?
Cela peut changer la sortie réseau observée. Il faut mesurer séparément si le fuseau horaire, la langue, le canvas, le stockage ou d'autres signaux changent. Ni une empreinte identique ni une IP différente n'établissent la décision d'identité d'une destination.
Sources
- Puppeteer : BrowserContextOptions
- Puppeteer : Page.authenticate
- Chromium : schémas proxy, authentification, DNS et exclusions de route directe
- Cloudflare : endpoints /cdn-cgi/ gérés
- W3C : connexion entre pairs WebRTC et configuration ICE
- BotBrowser : configuration proxy
- BotBrowser : alignement géographique du proxy
Conclusion pratique
Traitez l'authentification proxy Puppeteer, l'adresse de sortie de l'observateur et les réglages du navigateur comme des tests distincts. Une négociation prise en charge et une véritable réponse de la cible établissent davantage qu'un démarrage du navigateur ; deux sorties publiques validées établissent davantage que deux noms de proxy configurés. Comparez les entrées de profil inchangées et les signaux mesurés seulement après la réussite de ces vérifications. Appliquez un changement explicite d'environnement lorsque le workflow l'exige, puis vérifiez son effet sans en déduire l'anonymat ni le verdict d'un site.
Cet article fait partie du guide : Débuter avec l'automatisation du navigateur : carte initiale
Articles Connexes
Proxy Playwright : routes, authentification et signaux
RéseauConfiguration proxy du navigateur : guide SOCKS5, HTTP et HTTPS
RéseauAuthentification proxy du navigateur et hygiène des identifiants
RéseauCohérence proxy, DNS et WebRTC pour le navigateur
RéseauVérifier l’IP de sortie du proxy dans BotBrowser Launcher
IdentitéFuseau horaire, locale et langue dans les API du navigateur
Exécutez la même vérification dans BotBrowser
Chargez un profil, gardez dans le navigateur les signaux que vous venez de lire, puis confirmez le résultat sur votre propre machine avant le déploiement.