验证 WebRTC 网络策略,需要让页面具备创建连接的能力,再同时观察 STUN 服务端收到的数据报与页面产生的 ICE 候选。 如果先把 RTCPeerConnection 禁用了,之后没有数据包,只能说明没有发生这次连接,不能据此证明底层网络策略生效。

本文给出只在本机运行的教学实验,通过“关闭—开启—再次关闭”建立对照。2026年11月6日,在 Windows、Playwright 1.60.0、完整 Chrome 155.0.8059.85 的无头模式下,三次实验的 STUN Binding 请求数为 1、0、1,测试映射候选也依次出现、消失、恢复。

这些结果只支持该浏览器版本、本地 STUN 路径和观察窗口的结论,不是匿名性认证,也不证明所有站点、企业策略或执行环境中的地址获取方式均被覆盖。旧文WebRTC 泄漏机制与排查已经解释了基础概念,本篇集中讨论怎样取得可核验的实验依据。

文中的 dyDM 案例对应固定开发源码,不表示正式安装包已经交付相同能力。下方独立实验直接启动指定 Chrome,并未通过 dyDM 的完整业务启动链路;本地实验结果与私有项目整体验收分别记录。

网络策略与页面接口限制应分别验收

网络策略影响浏览器尝试使用哪些连接路径;页面接口限制影响页面是否能创建相关对象。两个控制层可以配合,但不应互相冒充验证证据。

测试目的 保持什么条件 主要观察
网络策略是否改变测试路径 页面 API 可用,仅切换网络策略 STUN 收包、候选类型与收集结束
页面 API 限制是否生效 独立配置初始化脚本 页面第一个脚本看到的对象
组合运行与兼容性 明确记录两层配置 目标任务是否完成、哪些功能受限

RFC 8828讨论 WebRTC 的 IP 地址处理要求,可用于理解策略的目标;浏览器具体接口和行为仍需结合实现验证。Chrome 的隐私 API 文档列有 disable_non_proxied_udp 这一策略值,但不能由某个参数字符串出现在启动列表,就推断所有 Chromium 产品均采用了它。Chrome privacy 文档

网络策略测试与页面API限制测试分别验证的对象和证据

图1:原创分层验证示意,未表示所有页面环境都已覆盖。

STUN 测试映射地址不是真实公网 IP

ICE 收集候选并检查可用路径,STUN 是其使用的工具之一。通过 STUN 获得的 server-reflexive 候选,通常简称 srflx,与本地接口相关的 host 候选、中继候选不同。RFC 8445、RFC 8489

为了让实验不依赖公网服务,下面的响应器绑定 127.0.0.1,收到 Binding 请求后,返回固定的 203.0.113.42 作为测试映射地址。它属于RFC 5737的文档地址段,不是本次测出的公网出口,也不代表这条候选能够建立真实公网通信。

响应器只实现本实验需要的回复,不是完整 STUN 服务端。把一个可辨识结果返回给浏览器,目的是检查请求与候选生成这条链路是否发生。

STUN 成功响应需要沿用请求的事务 ID,并按 XOR-MAPPED-ADDRESS 的格式编码地址和端口。随便返回一个 IP 文本,不能用来判断浏览器是否正确收到了 STUN 响应。示例实现依据 RFC 8489 的报文与属性格式,没有将它宣称为协议一致性测试套件。

用关—开—关排除无效对照

只有“开启后没收到包”,证据不充分:浏览器可能未创建连接、响应器可能没启动、采集过程也可能提前结束。

三次测试都使用新的浏览器进程和临时 Context,API 屏蔽始终关闭,只切换网络启动参数:

阶段 网络策略 预期检查
第一次 关闭 收到请求,产生固定测试映射的 srflx 候选
第二次 开启 在观察窗口内没有新增请求,也没有测试映射候选
第三次 再次关闭 请求与候选恢复,确认测试链路仍然正常

等待以 ICE 收集结束或最长四秒为边界。四秒不是网络世界的通用安全阈值,只是这个本地、可立即响应的夹具设置。实验若不能建立正向对照,应先报告对照失败,而不是把全程零包当作防护成功。

本地STUN关开关实验的预期判据与两端观察

图2:原创实验设计示意。实际结果见下方版本和观察表。

可独立运行的本地实验

在独立目录保存下方完整代码为 probe.py。需要 Python 3.10 及以上、Playwright,以及本机已安装的完整 Chrome:

1
2
python -m pip install playwright==1.60.0
python probe.py

脚本使用 channel="chrome",不会自动下载或替换本机 Chrome。没有安装该浏览器时应先处理环境;直接把 channel 改成测试 Chromium,得到的是另一个环境的结果,需要单独记录。此脚本不读取账号目录、Cookie 或真实代理配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
"""Original loopback-only STUN teaching experiment; not an Internet STUN server."""
import importlib.metadata
import ipaddress
import json
import platform
from pathlib import Path
import socket
import struct
import threading
from playwright.sync_api import sync_playwright

COOKIE=0x2112A442
TEST_IP='203.0.113.42'
SCRIPT='''async (port) => {
const pc = new RTCPeerConnection({iceServers:[{urls:`stun:127.0.0.1:${port}`} ]});
const candidates=[];
try {
const finished=new Promise(resolve => {
pc.onicecandidate=e => { if(e.candidate)candidates.push({type:e.candidate.type,address:e.candidate.address}); };
pc.onicegatheringstatechange=()=> {if(pc.iceGatheringState==='complete')resolve();};
setTimeout(resolve,4000);
});
pc.createDataChannel('test');
await pc.setLocalDescription(await pc.createOffer());
await finished;
return {state:pc.iceGatheringState,types:candidates.map(c=>c.type),hasTestMapping:candidates.some(c=>c.type==='srflx' && c.address==='203.0.113.42')};
} finally {pc.close();}
}'''


def run():
stop=threading.Event();count=[0];lock=threading.Lock()
sock=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);sock.bind(('127.0.0.1',0));sock.settimeout(.1)
port=sock.getsockname()[1]
def serve():
while not stop.is_set():
try:data,peer=sock.recvfrom(4096)
except socket.timeout:continue
if len(data)<20:continue
kind,length,cookie,transaction=struct.unpack('!HHI12s',data[:20])
if kind!=1 or cookie!=COOKIE or len(data)<20+length:continue
with lock:count[0]+=1
attribute=struct.pack('!HHBBHI',0x20,8,0,1,peer[1]^(COOKIE>>16),int(ipaddress.IPv4Address(TEST_IP))^COOKIE)
sock.sendto(struct.pack('!HHI12s',0x101,len(attribute),COOKIE,transaction)+attribute,peer)
thread=threading.Thread(target=serve);thread.start()
results=[]
try:
with sync_playwright() as pw:
for enabled in (False,True,False):
args=['--disable-background-networking']
if enabled:args+=['--webrtc-ip-handling-policy=disable_non_proxied_udp','--force-webrtc-ip-handling-policy=disable_non_proxied_udp']
browser=pw.chromium.launch(channel='chrome',headless=True,args=args)
try:
context=browser.new_context();page=context.new_page()
with lock:before=count[0]
observed=page.evaluate(SCRIPT,port)
with lock:received=count[0]-before
item={'network_policy':enabled,'headless':True,'browser':browser.version,'stun_requests':received,**observed}
results.append(item)
if enabled:assert received==0 and not observed['hasTestMapping'],item
else:assert received>0 and observed['hasTestMapping'],item
finally:browser.close()
browser=pw.chromium.launch(channel='chrome',headless=True)
try:
context=browser.new_context()
context.add_init_script("Object.defineProperty(window,'RTCPeerConnection',{value:undefined,configurable:false,writable:false});")
page=context.new_page();page.goto('data:text/html,<script>window.firstObservation=typeof RTCPeerConnection</script>')
assert page.evaluate('window.firstObservation')=='undefined'
finally:browser.close()
finally:
stop.set();thread.join(timeout=2);sock.close()
record={'python_environment':platform.system()+' local host; loopback UDP only; no account or Internet STUN','python':platform.python_version(),'playwright':importlib.metadata.version('playwright'),'browser_channel':'chrome','trials':results,'page_api_first_script':'undefined','scope':'One headless Chrome build; artificial TEST-NET mapping; not an anonymity or all-site protection claim'}
Path(__file__).with_name('verification-results.json').write_text(json.dumps(record,ensure_ascii=False,indent=2)+'\n',encoding='utf-8',newline='\n')
print(json.dumps(record,ensure_ascii=False,indent=2))


if __name__=='__main__':run()

程序将结果写入同目录的 verification-results.json。服务端只绑定回环地址,结束时停止线程并关闭 socket;输出仅保留候选类型与是否出现文档测试映射,不保存本地接口地址列表。

本次实际运行结果如下。三次均完成 ICE 收集,没有依靠超时把尚未结束的过程当作成功:

Chrome 155.0.8059.85,无头模式 STUN 请求数 候选类型 固定测试映射
策略关闭 1 host、srflx 出现
策略开启 0 无 未出现
策略再次关闭 1 host、srflx 恢复

表格是本次运行记录,不是建议值或模拟指标。响应器返回的是人工地址,因此不能把表格写成真实公网 IP 泄漏与修复测试。

页面 API 限制验证的是另一个结果

示例末尾还独立检查了页面首次脚本:通过 BrowserContext.add_init_script(),在文档脚本运行前限制 RTCPeerConnection,页面保存的首次观察结果为 undefined。

Playwright 初始化脚本文档说明了相应生命周期。注册初始化脚本与事后在开发者工具修改全局对象不同;已经运行的脚本可能保留旧引用。

这个附加用例只验证示例数据页面的首次脚本,不代表本次已测试跨源 iframe、popup、Worker、扩展脚本或任意 CSP。真实应用如果依赖这些路径,需要分别设置可观察断言;不能把方法调用成功当作覆盖清单已经完成。

对象限制也有兼容性成本。视频会议、设备采集、直播连麦等正常功能可能受影响,mediaDevices 等额外限制尤其需要按用途选择。策略关闭后,已经安装进旧页面的属性限制也不一定可撤销,重新创建浏览器是更清楚的生效边界。

完整 Chrome 与测试内核需要分别记录

个人项目的历史开发资料记录过一种反例:测试内核中的结果符合预期,完整 Chrome 的 STUN 验证却未通过。当前私有源码基线 56e25be7f9792a4245f343408bbb061df59bbf7e 保留了以下两个启动参数:

1
2
--webrtc-ip-handling-policy=disable_non_proxied_udp
--force-webrtc-ip-handling-policy=disable_non_proxied_udp

本文本次实测也使用这一组合,但没有分别测量两个参数各自的贡献,不能从组合成功推导两个参数单独都必需或都有效。它们不是本文承诺所有浏览器长期支持的稳定公共接口;版本升级后应复测实际行为。

同样,disable_non_proxied_udp 不会把一个只支持 TCP 的本地代理变成 UDP 转发器。没有实现 UDP ASSOCIATE 或其他 UDP 传输,就不能宣传为“所有 WebRTC 流量已经通过当前 SOCKS5 代理”。

结果能支持哪些结论

本次实验支持的结论是:在记录的完整 Chrome 无头版本中,指定参数组合改变了本地 STUN 测试路径,关闭后能恢复;附加页面用例中,初始化脚本在首次脚本前限制了指定对象。

它没有检查有窗口模式、所有浏览器产品、真实上游代理、远端 STUN/TURN、企业管理策略、实际业务账号或全部 JavaScript 环境。也没有用一次运行证明长期稳定性。

当 IP 检测页仍显示非预期地址时,应继续核对页面实际调用的接口域名、代理规则和当前运行快照。网络检测接口直连、旧配置仍被使用、上游自身分流,都可能造成类似表象;“IP 不对”不是 WebRTC 问题的充分证据。

参考资料

资料核对与本地实验日期为2026年11月6日。私有项目历史反例与本次原创夹具结果分别叙述;本篇不复现其全部内部测试。

  1. RFC 8828:WebRTC IP Address Handling Requirements,地址处理目标和策略背景。
  2. RFC 8445:ICE,候选与连通性检查。
  3. RFC 8489:STUN,Binding报文和映射地址属性。
  4. Chrome:privacy API,IP handling策略值。
  5. Playwright:BrowserContext.add_init_script,初始化脚本时机。
  6. Playwright:Browsers,配套浏览器与品牌浏览器的运行选择。
  7. RFC 5737:文档示例IPv4地址,人工映射地址的性质。