Puppeteer 代理排查:认证、SOCKS5 与浏览器信号
分层排查 Puppeteer 代理认证和 SOCKS5 失败,再用相同 profile 比较两个获授权出口的时区、语言与 canvas,区分网络出口变化和浏览器环境变化。
BotBrowser Team
Puppeteer 代理可能改变观测到的出口地址,同时保留浏览器的时区、语言和 canvas 输出。先区分连接失败和环境不符,两者需要修复的层不同。BotBrowser 可以随 profile 配置代理凭据以及明确的时区、区域和语言输入,但不能让上游服务商接受凭据,也不能保证目标站如何解释这些信号。
要点
- 通过 Puppeteer 或 Chromium 支持的输入配置代理,再验证请求实际走了该路由。
launch({ proxy: ... })不是 Puppeteer 文档规定的代理配置。 page.authenticate()提供 HTTP 凭据,不会给标准 Chrome 增加 SOCKS5 用户名和密码协商。- 经 HTTP 代理访问 HTTPS 目标,与通过 TLS 连接 HTTPS 代理是两种安排。代理协议和目标 URL 要分开判断。
- 只有自有观测端确认两个不同公网出口之后,才比较浏览器值。IP 变化不意味着时区、语言、canvas 或 cookie 变化。
目录
- 改凭据前先给失败分类
- 在本地复现 HTTP 与 SOCKS5 的边界
- 分开判断 HTTPS、DNS 与认证边界
- 用同一份不可变 profile 输入比较两个出口
- 改 profile 前先读逐信号对照
- 实际结论
改凭据前先给失败分类
保留最先失败的操作:浏览器启动、连接代理、HTTP 认证质询、目标 TLS、HTTP 响应,还是页面侧比较。407 Proxy Authentication Required 涉及代理,目标的 401 涉及该源站。没有 HTTP 响应的导航错误可能发生在任一应用作答之前。延长页面超时不能提供尚未支持的认证方法。
| 观测 | 首先检查 | 进入下一步前需要的证据 |
|---|---|---|
| 浏览器启动,但观测端报告直连出口 | 生效的代理作用域和直连排除项 | 预期代理收到该请求,且观测端报告它的出口 |
| HTTP 代理反复质询 | 凭据、质询协议和 page.authenticate() 时机 | 代理接受凭据,并转发预期请求 |
| SOCKS5 端点在其他客户端可用,在 Chrome 不可用 | SOCKS 协商的认证方法 | 客户端与服务端支持的握手,而非仅仅 TCP 端口可连接 |
| CONNECT 成功,随后导航失败 | 目标 TLS 和隧道转发 | 有效的目标连接和真实 HTTP 响应 |
| 出口变化,时区或语言没变 | 浏览器环境配置 | 测得的值及其预期来源,独立于 IP 判断 |
| 轮换后 canvas 输出相同 | Profile、渲染器和绘制输入 | 在受控条件下重复相同绘制;不能由此推导身份判断 |
Puppeteer context 选项列出了 proxyServer 和 proxyBypassList。Chromium 也可接受 --proxy-server=http://127.0.0.1:8080 这样的启动参数。这些配置入口作用域不同,都不是 Puppeteer 并不存在的顶层 proxy 启动选项。应核对已安装版本支持的 API,而不是把 Playwright 的启动对象复制进 Puppeteer。
在访问受保护页面之前设置 HTTP 凭据。Puppeteer 的 page.authenticate() 文档还说明,该操作会在内部启用请求拦截,可能影响性能。不要在一个其他变量尚未受控的对照中半途关闭认证来研究这点时序成本。避免统一添加 Proxy-Authorization 额外请求头:代理凭据与源站请求头的接收方不同。
产品专用参数和地理覆盖设置的约定见 BotBrowser 代理配置。这里区分的是标准 Puppeteer 浏览器的失败路径:路由被接受、凭据被接受,以及页面可见的环境值,是三项观测,不是一次配置成功就同时成立的结果。
在本地复现 HTTP 与 SOCKS5 的边界
本地 fixture 应让认证产生可见后果。HTTP 代理记录是否收到请求、凭据是否匹配,以及是否转发到自有目标。目标返回该用例专有的标记。登录或握手失败时,不能意外通过直连获得该标记。
分别运行凭据正确、未提供、错误的 HTTP 用例。成功用例需要同时看到代理接受请求和目标标记;拒绝用例需要看到代理质询,且没有转发的目标响应。只有浏览器错误字符串还不够:端口关闭、TLS 错误和不支持的代理方法都可能产生相同的导航失败。
对 SOCKS5,比较接受无认证方法的服务端和要求用户名密码认证的服务端,再给后者启用 page.authenticate() 重测。代理的 greeting 日志能区分不支持的方法和应用仅返回空页面。代理与目标都放在 loopback,并明确考虑 Chromium 的隐式本地地址排除规则,验证请求究竟是否到达代理。
不要从这些测试推断公网出口。两个本地 HTTP 监听器可能都通过同一网络接口转发。两次 SOCKS greeting 成功只能证明两个监听器作答,不能证明公网地址或服务商不同。后面的公网路由对照有单独的观测端断言。
下面仅复现 HTTP,创建自有 loopback 源站和需要密码的代理。保存为 local-auth.mjs,安装 [email protected],运行 CHROME=/absolute/path/to/chrome node local-auth.mjs。.test 目标在本地转发,代理拒绝其他所有目标。三个用例并发执行,被拒绝的用例不能触达源站。
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;
}
此脚本隔离 HTTP Basic 认证。SOCKS5 方法的边界仍需要捕获 SOCKS greeting:HTTP 成功并不是 SOCKS 密码测试。
分开判断 HTTPS、DNS 与认证边界
Chromium 的代理协议文档区分到代理的传输和目标请求的协议。HTTP 代理可以通过 CONNECT 承载 HTTPS 目标。HTTPS 代理还会用 TLS 保护浏览器到代理的连接。仅把目标 URL 改成 https:// 不会将 HTTP 代理变成 HTTPS 代理。
| 配置 | 浏览器到代理的路径 | 相关边界 |
|---|---|---|
| HTTP 代理,HTTPS 目标 | HTTP 代理连接,随后建立 CONNECT 隧道 | 代理认证和目标 TLS 仍各自独立 |
| HTTPS 代理,HTTPS 目标 | 到代理的 TLS,随后建立目标隧道 | 代理连接和目标连接都需要有效 TLS |
| 标准 Chrome SOCKS5 URL 请求 | 针对 TCP 请求的 SOCKS5 传输 | Chrome 文档规定代理侧主机名解析,且不支持 SOCKS5 认证方法 |
最后一行是浏览器实现的边界,并不是说 SOCKS5 协议没有认证。Python 或 Node SOCKS 客户端可以支持标准 Chrome 不会协商的用户名密码方法。修改 page.authenticate() 或把密码写进 Chromium 代理参数,都不会补上这个协议实现。反过来,Chrome 的 SOCKS5 路径本来就委托代理解析目标主机名;照搬其他客户端的 socks5h:// 写法,不能替代核对 Chrome 文档规定的协议。
如果获授权的 SOCKS5 服务要求凭据,应选择支持该方法的客户端,或使用上游客户端支持该方法的获准本地适配器。在实验中记录适配器:Chrome 此时连接的是适配器,后者执行带认证的 SOCKS 连接。保持监听器私有,限制目标,隐藏凭据。适配器运行成功不代表 Chrome 原生支持 SOCKS5 密码。
代理 TCP URL 的 DNS 解析也不能完整说明 WebRTC。Chromium 文档中的 SOCKS5 URL 路径不转发 UDP。因此,页面请求到达预期出口,不能证明后续 STUN 或 TURN 操作会走哪里。应按各自获准的网络策略测试这些路径,而非从 CONNECT 或页面 HTTP 状态推断。
用同一份不可变 profile 输入比较两个出口
保持浏览器二进制、启动配置、profile 输入、自有端点和请求方法相同,并在两轮记录相同字段。只更换获授权的代理路由。Launcher 出口验证流程说明图形界面下相应的路由证据要求;Puppeteer 对照也需要明确这些变量。
普通 Chromium user-data 目录可写,并受进程锁约束。并发 A/B 测试前,先关闭基准浏览器,再把不可变快照逐字节复制到两个新的测试目录,并验证启动前哈希。这样可以比较同一份 profile 输入走两条路由的结果,又不让两个进程打开同一个可写目录。两个互不相关的空目录并不具备相同的存储起点。
使用工作流负责人控制的域名上的 HTTPS 观测端点。该域名由 Cloudflare 提供服务时,文档规定的 /cdn-cgi/trace 端点可以提供范围明确的网络观测;其他部署可自行返回对端地址。相信地址字段前,先验证状态、响应格式和预期主机。真实地址应保密,公开证据只报告路由标签。
要求两个有效的公网地址观测,并断言二者不同。任一路由失败、返回私有地址,或与另一条路由出口相同,都不构成双出口对照。服务商的国家标签、不同端口或 session 名称不能替代这项检查。适配器也必须计数并限制上游请求,避免浏览器后台流量悄悄扩大测试范围。
取得已验证的响应之后,再采集页面可见值。两条路径使用相同绘制尺寸、文本、字体请求和 canvas 比较方法。分别记录 Intl.DateTimeFormat().resolvedOptions().timeZone、解析得到的 Intl locale、navigator.languages 和 user agent。为复审核对保留原值,两条路径都结束后再算相等性,不要预设哪些值应保持稳定。
将这个独立对照保存为 two-exits.mjs,与相同版本的 Puppeteer 安装放在一起。在环境中设置 CHROME、PROXY_A、PROXY_B、OBSERVER_URL 和 PROFILE_DIR,再运行 node two-exits.mjs --pool 20。观测端约定是 HTTPS、HTTP 200、text/plain 和一行 ip=,与自有 Cloudflare trace 端点一致。Profile 目录所属浏览器必须完全关闭;锁文件检查不能替代关闭浏览器。HTTP 代理可带凭据;SOCKS5 输入不得包含凭据。需要认证的 SOCKS5 上游必须使用另行获准的适配器。
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;
});
资源规则为每个浏览器预留一个估算空闲 CPU 核和 1 GiB 可用内存。不足两个并发槽位时,脚本会拒绝运行,不会改为串行对照。Profile 副本保留在操作系统临时目录供检查。地址检查保守地拒绝特殊用途网段,不做地理定位查询。每轮 HMAC 密钥仅在内存中保留,因此打印的摘要只能在该次执行内部比较相等性。
双路由实测对照
2026 年 11 月 11 日,对照在 macOS arm64、Darwin 24.6.0 上使用 Node 26.9.0、Puppeteer Core 25.10.0 和 Chrome 157.0.8103.79 执行。即时采样记录了 2.21 个估算空闲 CPU 核和 12,294,160,384 字节可用内存,将请求的 20 浏览器池缩减为两条同时运行的路径。两次导航开始时间相差一毫秒,脚本耗时 4.702 秒。已关闭的 profile 输入与两份启动前副本各有 147 个一致条目,SHA-256 为 4faf879627b7cff3c24aa9cded30e2f892d7772323939232061e72a036d33829,之后输入保持未变。
| 观测 | 路由 A | 路由 B | 已验证的对照 |
|---|---|---|---|
| 自有观测端地址 | 隐去的出口 A | 隐去的出口 B | 均为公网,且不同;每条路由一个 HTTP 200 响应 |
| 时区 | America/Toronto | America/Toronto | 相同 |
| Intl locale | en-US | en-US | 相同 |
| 语言 | ["en-US", "en"] | ["en-US", "en"] | 相同 |
| Canvas SHA-256 前缀 | 7d6ee35f6cebcbf3 | 7d6ee35f6cebcbf3 | 完整哈希相同 |
| User agent 浏览器标记 | HeadlessChrome/157.0.0.0 | HeadlessChrome/157.0.0.0 | 完整 user agent 相同 |
iceServers: [] 下的 WebRTC | 一个 host candidate | 一个 host candidate | 采集完成;未测试 STUN/UDP 出口 |
最终 stdout 包含 "differentPublicExits": true 和 "maximumActive": 2。两个获授权上游都要求 SOCKS5 凭据,因此执行包装层提供了仅允许 botbrowser.io:443 的私有 HTTP CONNECT 适配器;各适配器只放行一次上游连接,并拒绝后台目标以及每条路径一次额外的同目标 CONNECT。Chrome 没有协商上游 SOCKS5 凭据。没有保留真实地址、凭据或 candidate 地址。
已记录的外网执行还带有一个额外的 Chromium loopback 路由参数。展示的版本针对非 loopback 观测端删除了这个不必要的参数。最终执行在资源检查处停止:1.28 个估算空闲 CPU 核只得到一个槽位,两条路由都没有发出观测请求。最终排版后的本地 HTTP 脚本通过全部三个用例:凭据正确时返回 200 和标记,凭据错误或缺失时转发请求和源站命中均为零。表格记录的是先前成功的外网测量;最后删除启动参数后的版本没有已完成的外网测量。
WebRTC 观测有独立范围
本地 RTCPeerConnection 使用 iceServers: [],可以记录该轮暴露的 host candidate,而不联系第三方 STUN 服务。记录 candidate 类型和数量,不记录私有地址或完整 SDP。这没有测试公网 STUN 或 TURN 路径,不能证明 UDP 跟随 HTTP 代理,也不能证明其他配置下不会出现公网 WebRTC 地址。结果为空或 host candidate 被 mDNS 隐藏,都不是通用的“无泄露”结论。
改 profile 前先读逐信号对照
有用的结果是:把逐信号对照与独立验证的出口结果并列。时区和 Intl locale 通常来自浏览器或操作系统输入,新代理本身不会重写这些输入。Canvas 输出还依赖绘制请求、字体和渲染环境。Cookie 等存储来自 profile 快照及随后的应用活动。代理密码或国家标签不应被当作自动派生出这些值的依据。
如果出口变化而 navigator.languages 未变,应先问工作流是否确实要求另一种语言。多语言用户和旅行者都存在,地理不一致本身既不是错误,也不是远端检测结论。受控区域测试应明确声明预期 locale 和时区,把该设置作为单独变量更改后重测。否则,一边轮换 IP 一边修改多个环境值,就无法判断哪个输入导致应用结果不同。
Canvas 哈希相同的证据范围,比“站点知道这是同一台机器”窄得多。两个 profile 可以绘出相同像素;同一个环境也可能在字体或渲染器更新后改变输出。Canvas 哈希变化同样不能证明新身份。保留浏览器版本和绘制输入,才能复现实测,而不是把一个哈希变成缺乏依据的归因。
BotBrowser 文档规定的代理配置通过 --proxy-server 接受凭据,明确的时区、区域和语言覆盖仍是独立输入。测试有此要求时,这些控制可以支持已声明的环境。它们不能修正服务商密码、认证其国家分配,也不能承诺目标如何关联会话。标准 Chrome fixture 没有测试 BotBrowser、目标风险模型或任何账号授权结果。
根据最先失败的层选择修复。先纠正无效代理选项,再测认证;先解决 HTTP 质询,再比较出口;先取得两个真实出口,再解释 A/B 信号。路由正常而仅环境预期不符时,应修改声明的环境,而不是反复轮换凭据。无关的浏览器和服务商改动应留在该复现之外。
常见问题
Puppeteer 接受 launch({ proxy }) 吗?
这不是 Puppeteer 文档规定的启动入口。核对已安装版本的 BrowserContextOptions.proxyServer,或传入 Chromium 文档规定的代理启动参数。浏览器启动成功,不能证明未知 JavaScript 选项配置了网络路由。
为什么 page.authenticate() 修不好 SOCKS5 密码?
它提供 HTTP 凭据。Chromium 文档说明其原生 SOCKS5 路径没有支持的认证方法。确认代理要求的方法,再用支持的客户端或获准适配器,而不是给 SOCKS 握手添加 HTTP 头。
Puppeteer HTTPS 代理需要不同的目标 URL 吗?
代理协议描述到代理的连接,目标 URL 描述目的地。HTTP 代理可以为 HTTPS 目标建立隧道。HTTPS 代理额外给浏览器到代理这一段增加 TLS,需要它自己的有效证书和相应支持。
换代理会改变浏览器指纹吗?
它可能改变观测到的网络出口。时区、语言、canvas、存储等信号是否变化,需要分别测量。哈希匹配或 IP 不同,都不能建立目标站的身份判断。
来源
- Puppeteer:BrowserContextOptions
- Puppeteer:Page.authenticate
- Chromium:代理协议、认证、DNS 与直连排除项
- Cloudflare:托管的 /cdn-cgi/ 端点
- W3C:WebRTC peer connection 与 ICE 配置
- BotBrowser:代理配置
- BotBrowser:代理地理对齐
实际结论
将 Puppeteer 代理认证、观测端出口地址和浏览器侧设置视为不同测试。受支持的握手加真实目标响应,比浏览器启动更有证明力;两个已验证公网出口,也比两个代理名称更有证明力。完成这些检查后,再比较未变的 profile 输入与实测信号。工作流需要时,明确变更环境并验证效果,不据此推断匿名性或站点结论。
本文属于指南: 浏览器自动化入门:首次运行地图