---
title: "Chrome WebRTC 防泄露测试：用本地 STUN 验证网络策略"
source: https://www.iowill.com/blog/2026/11/chrome-webrtc-stun-policy-validation/
date: 2026-11-09
updated: 2026-11-09
tags: [Chromium, Playwright, WebRTC, 网络隐私, 软件测试]
categories: [编程开发]
---

# Chrome WebRTC 防泄露测试：用本地 STUN 验证网络策略

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

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

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

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

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

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

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

[RFC 8828](https://www.rfc-editor.org/rfc/rfc8828.html)讨论 WebRTC 的 IP 地址处理要求，可用于理解策略的目标；浏览器具体接口和行为仍需结合实现验证。Chrome 的隐私 API 文档列有 `disable_non_proxied_udp` 这一策略值，但不能由某个参数字符串出现在启动列表，就推断所有 Chromium 产品均采用了它。[Chrome privacy 文档](https://developer.chrome.com/docs/extensions/reference/api/privacy#type-IPHandlingPolicy)

![网络策略测试与页面API限制测试分别验证的对象和证据](https://img.iowill.com/file/%E7%BC%96%E7%A8%8B%E5%BC%80%E5%8F%91/WebrtcStunValidation/body1.png)

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

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

ICE 收集候选并检查可用路径，STUN 是其使用的工具之一。通过 STUN 获得的 server-reflexive 候选，通常简称 `srflx`，与本地接口相关的 `host` 候选、中继候选不同。[RFC 8445](https://www.rfc-editor.org/rfc/rfc8445.html)、[RFC 8489](https://www.rfc-editor.org/rfc/rfc8489.html)

为了让实验不依赖公网服务，下面的响应器绑定 `127.0.0.1`，收到 Binding 请求后，返回固定的 `203.0.113.42` 作为测试映射地址。它属于[RFC 5737的文档地址段](https://www.rfc-editor.org/rfc/rfc5737.html)，**不是本次测出的公网出口**，也不代表这条候选能够建立真实公网通信。

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

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

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

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

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

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

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

![本地STUN关开关实验的预期判据与两端观察](https://img.iowill.com/file/%E7%BC%96%E7%A8%8B%E5%BC%80%E5%8F%91/WebrtcStunValidation/body2.png)

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

## 可独立运行的本地实验

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

```bash
python -m pip install playwright==1.60.0
python probe.py
```

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

```python
"""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 初始化脚本文档](https://playwright.dev/python/docs/api/class-browsercontext#browser-context-add-init-script)说明了相应生命周期。注册初始化脚本与事后在开发者工具修改全局对象不同；已经运行的脚本可能保留旧引用。

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

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

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

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

```text
--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](https://www.rfc-editor.org/rfc/rfc8828.html)，地址处理目标和策略背景。
2.  [RFC 8445：ICE](https://www.rfc-editor.org/rfc/rfc8445.html)，候选与连通性检查。
3.  [RFC 8489：STUN](https://www.rfc-editor.org/rfc/rfc8489.html)，Binding报文和映射地址属性。
4.  [Chrome：privacy API](https://developer.chrome.com/docs/extensions/reference/api/privacy#type-IPHandlingPolicy)，IP handling策略值。
5.  [Playwright：BrowserContext.add\_init\_script](https://playwright.dev/python/docs/api/class-browsercontext#browser-context-add-init-script)，初始化脚本时机。
6.  [Playwright：Browsers](https://playwright.dev/python/docs/browsers)，配套浏览器与品牌浏览器的运行选择。
7.  [RFC 5737：文档示例IPv4地址](https://www.rfc-editor.org/rfc/rfc5737.html)，人工映射地址的性质。
