Volver al Centro de conocimiento
Primeros pasos

Proxy en Puppeteer: autenticación, SOCKS5 y señales

Diagnostica fallos de autenticación y SOCKS5 en Puppeteer; compara dos salidas autorizadas con el mismo perfil, zona horaria, idioma y canvas.

BotBrowser Team

Documentación

Quieres la documentación estructurada de Primeros pasos?

Este artículo forma parte de la biblioteca editorial. Para pasos de configuración, material de referencia y actualizaciones continuas, entra en la sección de docs.

Un proxy de Puppeteer puede cambiar la dirección de salida observada mientras el navegador conserva su zona horaria, idioma y salida de canvas. Primero hay que distinguir un fallo de conexión de una discrepancia del entorno: la corrección corresponde a una capa distinta. BotBrowser permite configurar credenciales de proxy y valores explícitos de zona horaria, configuración regional e idioma junto con un perfil, pero no puede hacer que un proveedor acepte las credenciales ni garantizar cómo interpreta un destino esas señales.

Resumen

  • Configura una entrada de proxy compatible de Puppeteer o Chromium y verifica que la petición haya seguido realmente esa ruta. launch({ proxy: ... }) no es la configuración de proxy documentada de Puppeteer.
  • page.authenticate() proporciona credenciales HTTP. No añade negociación de usuario y contraseña SOCKS5 a Chrome estándar.
  • Un destino HTTPS a través de un proxy HTTP y una conexión TLS con un proxy HTTPS son configuraciones distintas. Mantén separado el esquema del proxy de la URL de destino.
  • Compara los valores del navegador solo después de que un observador propio confirme dos salidas públicas distintas. Un cambio de IP no implica cambios en la zona horaria, el idioma, canvas o las cookies.

Contenido

Clasificar el fallo antes de cambiar las credenciales

Conserva la primera operación que falla: inicio del navegador, conexión con el proxy, desafío HTTP, TLS del destino, respuesta HTTP o comparación en la página. Una respuesta 407 Proxy Authentication Required corresponde al proxy. Un 401 del destino corresponde a ese origen. Un error de navegación sin respuesta HTTP puede haber ocurrido antes de que cualquiera de las aplicaciones pudiera responder. Aumentar el tiempo de espera de la página no aporta un método de autenticación incompatible.

ObservaciónPrimera comprobaciónPrueba necesaria antes de continuar
El navegador arranca, pero el observador informa de la ruta directaÁmbito efectivo del proxy y exclusiones de la ruta directaEl proxy previsto recibe esta petición y el observador informa de su salida
El proxy HTTP solicita autenticación repetidamenteCredenciales, esquema del desafío y momento de page.authenticate()El proxy acepta las credenciales y reenvía la petición prevista
El endpoint SOCKS5 funciona con otro cliente, pero no con ChromeMétodos de autenticación SOCKS negociadosUn handshake compatible entre cliente y servidor, no solo un puerto TCP abierto
CONNECT tiene éxito, pero después falla la navegaciónTLS del destino y reenvío del túnelUna conexión válida con el destino y una respuesta HTTP real
La salida cambia, pero no la zona horaria o el idiomaConfiguración del entorno del navegadorEl valor medido y su fuente prevista, independientemente de la IP
La salida de canvas es idéntica tras la rotaciónPerfil, renderizador y entradas del dibujoUna repetición controlada del mismo dibujo; no se deduce ningún veredicto de identidad

Las opciones de contexto de Puppeteer incluyen proxyServer y proxyBypassList. Chromium también puede recibir un argumento de inicio como --proxy-server=http://127.0.0.1:8080. Son interfaces de configuración con ámbitos distintos; ninguna es la inexistente opción de inicio proxy de primer nivel de Puppeteer. Confirma la API que admite la versión instalada en lugar de copiar en Puppeteer un objeto de inicio de Playwright.

Establece las credenciales HTTP antes de la navegación protegida. La documentación de page.authenticate() de Puppeteer también indica que activa internamente la interceptación de peticiones, lo que puede afectar al rendimiento. No investigues ese coste temporal desactivando la autenticación a mitad de una comparación sin controlar las demás variables. Evita añadir indiscriminadamente una cabecera extra Proxy-Authorization: las credenciales del proxy y las cabeceras de la petición al origen tienen destinatarios distintos.

Para el contrato de los flags específicos del producto y de las modificaciones geográficas, consulta la configuración de proxy de BotBrowser. Aquí la diferencia es la secuencia de fallos en un navegador estándar controlado por Puppeteer: una ruta aceptada, unas credenciales aceptadas y un valor del entorno visible en la página son tres observaciones, no un único evento de configuración correcto.

Reproducir localmente los límites de HTTP y SOCKS5

Un caso de prueba local debe dar a la autenticación una consecuencia visible. El proxy HTTP registra si recibió una petición, si coincidieron las credenciales y si reenvió la petición al destino propio. El destino devuelve un marcador específico del caso. Un inicio de sesión o handshake fallido no debe producir accidentalmente ese marcador mediante una conexión directa.

Usa casos HTTP separados para credenciales correctas, ausentes e incorrectas. El caso correcto necesita tanto una petición aceptada por el proxy como el marcador del destino. Los casos rechazados necesitan un desafío del proxy y ninguna respuesta reenviada del destino. Un mensaje de error del navegador no basta: el mismo fallo de navegación puede proceder de un puerto cerrado, de TLS incorrecto o de un método de proxy incompatible.

Para SOCKS5, compara un servidor que acepte el método sin autenticación con otro que exija autenticación mediante usuario y contraseña. Repite el segundo con page.authenticate() activado. El registro del saludo del proxy distingue un método incompatible de una aplicación que simplemente devolvió una página vacía. Mantén el proxy y el destino en loopback y ten en cuenta explícitamente la exclusión implícita de direcciones locales de Chromium al comprobar si el tráfico llegó al proxy.

No deduzcas una salida pública de estas pruebas. Dos listeners HTTP locales pueden reenviar a través de la misma interfaz de red. Dos saludos SOCKS correctos demuestran que respondieron dos listeners; no demuestran direcciones públicas ni proveedores distintos. La comparación de rutas públicas que aparece más abajo tiene una aserción independiente del observador.

La siguiente reproducción, exclusiva de HTTP, crea su propio origen de loopback y un proxy protegido por contraseña. Guárdala como local-auth.mjs, instala [email protected] y ejecuta CHROME=/absolute/path/to/chrome node local-auth.mjs. El destino .test se reenvía localmente; el proxy rechaza cualquier otro destino. Los tres casos se ejecutan de forma concurrente y un caso rechazado debe dejar el origen sin peticiones.

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;
}

Este script aísla la autenticación HTTP Basic. El límite del método SOCKS5 sigue requiriendo capturar un saludo SOCKS: el éxito de HTTP no es una prueba de contraseña SOCKS.

Separar los límites de HTTPS, DNS y autenticación

La documentación de protocolos de proxy de Chromium distingue el transporte hacia el proxy del esquema del destino solicitado. Un proxy HTTP puede transportar un destino HTTPS mediante CONNECT. Un proxy HTTPS protege además con TLS la conexión entre navegador y proxy. Cambiar únicamente la URL de destino a https:// no convierte un proxy HTTP en uno HTTPS.

ConfiguraciónTrayecto entre navegador y proxyLímite relevante
Proxy HTTP, destino HTTPSConexión con el proxy HTTP seguida de un túnel CONNECTLa autenticación del proxy y el TLS del destino siguen separados
Proxy HTTPS, destino HTTPSTLS con el proxy y después el túnel hacia el destinoTanto la conexión con el proxy como la conexión con el destino necesitan TLS válido
Petición de URL mediante SOCKS5 de Chrome estándarTransporte SOCKS5 para peticiones TCPChrome documenta resolución de nombres en el proxy y ningún método de autenticación SOCKS5 compatible

La última fila es un límite de la implementación del navegador, no una afirmación de que el protocolo SOCKS5 carezca de autenticación. Un cliente SOCKS de Python o Node puede admitir métodos de usuario y contraseña que Chrome estándar no negocia. Cambiar page.authenticate() o incluir una contraseña en un argumento de proxy de Chromium no añade esa implementación del protocolo. A la inversa, la ruta SOCKS5 de Chrome ya delega en el proxy la resolución del nombre del destino; copiar la notación socks5h:// de otro cliente no sustituye la comprobación del esquema documentado de Chrome.

Si un servicio SOCKS5 autorizado exige credenciales, elige un cliente que admita ese método o usa un adaptador local aprobado cuyo cliente ascendente lo admita. Registra el adaptador en el experimento: Chrome se conecta entonces al adaptador y este establece la conexión SOCKS autenticada. Mantén privado su listener, restringe los destinos y oculta las credenciales. Una ejecución correcta mediante adaptador no demuestra que Chrome admita de forma nativa contraseñas SOCKS5.

La resolución DNS de una URL TCP enviada por proxy tampoco describe por completo WebRTC. La ruta de URL SOCKS5 documentada de Chromium no retransmite UDP. Por tanto, que una petición de página alcance la salida prevista no certifica por dónde viajaría una operación posterior de STUN o TURN. Prueba esas rutas con su propia política de red aprobada en lugar de extrapolar a partir de CONNECT o del estado HTTP de una página.

Comparar dos salidas con una entrada de perfil inmutable

Mantén constantes el binario del navegador, los ajustes de inicio, la entrada del perfil, el endpoint propio y el método de petición, y registra los mismos campos en ambas ejecuciones. Cambia solo la ruta de proxy autorizada. El flujo de verificación de salida de Launcher cubre esa misma disciplina de pruebas de ruta en una interfaz gráfica; en una comparación con Puppeteer deben seguir siendo explícitas las mismas variables.

Un directorio de datos de usuario normal de Chromium permite escrituras y está bloqueado por el proceso que lo utiliza. Para ejecutar ramas A/B concurrentes, cierra primero el navegador de referencia y crea copias idénticas byte a byte de esa instantánea inmutable en dos directorios de prueba nuevos. Verifica los hashes antes del inicio. Así se compara una misma entrada de perfil en dos rutas sin abrir el mismo directorio modificable desde dos procesos. Dos directorios vacíos sin relación entre sí no proporcionarían el mismo estado inicial del almacenamiento.

Usa un endpoint de observación HTTPS en un dominio controlado por el responsable del flujo. Cuando Cloudflare sirve ese dominio, su endpoint documentado /cdn-cgi/trace puede ofrecer una observación acotada de la red; otro despliegue puede exponer su propia respuesta con la dirección del par. Valida el estado, el formato de respuesta y el host previsto antes de confiar en un campo de dirección. Mantén privadas las direcciones sin ocultar y publica etiquetas de ruta en las pruebas.

Exige dos observaciones válidas de direcciones públicas y comprueba mediante una aserción que sean distintas. Si una ruta falla, devuelve una dirección privada o produce la misma salida que la otra, el resultado no es una comparación de dos salidas. Una etiqueta de país del proveedor, un puerto diferente o un nombre de sesión no pueden sustituir esta comprobación. Un adaptador también debe contar y restringir sus peticiones ascendentes para que el tráfico de fondo del navegador no amplíe silenciosamente la prueba.

Recoge los valores visibles en la página solo después de la respuesta verificada. Usa en ambas ramas las mismas dimensiones de dibujo, texto, solicitud de fuente y método de comparación de canvas. Registra por separado Intl.DateTimeFormat().resolvedOptions().timeZone, la configuración regional resuelta de Intl, navigator.languages y el agente de usuario. Conserva los valores originales para revisarlos; calcula la igualdad una vez terminadas ambas ramas en lugar de dar por hecho qué debería permanecer estable.

Guarda esta comparación independiente como two-exits.mjs junto a la misma versión instalada de Puppeteer. Establece CHROME, PROXY_A, PROXY_B, OBSERVER_URL y PROFILE_DIR en el entorno y ejecuta node two-exits.mjs --pool 20. El contrato del observador exige HTTPS, HTTP 200, text/plain y una línea ip=, como en un endpoint de traza de Cloudflare propio. El directorio del perfil debe pertenecer a un navegador completamente cerrado; la comprobación del archivo de bloqueo no sustituye su cierre. Los proxies HTTP pueden incluir credenciales; las entradas SOCKS5 no deben incluirlas. Un servidor SOCKS5 ascendente autenticado necesita un adaptador aprobado por separado.

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 regla de recursos reserva un núcleo de CPU inactivo estimado y 1 GiB de memoria disponible por navegador. Rechaza convertir la comparación en una ejecución secuencial cuando no hay dos plazas disponibles. Las copias del perfil permanecen en el directorio temporal del sistema operativo para su inspección. La comprobación de direcciones rechaza de forma conservadora los rangos de uso especial; no es una consulta de geolocalización. La clave HMAC de cada ejecución permanece en memoria, por lo que los resúmenes impresos solo permiten comprobar igualdad dentro de esa ejecución.

Comparación medida de dos rutas

El 11 de noviembre de 2026, la comparación se ejecutó con Node 26.9.0, Puppeteer Core 25.10.0 y Chrome 157.0.8103.79 en macOS arm64, Darwin 24.6.0. La nueva muestra registró 2.21 núcleos de CPU inactivos estimados y 12,294,160,384 bytes de memoria disponible, lo que redujo el pool solicitado de 20 a dos ramas simultáneas. Sus navegaciones empezaron con un milisegundo de diferencia; la ejecución del script tardó 4.702 segundos. La entrada del perfil cerrado y ambas copias previas al inicio contenían 147 entradas coincidentes con SHA-256 4faf879627b7cff3c24aa9cded30e2f892d7772323939232061e72a036d33829, y la entrada permaneció sin cambios después.

ObservaciónRuta ARuta BComparación verificada
Dirección observada por el observador propioSalida A ocultaSalida B ocultaAmbas públicas y distintas; una respuesta HTTP 200 por ruta
Zona horariaAmerica/TorontoAmerica/TorontoIguales
Configuración regional de Intlen-USen-USIguales
Idiomas["en-US", "en"]["en-US", "en"]Iguales
Prefijo SHA-256 de canvas7d6ee35f6cebcbf37d6ee35f6cebcbf3Hashes completos iguales
Token del navegador en el agente de usuarioHeadlessChrome/157.0.0.0HeadlessChrome/157.0.0.0Agentes de usuario completos iguales
WebRTC con iceServers: []Un candidato de hostUn candidato de hostRecopilación finalizada; sin prueba de salida STUN/UDP

La salida estándar final incluyó "differentPublicExits": true y "maximumActive": 2. Ambos servidores ascendentes autorizados exigían credenciales SOCKS5, por lo que el wrapper de ejecución proporcionó adaptadores HTTP CONNECT privados restringidos a botbrowser.io:443; cada uno admitió exactamente una conexión ascendente. Los adaptadores rechazaron destinos de fondo y un CONNECT adicional al mismo destino en cada rama. Chrome no negoció las credenciales SOCKS5 ascendentes. No se conservó ninguna dirección sin ocultar, credencial ni dirección de candidato.

La ejecución externa registrada incluía un argumento adicional de enrutamiento loopback de Chromium. La versión mostrada omite ese argumento innecesario para el observador que no está en loopback. Su ejecución final se detuvo en la comprobación de recursos: 1.28 núcleos de CPU inactivos estimados dieron una sola plaza, por lo que ninguna ruta hizo una petición al observador. El script HTTP local con el formato final superó los tres casos: las credenciales correctas devolvieron 200 y el marcador, mientras que las incorrectas y las ausentes produjeron cero peticiones reenviadas y cero accesos al origen. La tabla registra la medición externa anterior que sí terminó correctamente; no hay una medición externa completada tras eliminar el argumento de inicio final.

La observación de WebRTC tiene su propio alcance

Un RTCPeerConnection local con iceServers: [] puede registrar los candidatos de host expuestos en esa ejecución sin contactar con un servicio STUN de terceros. Registra tipos y cantidades de candidatos, no direcciones privadas ni el SDP completo. Esto no ejercita una ruta pública STUN o TURN, no demuestra que UDP siga al proxy HTTP ni establece que no pueda aparecer una dirección pública de WebRTC con otra configuración. Un resultado de candidatos de host vacío u oculto mediante mDNS no es un veredicto universal de «sin fugas».

Interpretar las señales antes de cambiar el perfil

Comparación registrada de clones idénticos de un perfil cerrado mediante dos adaptadores de HTTP CONNECT a SOCKS5: el observador propio ve salidas distintas mientras coinciden la zona horaria, los idiomas y canvas medidos; ICE de host no demuestra la salida de STUN o UDP.

El resultado útil es una comparación por señal junto al resultado de salida comprobado de forma independiente. La zona horaria y la configuración regional de Intl suelen depender de entradas del navegador o del sistema operativo; un proxy nuevo no reescribe por sí solo esas entradas. La salida de canvas depende además del dibujo solicitado, las fuentes y el entorno de renderizado. Las cookies y el resto del almacenamiento pertenecen a la instantánea del perfil y a la actividad posterior de la aplicación. Ninguno de estos elementos debe tratarse como un derivado automático de una contraseña de proxy o de una etiqueta de país.

Si la salida cambió pero navigator.languages no, comprueba si el flujo exige realmente otro idioma. Existen usuarios multilingües y viajeros; una discrepancia geográfica por sí sola no es un error ni un resultado de detección remota. Para una prueba regional controlada, declara explícitamente la configuración regional y la zona horaria esperadas, cambia ese ajuste como variable separada y repite. De lo contrario, una rotación de IP junto con varios cambios de entorno no permite saber qué entrada causó un resultado distinto en la aplicación.

Que los hashes de canvas sean idénticos es una prueba más limitada que «el sitio sabe que es la misma máquina». Dos perfiles pueden producir píxeles idénticos, y un mismo entorno puede cambiar su salida después de actualizar una fuente o el renderizador. Un hash de canvas distinto tampoco demuestra una identidad nueva. Conserva la versión del navegador y las entradas del dibujo para poder reproducir la observación sin convertir un hash en una atribución sin fundamento.

La configuración de proxy documentada de BotBrowser acepta credenciales mediante --proxy-server; las modificaciones explícitas de zona horaria, configuración regional e idioma siguen siendo entradas independientes. Esos controles pueden respaldar un entorno declarado cuando la prueba lo exige. No pueden corregir la contraseña de un proveedor, certificar el país que le asigna ni prometer cómo vincula las sesiones un destino. El caso de prueba de Chrome estándar no prueba BotBrowser, el modelo de riesgo de un destino ni ningún resultado de autorización de cuentas.

Elige la corrección a partir de la primera capa que falla. Corrige una opción de proxy inválida antes de probar la autenticación; resuelve un desafío HTTP antes de comparar la salida; exige dos salidas reales antes de interpretar las señales A/B. Cuando la ruta funcione y solo difiera una expectativa del entorno, cambia el entorno declarado en lugar de rotar repetidamente las credenciales. Mantén fuera de la reproducción los cambios del navegador y del proveedor que no estén relacionados.

Preguntas frecuentes

¿Admite Puppeteer launch({ proxy })?

Esa no es la interfaz de inicio documentada de Puppeteer. Comprueba BrowserContextOptions.proxyServer en la versión instalada o pasa el argumento de inicio de proxy documentado de Chromium. Que el navegador arranque correctamente no demuestra que una opción JavaScript desconocida haya configurado su ruta de red.

¿Por qué page.authenticate() no corrige mi contraseña SOCKS5?

Proporciona credenciales HTTP. Chromium no documenta ningún método de autenticación compatible en su ruta SOCKS5 nativa. Confirma el método que exige el proxy y usa después un cliente compatible o un adaptador aprobado, en lugar de añadir cabeceras HTTP a un handshake SOCKS.

¿Necesita un proxy HTTPS de Puppeteer una URL de destino diferente?

El esquema del proxy describe la conexión con el proxy; la URL de destino describe el destino. Un proxy HTTP puede crear un túnel hacia un destino HTTPS. Un proxy HTTPS añade TLS en el tramo entre navegador y proxy, lo que necesita su propio certificado válido y compatibilidad.

¿Cambia la huella del navegador al cambiar el proxy?

Puede cambiar la salida de red observada. Saber si cambian la zona horaria, el idioma, canvas, el almacenamiento u otras señales requiere mediciones separadas. Ni un hash coincidente ni una IP diferente establecen la decisión de identidad de un destino.

Fuentes

Conclusión práctica

Trata la autenticación de proxy de Puppeteer, la dirección de salida del observador y los ajustes del navegador como pruebas separadas. Un handshake compatible y una respuesta real del destino establecen más que el arranque del navegador; dos salidas públicas validadas establecen más que dos nombres de proxy configurados. Compara entradas de perfil sin cambios y señales medidas solo después de superar esas comprobaciones. Aplica un cambio explícito de entorno cuando el flujo lo exija y verifica después su efecto sin deducir anonimato ni el veredicto de un sitio.

#Puppeteer#Autenticación De Proxy#SOCKS5#Identidad Del Navegador

Este artículo forma parte de la guía: Primeros pasos con automatización de navegador: mapa inicial

Ejecuta la misma comprobación en BotBrowser

Carga un perfil, mantén dentro del navegador las señales que acabas de leer y confirma el resultado en tu propia máquina antes del despliegue.