WebRTC 地址泄露与浏览器隐私检测
本页说明 MyIP 如何通过浏览器 WebRTC 的 ICE 候选收集,展示不同 STUN 服务观测到的地址,以及如何处理浏览器禁用 WebRTC、检测失败和结果汇总。该功能提供地址暴露的诊断证据,而不是对浏览器全部隐私能力的综合认证。
目的与范围
本文覆盖 WebRtcTest 的检测入口、STUN 连接、候选地址筛选、归属信息补全、状态语义、命令与事件、超时和资源清理。IP 定位服务内部实现、DNS 泄露检测、全局刷新编排和报告生成规则属于相邻能力,不在本页展开。
**证据范围:**本页依据 WebRtcTest.vue 的脚本实现。未进一步读取模板、Store、useMaxmind、事件总线或专门的 WebRTC 测试实现,因此不会推断它们的持久化策略、缓存、网络端点及测试覆盖率。
概述:这项检测能说明什么
组件为四个 STUN 服务分别创建 RTCPeerConnection,创建数据通道和本地 Offer,监听 ICE 候选。只有类型为 srflx 或 prflx 且能提取出 IP 的候选才构成成功结果;浏览器自身产生的 host 候选不证明 STUN 请求成功,不能作为“STUN IP”展示。
结果需要按以下方式解读:
| 结果 | 实现中的含义 | 不能直接推出的结论 |
|---|---|---|
| 得到 IP | 接受了一个反射候选,并从候选字符串提取地址 | 该地址一定绕过了代理,或一定不同于 HTTP 出口 |
StatusUnavailable | WebRTC API 缺失,或运行时呈现明确的阻止/替换行为 | 浏览器所有隐私风险都已消除 |
StatusError | 没有及时得到合格候选,或出现其他异常 | 一定启用了 mDNS,或一定不存在泄露 |
| IP 成功、归属字段失败 | 地址已得到,但归属查询没有返回数据 | STUN 本身失败 |
已读取的检测代码没有将 STUN IP 与 HTTP 出口 IP 进行比较,也没有生成独立的“泄露/不泄露”布尔判定。natType 的实现是对 ICE 候选类型的翻译映射,不是完整的 NAT 映射/过滤行为探测。
架构与依赖
Source: WebRtcTest.vue
这一结构把“是否拿到地址”和“地址归属查询”分为两个阶段:IP 可以先成为有效结果,归属信息随后补齐。报告快照则由整轮检测统一发出,不由每个候选事件分别发布。
WebRtcTest 还依赖 useMainStore 记录挂载/完成状态,依赖 vue-i18n 生成显示状态,使用 trackEvent 记录刷新操作。隐私显示层初始化了 createMaskGate(t),但本页未读取模板,不能据此断言所有字段或 SDP 日志均已脱敏。
检测生命周期与真实控制流
1. 挂载不等于执行检测
onMounted 只调用 store.setMountingStatus('WebRTC', true)。脚本中的命令入口为 webrtc:run,其处理器先等待下一次 webrtc:finished,再启动检测;这种顺序可以覆盖“API 不可用时立即完成”的同步事件路径。
1useAppCommand('webrtc:run', ({ isRefresh = false } = {}) => {
2 const finished = waitForAppEvent('webrtc:finished');
3 checkAllWebRTC(isRefresh);
4 return finished;
5});Source: WebRtcTest.vue
isRefresh 默认为 false,为真时记录 trackEvent('Section', 'RefreshClick', 'WebRTC')。它不改变 STUN 超时,也不切换服务器列表。自动运行偏好的具体调度位置未在本页核验。
2. API 缺失时直接形成结果
组件初始化时通过 typeof RTCPeerConnection === 'function' 保存 isWebRtcAvailable。每轮开始设置 isStarted = true;若 API 缺失,则四张服务器记录统一写入 StatusUnavailable,并设置 natTypeCode = 'unavailable',随后更新 Store 并发布完成事件,不创建连接。
这是一种明确的产品语义:对于地址泄露检测,禁用 WebRTC 是保护性状态。toneOf 将该状态映射为 ok-fast,而不是普通错误;isFieldPending 同时把不可用状态列为无有效详情值的情况。
3. 四个服务器并行探测
正常路径遍历 stunServers,将地址、类型、国家和组织重置为 StatusTesting,清空 country_code,将 natTypeCode 设为 undefined,然后立即调用各自的 checkSTUNServer(server)。
单服务器探测会:
- 重建
sdpLog数组并关闭日志展开状态。 - 记录以
performance.now()为基准的毫秒偏移日志。 - 用单个 STUN URL 构造
RTCPeerConnection。 - 检查
createDataChannel、createOffer是否存在,识别隐私扩展提供的空壳对象。 - 将真实连接加入
activeConnections。 - 注册 ICE 收集状态、候选错误与候选回调。
- 创建空标签数据通道,创建 Offer 并设置本地描述。
- 设置 5000 毫秒兜底计时器。
已读取路径没有调用 getUserMedia,也没有建立远端会话或调用 setRemoteDescription;其目的在于触发候选收集,而非发起音视频通话。
4. 严格筛选候选,避免把本地候选当作 STUN 结果
候选处理先写日志,再检查是否已经完成,然后从空格分隔字符串的第八项读取类型。
1 const candidate = event.candidate.candidate;
2 log(`candidate: ${candidate}`);
3 if (settled) return;
4 const type = candidate.split(' ')[7];
5
6 // Only server-reflexive / peer-reflexive candidates represent a
7 // STUN answer. Host candidates (with or without mDNS) don't prove
8 // STUN worked and we must not display them as the "STUN IP".
9 if (type !== 'srflx' && type !== 'prflx') return;
10
11 const ipMatch = CANDIDATE_IP_RE.exec(candidate);
12 if (!ipMatch) return;
13 succeedWith(ipMatch[0], candidate);Source: WebRtcTest.vue
host、relay 和未知类型都不会使本轮成功。空候选只记录 end-of-candidates,不会立即宣告失败;后续由超时结束。地址提取使用覆盖常见 IPv4/IPv6 形式的正则,而非完整 SDP 解析器,因此不应将其视为独立的严格 IP 校验 API。
5. 地址成功后再补齐归属
succeedWith 在等待归属查询前就设置 settled = true 并清除 5 秒计时器,防止后续候选重复覆盖结果。随后写入 IP、翻译后的候选类型和无语言依赖的类型码。
第一次更新 IPArray 只带 { ip, country: '' };lookupMaxmind(ip) 返回数据后,再追加包含国家代码、位置、ASN、组织的记录。代码采用追加而不是原位替换,供 watch(IPArray) 调用 store.updateAllIPs。Store 是否合并或去重,不能从组件实现确认。
若归属查询返回空值,只将国家与组织置为 StatusError,保留已经成功的 IP 和候选类型。成功路径最后关闭连接、从集合移除并完成 Promise。
6. 整轮完成是一个有上限的快照
1 const allSettledPromise = Promise.allSettled(promises);
2 const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000));
3 return Promise.race([allSettledPromise, timeoutPromise]).then(() => {
4 store.setLoadingStatus('WebRTC', true);
5 emitWebrtcFinished();
6 });Source: WebRtcTest.vue
这里有两层不同的时间边界:5 秒用于单连接等待有效候选,6 秒用于整轮等待各服务器流程。由于成功候选会取消其 5 秒计时器并等待定位查询,6 秒快照可能在归属补齐之前发布。
Promise.race 不取消落后的任务。因此,“已发出完成事件”不代表全部底层异步任务一定已结束;定位结果仍可能在事件之后写回卡片与 IPArray。代码在完成处调用 setLoadingStatus('WebRTC', true),其布尔值不能脱离 Store 实现按名称猜测为“仍在加载”。
Source: WebRtcTest.vue
图中的归属查询没有由组件设定独立超时;整轮 6 秒边界可先于查询返回。网络错误事件以及 Offer 失败的细分行为见下文。
状态模型、结果事件与内部接口
服务器记录和类型码
每项 stunServers 记录包含 id、url、ip、natType、country、country_code、org、sdpLog、sdpOpen;执行过程中再写入 natTypeCode。显示字段使用当前语言的状态文本,类型码用于下游消费时避免依赖翻译字符串。
| 字段或状态 | 用途与生命周期 |
|---|---|
ip | 等待/检测/失败/不可用标签,或成功提取的地址 |
natType | 候选类型的翻译结果,或对应状态标签 |
natTypeCode | 检测开始为 undefined;成功为 srflx/prflx;失败为 error;不可用为 unavailable |
country_code | 初始及重测时为空;归属成功后填充 |
sdpLog | 每个服务器的相对时间日志;每次实际 STUN 检测重建 |
IPArray | 组件内累积的地址记录,监听后推送到 Store;整轮重测没有清空它 |
hasEverSettled | 发布快照时设为 true,表示至少完成过一轮,不在重测时重置 |
activeConnections | 尚需关闭的连接集合,不是结果存储 |
determineNATType 和 natTypeCodeOf 都支持 host、srflx、prflx、relay,但正常成功路径此前已过滤为两种反射候选。因此不能因为映射函数包含四种类型,就认为界面会接受四种类型作为 STUN 成功结果。
报告快照的真实结构
1const emitWebrtcFinished = () => {
2 hasEverSettled.value = true;
3 emitAppEvent('webrtc:finished', {
4 servers: stunServers.map((server) => ({
5 id: server.id,
6 url: server.url,
7 ip: server.ip,
8 natTypeCode: server.natTypeCode,
9 country_code: server.country_code,
10 org: server.org,
11 })),
12 });
13};Source: WebRtcTest.vue
快照不包含原始 SDP 日志、翻译后的 natType、国家显示名称或 ASN。源码注释说明下游报告构建器会丢弃没有 natTypeCode 的等待项;本页没有读取该构建器,因此这属于组件声明的下游约定,而不是对报告实现的独立验证。
内部接口参考
这些函数位于 Vue <script setup> 中,是组件内部接口,不是 HTTP API。源码为 JavaScript,以下返回说明依据函数体,不代表仓库声明了 TypeScript 类型。
| 接口 | 参数 | 返回与副作用 |
|---|---|---|
checkSTUNServer(stun) | 一条可变服务器记录 | 返回 Promise;通过修改该记录表达结果,正常结束不返回结果对象 |
checkAllWebRTC(isRefresh) | 刷新标志,真值时记录分析事件 | 异步执行整轮;API 不可用时直接发布结果,否则在全 settled 或 6 秒时发布 |
determineNATType(candidate) | ICE 候选字符串 | 按第八项返回翻译后的类型;其他值为 unknown 对应翻译 |
natTypeCodeOf(candidate) | ICE 候选字符串 | 返回四种已知类型之一或 unknown |
emitWebrtcFinished() | 无 | 设置粘性完成标志,发布 servers 快照 |
webrtc:run 命令处理器 | 可省略的对象,isRefresh = false | 返回 waitForAppEvent('webrtc:finished') 的等待结果 |
异常不是这些接口的结构化返回值:常规失败写入字段并完成 Promise。lookupMaxmind 的错误吞并契约由调用点注释说明,本页未核验其实现;不应把组件视为能吸收任何第三方实现异常的通用安全包装器。
配置与使用示例
固定参数
已读取实现中的参数是代码常量或内联值,没有发现该组件读取 STUN 环境变量或接受服务器配置属性。
| 选项 | 类型 | 当前值 | 作用 |
|---|---|---|---|
| Google STUN | 字符串 | stun.l.google.com:19302 | id = google |
| BlackBerry STUN | 字符串 | stun.voip.blackberry.com:3478 | id = blackberry |
| Twilio STUN | 字符串 | global.stun.twilio.com | id = twilio,代码未显式写端口 |
| Cloudflare STUN | 字符串 | stun.cloudflare.com | id = cloudflare,代码未显式写端口 |
| 单连接候选超时 | 毫秒数值 | 5000 | 没有有效反射候选时进入错误状态 |
| 整轮等待上限 | 毫秒数值 | 6000 | 限制统一完成事件的等待时间,不取消工作 |
| 数据通道标签 | 字符串 | '' | 触发数据通道相关协商 |
isRefresh | 命令输入标志 | false | 控制刷新点击的分析事件 |
simpleMode | Store 偏好 | 本页未核验默认值 | 被读取为 isSimpleMode;模板行为未核验 |
源码示例:触发候选收集并保留失败日志
1 pc.createDataChannel('');
2 pc.createOffer().then((offer) => {
3 // Offer SDP is multi-line; record it as a single block so the
4 // Collapsible <pre> renders it verbatim.
5 log(`createOffer ok\n--- Offer SDP ---\n${offer.sdp}--- end SDP ---`);
6 return pc.setLocalDescription(offer);
7 }).then(() => {
8 log('setLocalDescription ok');
9 }).catch((error) => {
10 log(`offer/setLocalDescription failed: ${error?.message || error}`);
11 });Source: WebRtcTest.vue
该片段展示了诊断工具的关键取舍:保留 Offer SDP 和具体错误,但不在 Promise 拒绝时直接断言隐私策略或网络根因;通常仍由候选等待超时统一收敛为失败。
源码示例:区分明确阻止与普通异常
1 const constructionBlocked = error?.name === 'NotAllowedError'
2 || error?.name === 'NotSupportedError'
3 || (error instanceof TypeError && /not a constructor/i.test(error?.message || ''));
4 if (constructionBlocked) {
5 log(`construction blocked: ${error?.message || error}`);
6 failWith('StatusUnavailable');
7 return;
8 }Source: WebRtcTest.vue
这是包围连接建立流程的同步 catch 判定。不要把它推广为“所有异步 NotAllowedError 都显示不可用”:Offer 链中的拒绝由上一个示例的 .catch 只记录日志。
故障模式、边界与并发
故障如何呈现
| 情况 | 当前处理 | 排查含义 |
|---|---|---|
RTCPeerConnection 全局 API 缺失 | 所有服务器立即不可用 | 不等待网络超时 |
对象缺少 createDataChannel 或 createOffer | 丢弃对象引用,标记不可用 | 不调用可能不存在的 close() |
| 同步异常满足阻止条件 | 标记不可用 | 作为保护性状态呈现 |
| 其他同步异常 | console.error、写日志、标记错误 | 不应解读为明确的隐私保护 |
onicecandidateerror | 记录错误码、文本和 URL | 不直接终止检测,仍可能得到有效候选 |
| Offer 或本地描述 Promise 拒绝 | 仅写入日志 | 通常等待 5 秒兜底 |
| 候选收集结束但无合格 IP | 记录结束标记 | 不提前失败,仍等超时 |
| 只有 host/mDNS 或 relay 候选 | 忽略为成功结果 | 不能证明 STUN 成功,也不能确定失败根因 |
| 归属查询返回空值 | 国家和组织失败,IP 保留 | 应区分地址获取与定位服务两个层面 |
源码刻意不区分“超时”和“mDNS 隐私”,因为两者不是互斥原因。把无有效候选直接解释为 mDNS 保护会造成误判。
单连接去重不等于整轮互斥
单连接的 settled 保证第一个成功候选之后的候选不再改写结果,也使超时失败不覆盖已成功的 IP。但 checkAllWebRTC 本身没有执行中锁、代次编号或旧任务取消逻辑。
因此,如果外部允许重叠调用,会有以下风险:
- 多轮检测操作同一个服务器对象,旧轮定位返回后可能覆盖新轮字段。
- 日志辅助函数始终向当前
stun.sdpLog写入;新轮替换数组后,旧轮仍可能向新数组追加日志。 - 命令等待的是下一次同名完成事件,组件没有携带请求 ID;外部事件工具是否进一步隔离并发,本页未核验。
IPArray在刷新时不清空,重复地址可能持续进入 Store 更新路径。
这些是调用点可见的边界,不代表已经验证了全局界面会允许用户重复并发触发。
卸载时关闭连接,但不等于取消所有异步任务
onBeforeUnmount 遍历 activeConnections 调用 close(),然后清空集合,避免 ICE 收集继续占用资源。正常成功和失败路径也会关闭并移除连接。
不过,卸载钩子没有清除各闭包内的计时器,没有取消定位请求,也没有终止整轮 Promise.race。所以不能将资源关闭描述为完整的请求取消机制。需要强化这一行为时,应同时考虑计时器、定位查询、状态写回和完成事件的生命周期,而不只是 RTCPeerConnection.close()。
性能、运维与隐私注意事项
- 网络并行度。 每轮最多创建四个独立连接;组件没有重试循环。所有服务不可达时,常规路径约在单服务器兜底时间后收敛,而不是顺序等待四次超时。
- 定位查询成本。 每个成功服务器都会调用一次
lookupMaxmind(ip);组件没有按 IP 去重或缓存。相同出口地址是否共享缓存取决于未核验的定位模块。 - 日志排查顺序。 先查看构造/空壳识别,再查看 Offer、本地描述、ICE 错误和候选类型,最后确认是否出现
resolved IP。日志使用每台服务器各自的毫秒偏移,不能把它当作全局绝对时间。 - 外部交互。 测试主动使用四个外部 STUN 地址,并把得到的 IP 传给定位模块。归属查询的实际网络目的地和数据保留策略需要另查其实现。
- 诊断日志可能敏感。 日志保留完整 Offer SDP、候选和解析 IP。截图、复制日志或提交问题前应检查其中地址信息;仅知道初始化了
createMaskGate,不足以保证日志被遮罩。 - 没有独立持久化实现。 已读取组件使用响应式内存状态和 Store 更新,没有直接数据库或本地存储操作。全局 IP 历史是否持久化不属于本页已验证范围。
来源:日志与时间戳、定位与候选日志、并行及 Store 同步。
扩展与验证建议
以下是基于现有结构的维护建议,不是已实现功能或已验证测试:
- **增减 STUN 服务:**修改
stunServers,保留稳定id与完整显示状态字段,因为报告快照直接使用它们。 - **修改候选策略:**同时检查候选过滤、类型映射、结果说明和报告语义;单独放开
host会破坏“STUN 地址确实来自反射候选”的现有约束。 - **增加可取消运行:**应使用每轮标识隔离状态写回,并协调命令等待、事件发布、定位请求和卸载清理。
- **调整超时:**分别评估单连接候选期限和整轮快照期限;提高整轮上限不会自动为定位查询加入取消机制。
本次取证未读取 WebRTC 专项测试,无法确认上述边界的自动化覆盖。建议回归验证 API 缺失、构造阻止、空壳 API、只有 host 候选、有效 IPv4/IPv6、重复候选、ICE 错误后成功、Offer 拒绝、定位为空或延迟、重叠运行以及运行中卸载。并应验证“归属失败不覆盖成功 IP”“6 秒事件不被误认作底层工作全部结束”两项核心语义。
相关链接
- STUN 地址筛选与归属补全:继续追踪地址从候选进入结果的实现。
- 命令入口与报告事件契约:对接全局刷新或报告收集时的边界。
- 状态语义与隐私显示入口:区分不可用、错误和无详情字段。
关于 DNS 泄露、HTTP 出口地址、定位服务和报告生成,应分别参考对应专题;本次运行上下文未提供这些目录项的实际路径,因此不构造未经验证的 Wiki 链接。