什么是浏览器 LNA 和 PNA 安全限制?
要理解 LNA 之前一定要先了解已经被 Chrome 弃用的功能 PNA。
专用网络访问处于暂停状态:我们之前曾宣布,从 Chrome 130 开始,系统将强制执行 PNA 预检查请求。由于存在一些兼容性问题,此功能目前暂停发布。
PNA 全称:Private Network Access,是浏览器一项安全功能,用于限制网站向专用网络上的服务器发送请求的功能,该功能从 Chrome123 开始显示警告、Chrome130 之后强制执行。
详细来讲就是说浏览器会把网络按照公开程度分为:
公网 public => 内网 private => 本地 local
当从一个较公开的网络访问一个更私有的网络就会触发 PNA 保护机制。这项保护机制非常重要,可以防止公共网络借由宿主机器向宿主的私有网络发起 CSRF 攻击。
PNA 运行原理
浏览器划分网络类型的依据是 IP 地址,最典型的 127.0.0.1 就是本地网段,常见的 129.168.x.x \ 10.x.x.x 等均为内网网段。
当浏览器判断请求符合跨网段条件时会在真正发起请求前发送 预检请求,这一点和 CORS 非常类似。
浏览器会自动为预检请求带上 PNA 请求头:
Access-Control-Request-Private-Network: true若在响应头中没有得到 相应的豁免头 则浏览器会阻止发送真正的请求:
# CORS 豁免
Access-Control-Allow-Origin: https://foo.example
# PNA 豁免
Access-Control-Allow-Private-Network: true大部分情况下 PNA 会和 CORS 叠加触发(因为跨网段访问大概率是跨域请求),所以会得到类似这样的报错:
Access to fetch at 'aaaaaa' from origin 'bbbbbb' has been blocked by CORS policy:
Permission was denied for this request to access the local address space.它不是简单的跨域拦截而可能是 PNA 拦截~
注意点
- PNA 还涵盖 Web Worker 和浏览器扩展;
- PNA 的预检发送早于所有其他模式,譬如
cors和no-cors; - 使用代理工具可能会导致 PNA 失效;
- 判断浏览器是否使用了 PNA 需要查看预检请求头上是否携带
Access-Control-Request-Private-Network。
最新的标准 LNA
LNA 全称:Local Network Access,它被提出用于解决和 PNA 相同的问题。它与 PNA 存在两个主要差异:
- 于用户而言,用户会明确感知到访问的网站存在 LNA 行为并有同意/拒绝的权利;
- 于开发者而言,无需额外的请求/响应头配置,权限的请求和拦截完全由浏览器完成;
![]()
LNA 排查技巧
正因为 LNA 的授权行为完全由浏览器控制,对于开发者而言整个过程完全黑盒加大了排查难度:
- 浏览器可能因为一些原因不发起授权弹窗而直接拒绝 LNA 请求;
- DevTools 中不会有明确的 LNA 拒绝提示且浏览器会隐藏请求响应头。
尤其是我一开始不了解 LNA 相关知识,一直在 PNA 层面做协议的调试。
值得一提的是抓包工具/代理工具会使 LNA 无效。
因为这类工具的原理都是代理浏览器请求做转发,所以浏览器会将请求都解析到代理工具的 IP 和端口使得不存在“跨网络类型”访问的情景,LNA 就不会触发。
所以用抓包工具去查响应头时浏览器就放行请求,而关闭抓包工具时请求又被浏览器意味不明的拦截,一根筋两头堵了。
我们必须使用 Chrome 内置的调试工具:
- 内置网络导出工具:
chrome://net-export/ - 网络日志解析工具:
https://netlog-viewer.appspot.com/#import
前者可以录制并将网络日志导出成 JSON 文件;而后者可以解析导出的文件。
在老版本的 Chrome 浏览器中可以使用
chrome://net-internals/#events直接观察和调试,但在新版本已经被废弃。
日志信息可以会比较多,需要善用页面上的搜索功能用域名或者网址做过滤,可以看到我调试的 OpenApi 接口确实发起了两次 URL_REQUEST 请求符合 CORS 的特征。
![]()
日志详情的内容比较多,我们只需要注意 source_dependency 把握整体的调用链路即可很快找到问题,上图的调用链路如下:
URL_REQUEST(GET) => URL_REQUEST(OPTIONS) =>
HTTP_STREAM_JOB_CONTROLLER => HTTP_STREAM_JOB => QUIC_SESSION_POOL_DIRECT_JOB =>
HOST_RESOLVER_IMPL_JOB => URL_REQUEST(OPTIONS)在 HOST_RESOLVER_IMPL_JOB 步骤中 Chrome 调用 HOST_RESOLVER_SYSTEM_TASK 解析到 IP 地址形如:
--> address_list = [
"10.x.x.x:0",
"[fdbd:x:x:x::x]:0"
]得到所需信息之后 Callback 回到 URL_REQUEST(OPTIONS) 请求查询到如下日志:
![]()
看到 lna-permission-required 以及 denied 之后马上就就能联想到是触发了 LNA 拦截且被拒绝。
那么第二个问题来了:为什么浏览器直接拒绝了 LNA 而没有触发弹窗给到我们选择呢?
原因是在我的网站中嵌套至少三层 Iframe,而 OpenAPI 的调用是在其中一层 Iframe 发起的。
Iframe 中的 LNA 机制
其实 Google 有一篇专门的文档 《LNA Adoption Guide》 讲解 LNA 机制。对于 Iframe 中的 LNA 必须开启相应的权限:
<iframe src="domainB.example" allow="local-network; loopback-network"></iframe>如果 Iframe 本身存在跳转则必须声明跳转之后的域名 local-network domainB domainC 也可以直接使用 local-network *。
local-network 和 loopback-network 是从 local-network-access 拆分出来的权限点,如果需要兼容 Chrome 145 以下的浏览器可以考虑把三个权限点都写上。
另外在 Chrome 145+ 的浏览器可以直接调用 API 查询用户对权限点的授权状态用于展示对用户的提示:
navigator.permissions.query({ name: "local-network-access" })
.then((result) => {
console.log(`LNA permission state: ${result.state}`)
});最后的吐槽
Chrome 的知识点学不完,根本学不完。另外 Claude、Gemini 和 GPT 都没有根据已知信息快速推测出是 LNA 拦截,这也是我陆陆续续耗费了三天才把问题锁定的原因。
其他模型就不说了,建议严查 Gemini 连自家的文档都没有好好喂给模型学习是吧。