作为一个电商平台,用户下单时,我们提供了一个页面让其选择自提点。一天下午,同事突然发现这个页面会多请求一遍接口,具体现象是:
- 先是一个 Pre-flight/OPTIONS 请求(因为接口跨域),但是这个接口失败了,从 F12 的 network 界面只能看到
net::ERR_FAILED,看不到具体的错误信息,从 console 界面也没有任何报错。 - 然后是一个正常的 Pre-flight/OPTIONS 请求,这个请求成功了。
- 然后是一个正常的 GET 请求,这个请求成功了。

每个接口都会这样,因此应该是个共性问题。
我在我本地试了一下,得出了一个专业程序员常见的结论:在我本地没问题,是你们的问题,你看我有截图为证……截图的样子就是上图去掉了红色记录。
F12 看不到的问题
当然,说笑归说笑,这个问题在不止一个同事的电脑上可以稳定复现,所以我还是得认真排查一下。常见的请求问题排查直接用 F12 network 面板就可以了,但这个请求错误似乎没留任何痕迹。

F12 network 面板里的数据是经过解析的,里面一些很底层的内容(如 Connect 请求头)是看不到的。页面使用了 HTTPS,所以 Wireshark 似乎也没法抓到有效的数据。
Chrome 自身有没有可以查看底层数据的地方呢?有!Chrome 的 NetLog 可以把浏览器的网络事件完整记录下来,然后用官方提供的 NetLog Viewer 工具查看。
NetLog 雪中送炭
NetLog 是 Chrome 提供的一个底层调试工具,打开 chrome://net-export/ 页面,可以看到设计很原始:

有用的只有一个按钮:Start Logging to Disk,用于把网络事件保存到磁盘的一个文件里,下面还有一些选项:
- Strip private information:移除掉所有的隐私信息,这个是默认选项,如果你要把保存的文件发送到外网,最好只使用这个选项,可以防止敏感信息泄漏。
- Include cookies and credentials:记录 cookie 和其它凭据。
- Include raw bytes:记录请求和响应的原始数据,这个选项对于排查问题非常有用,但是会让文件变得非常大,并且里面也带了 cookie 和其它凭据。
为了防止文件过大,最后有一个输入框,用来限制文件大小(单位是 MB),超过这个大小后就会自动停止记录。如果不填,则不会限制大小。
点击 Start Logging to Disk 并选择磁盘位置后,Chrome 会在后台记录网络事件,这个过程中,你可以正常使用 Chrome,如果要停止记录,可以点击页面上的 Stop Logging 按钮(如果不小心关了这个页面,再打开就可以了)。
停止之后,能看到两个按钮:Show File 和 Start Over,分别是“查看文件”(会打开文件管理器)和“重新开始”(回到第一步)。
初识 NetLog Viewer
生成的文件可以在 NetLog Viewer 里查看,在首页选择刚才生成的文件(或拖拽文件进来),页面会自动分析文件内容,并展示一些基本信息。

左边的菜单栏里有很多选项,可以查看各种信息,可能比较常用的有:
- Events:所有的网络事件,包括请求、响应、重定向、DNS 解析、TCP 连接、SSL 握手等等。
- DNS:DNS 的配置、对记录中域名的解析结果。
- HTTP/2:HTTP/2 的帧。
- QUIC:QUIC 的帧。
- Modules:包括了 Chrome 扩展,以及在 Windows 上面的一些信息(Layered Service Providers 和 Namespace Providers)。
因此,我们要看的数据就在 Events 里面。
Events 里的数据
初看 Events 页面可能会有点漫无目标,但如果之前有使用过 Wireshark 或者 Fiddler 等软件,就会发现用法大致相同:

找到所需的 Events
首先在上面的蓝色问号后面输入需要过滤的条件,例如我们只看业务页面中、跨域相关的请求,就可以直接把 URL 输入进去(只输入域名也可以,是模糊匹配),可以看到这里有我们关注的请求信息:

直接点击记录就可以在右边看到一些具体的信息了(或者勾选第一列的复选框,可以在右边查看多条信息),例如我勾选 ID 为 943078 的记录(第一个 URL_REQUEST 记录),可以看到这个请求的详细信息(敏感数据用 [MASKED] 替代):
t=33855698 [st= 0] +CORS_REQUEST [dt=527]
--> cors_preflight_policy = "consider_preflight"
--> headers = "sec-ch-ua: \"Google Chrome\";v=\"113\", \"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\"\r\nContent-Type: application/json\r\nDNT: 1\r\nsec-ch-ua-mobile: ?0\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36\r\nX-Request-ID: e1da6-b793-c5f1-b3a\r\nsec-ch-ua-platform: \"macOS\"\r\nAccept: */*\r\n\r\n"
--> is_revalidating = false
--> method = "POST"
--> url = "https://[MASKED]/get_location_children/"
t=33855698 [st= 0] CHECK_CORS_PREFLIGHT_REQUIRED
--> preflight_required = true
--> preflight_required_reason = "disallowed_header"
t=33855698 [st= 0] CORS_PREFLIGHT_URL_REQUEST
--> source_dependency = 943079 (URL_REQUEST)
t=33856115 [st=417] CORS_PREFLIGHT_ERROR
--> cors-error = 26
--> error = "ERR_FAILED"
t=33856115 [st=417] CHECK_CORS_PREFLIGHT_REQUIRED
--> preflight_required = true
--> preflight_required_reason = "private_network_access"
t=33856115 [st=417] CORS_PREFLIGHT_URL_REQUEST
--> source_dependency = 943119 (URL_REQUEST)
t=33856162 [st=464] CORS_PREFLIGHT_RESULT
--> access-control-allow-headers = "content-type,x-request-id"
--> access-control-allow-methods = "GET,POST,PUT"
t=33856162 [st=464] +REQUEST_ALIVE [dt=63]
--> priority = "MEDIUM"
--> traffic_annotation = 101845102
--> url = "https://[MASKED]/get_location_children/"
t=33856162 [st=464] NETWORK_DELEGATE_BEFORE_URL_REQUEST [dt=0]
t=33856162 [st=464] +URL_REQUEST_START_JOB [dt=62]
--> logtiator = "https://[MASKED]"
--> load_flags = 66 (BYPASS_CACHE | DO_NOT_SAVE_COOKIES)
--> method = "POST"
--> network_isolation_key = "https://[MASKED] https://[MASKED]"
--> request_type = "other"
--> site_for_cookies = "SiteForCookies: {site=https://[MASKED]; schemefully_same=true}"
--> upload_id = "0"
--> url = "https://[MASKED]/get_location_children/"
t=33856162 [st=464] COMPUTED_PRIVACY_MODE
--> privacy_mode = "enabled"
t=33856162 [st=464] NETWORK_DELEGATE_BEFORE_START_TRANSACTION [dt=1]
t=33856163 [st=465] HTTP_CACHE_GET_BACKEND [dt=0]
t=33856163 [st=465] +HTTP_STREAM_REQUEST [dt=0]
t=33856163 [st=465] HTTP_STREAM_JOB_CONTROLLER_BOUND
--> source_dependency = 943127 (HTTP_STREAM_JOB_CONTROLLER)
t=33856163 [st=465] HTTP_STREAM_REQUEST_BOUND_TO_JOB
--> source_dependency = 943128 (HTTP_STREAM_JOB)
t=33856163 [st=465] -HTTP_STREAM_REQUEST
t=33856163 [st=465] +URL_REQUEST_DELEGATE_CONNECTED [dt=0]
t=33856163 [st=465] LOCAL_NETWORK_ACCESS_CHECK
--> client_address_space = "public"
--> resource_address_space = "local"
--> result = "allowed-by-target-ip-address-space"
t=33856163 [st=465] -URL_REQUEST_DELEGATE_CONNECTED
t=33856163 [st=465] UPLOAD_DATA_STREAM_INIT [dt=0]
--> is_chunked = false
--> net_error = 0 (?)
--> total_size = 36
t=33856163 [st=465] +HTTP_TRANSACTION_SEND_REQUEST [dt=0]
t=33856163 [st=465] HTTP_TRANSACTION_HTTP2_SEND_REQUEST_HEADERS
--> :method: POST
:authority: [MASKED]
:scheme: https
:path: /[MASKED]/get_location_children/
content-length: 36
pragma: no-cache
cache-control: no-cache
sec-ch-ua: "Google Chrome";v="113", "Chromium";v="113", "Not-A.Brand";v="24"
content-type: application/json
dnt: 1
sec-ch-ua-mobile: ?0
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36
x-request-id: e1da6-b793-c5f1-b3a
sec-ch-ua-platform: "macOS"
accept: */*
origin: https://[MASKED]
sec-fetch-site: same-site
sec-fetch-mode: cors
sec-fetch-dest: empty
referer: https://[MASKED]/
accept-encoding: gzip, deflate, br
accept-language: en-GB,en;q=0.9,zh-HK;q=0.8,zh;q=0.7,en-US;q=0.6,zh-CN;q=0.5
sec-gpc: 1
t=33856163 [st=465] UPLOAD_DATA_STREAM_READ [dt=0]
--> current_position = 0
t=33856163 [st=465] HTTP2_STREAM_UPDATE_SEND_WINDOW
--> delta = -36
--> stream_id = 5
--> window_size = 65500
t=33856163 [st=465] -HTTP_TRANSACTION_SEND_REQUEST
t=33856163 [st=465] +HTTP_TRANSACTION_READ_HEADERS [dt=60]
t=33856223 [st=525] HTTP2_STREAM_UPDATE_SEND_WINDOW
--> delta = 2147418111
--> stream_id = 5
--> window_size = 2147483611
t=33856223 [st=525] HTTP_TRANSACTION_READ_RESPONSE_HEADERS
--> HTTP/1.1 200
server: SGW
date: Thu, 01 Jun 2023 06:11:01 GMT
content-type: application/json; charset=utf-8
vary: Accept-Encoding
access-control-allow-origin: https://[MASKED]
lpsreply: True
ret-code: 0
x-request-id: |e1da6-b793-c5f1-b3a|bvvX
content-encoding: gzip
t=33856223 [st=525] -HTTP_TRANSACTION_READ_HEADERS
t=33856223 [st=525] +NETWORK_DELEGATE_HEADERS_RECEIVED [dt=1]
t=33856223 [st=525] HTTP2_STREAM_UPDATE_RECV_WINDOW
--> delta = -326
--> stream_id = 5
--> window_size = 6291130
t=33856224 [st=526] -NETWORK_DELEGATE_HEADERS_RECEIVED
t=33856224 [st=526] URL_REQUEST_FILTERS_SET
--> filters = "GZIP"
t=33856224 [st=526] -URL_REQUEST_START_JOB
t=33856224 [st=526] URL_REQUEST_DELEGATE_RESPONSE_STARTED [dt=0]
t=33856224 [st=526] HTTP_TRANSACTION_READ_BODY [dt=0]
t=33856224 [st=526] URL_REQUEST_JOB_BYTES_READ
--> byte_count = 326
--> bytes =
1F 8B 08 00 00 00 00 00 04 03 8D 93 4F 4B C3 30 ... ....OK.0
18 C6 EF FD 14 21 67 0F 69 D3 BA D6 AF 22 22 A1 .....!g.i...."".
[MASKED] [MASKED]
t=33856224 [st=526] URL_REQUEST_JOB_FILTERED_BYTES_READ
--> byte_count = 1072
--> bytes =
7B 0A 20 22 72 65 74 63 6F 64 65 22 3A 20 30 2C {. "retcode": 0,
0A 20 22 6D 65 73 73 61 67 65 22 3A 20 22 73 75 . "message": "su
[MASKED] [MASKED]
t=33856225 [st=527] HTTP_TRANSACTION_READ_BODY [dt=0]
t=33856225 [st=527] -CORS_REQUEST
t=33856225 [st=527] -REQUEST_ALIVE看起来很复杂,但实际上是有迹可循的。
Events 的常见概念
首先先了解一个概念,这里的 events 实际上是监控中的 transactions 和 events 两个概念的集合:
Event 指一个“事件”
- 比如输出一个错误信息、校验一个数据。
- Event 只有类型、发生时间、携带的数据、所属的 transaction 几个字段。
Transaction 指一个有起止时间的“过程”
- 比如一个请求的开始到结束、一个页面的加载过程。
- 一个 transaction 里面可以包含多个 transaction 或 event,比如一个页面的加载过程里面包含了 API 请求(transaction)、报错(event)、CORS 校验(event)等。
- Transaction 比 Events 多了一个持续时间字段,也有些系统会保存“起始/终止时间”而不是“起始/持续时间”,本质相同。
通过 transactions 和 events 的组合,就可以构成一棵消息树,也可以将这棵树用火焰图的形式展现出来,用于定位性能问题(一个常见的技巧是,性能问题往往出现在最底层的宽节点上)。
Events 的文字格式
简单解释一下每一行的格式:
t=浏览器时间 [st=发生时间] +类型A [dt=持续时间]
--> 携带的数据,使用 key = value 表示
--> 每行只有一个 key = value
--> 每个“+类型”对应一个“-类型”(transaction 结束)
--> 有时会出现“--> source_dependency = 943079 (类型)”的数据
--> 此时可以用鼠标点到对应的 event 中
t=浏览器时间 [st=发生时间] 类型B
--> 这里的缩进表示这是上一个事件的孩子
--> 如果有其它同级的数据,会使用同一缩进
--> 这里“类型”表示 event,无持续时间,但可以有数据
t=浏览器时间 [st=发生时间] -类型A
--> 这里也可以有数据这样看起来就清晰很多了。
发现问题
我在我的电脑和同事的电脑上各导出了一份 Events,经过对比,发现有个地方不同——这是同事电脑(可以复现问题)上的情况:
t=33855698 [st= 0] CHECK_CORS_PREFLIGHT_REQUIRED
--> preflight_required = true
--> preflight_required_reason = "disallowed_header"
t=33855698 [st= 0] CORS_PREFLIGHT_URL_REQUEST
--> source_dependency = 943079 (URL_REQUEST)
t=33856115 [st=417] CORS_PREFLIGHT_ERROR
--> cors-error = 26
--> error = "ERR_FAILED"
t=33856115 [st=417] CHECK_CORS_PREFLIGHT_REQUIRED
--> preflight_required = true
--> preflight_required_reason = "private_network_access"
t=33856115 [st=417] CORS_PREFLIGHT_URL_REQUEST
--> source_dependency = 943119 (URL_REQUEST)
t=33856162 [st=464] CORS_PREFLIGHT_RESULT
--> access-control-allow-headers = "content-type,x-request-id"
--> access-control-allow-methods = "GET,POST,PUT"
; source_dependency = 943079 (URL_REQUEST)
t=33856114 [st=416] +URL_REQUEST_DELEGATE_CONNECTED [dt=0]
t=33856114 [st=416] LOCAL_NETWORK_ACCESS_CHECK
--> client_address_space = "public"
--> resource_address_space = "local"
--> result = "blocked-by-policy-preflight-warn"
t=33856114 [st=416] -URL_REQUEST_DELEGATE_CONNECTED
; source_dependency = 943119 (URL_REQUEST)
t=33856116 [st= 1] +URL_REQUEST_DELEGATE_CONNECTED [dt=0]
t=33856116 [st= 1] LOCAL_NETWORK_ACCESS_CHECK
--> client_address_space = "public"
--> resource_address_space = "local"
--> result = "allowed-by-target-ip-address-space"
t=33856116 [st= 1] -URL_REQUEST_DELEGATE_CONNECTED可以看出,同事电脑上是这样的流程:
CHECK_CORS_PREFLIGHT_REQUIRED检查是否需要发起 pre-flight 请求,因为存在特殊 header,按照 CORS 规范需要先发送 pre-flight 请求CORS_PREFLIGHT_URL_REQUEST这个 pre-flight 请求相关的内容URL_REQUEST_DELEGATE_CONNECTED发起请求的过程LOCAL_NETWORK_ACCESS_CHECK检查 local network access,结论是blocked-by-policy-preflight-warn
CORS_PREFLIGHT_ERRORpre-flight 请求失败,错误码是 26,错误信息是ERR_FAILEDCHECK_CORS_PREFLIGHT_REQUIRED再次检查是否需要发起 pre-flight 请求,结论是需要,因为private_network_accessCORS_PREFLIGHT_URL_REQUEST这个 pre-flight 请求相关的内容URL_REQUEST_DELEGATE_CONNECTED发起请求的过程LOCAL_NETWORK_ACCESS_CHECK检查 local network access,结论是allowed-by-target-ip-address-space
CORS_PREFLIGHT_RESULTpre-flight 请求成功,返回了允许的 header 和方法
而我的电脑上(无法复现问题)的记录是这样的:
t=41646247 [st= 0] CHECK_CORS_PREFLIGHT_REQUIRED
--> preflight_required = true
--> preflight_required_reason = "disallowed_header"
t=41646247 [st= 0] CORS_PREFLIGHT_URL_REQUEST
--> source_dependency = 1007510 (URL_REQUEST)
t=41646467 [st=220] CORS_PREFLIGHT_RESULT
--> access-control-allow-headers = "content-type,x-request-id"
--> access-control-allow-methods = "GET,POST,PUT"
; source_dependency = 1007510 (URL_REQUEST)
t=41646248 [st= 1] +URL_REQUEST_DELEGATE_CONNECTED [dt=0]
t=41646248 [st= 1] LOCAL_NETWORK_ACCESS_CHECK
--> client_address_space = "loopback"
--> resource_address_space = "unknown"
--> result = "allowed-no-less-public"
t=41646248 [st= 1] -URL_REQUEST_DELEGATE_CONNECTED对应的流程是这样的:
CHECK_CORS_PREFLIGHT_REQUIRED检查是否需要发起 pre-flight 请求,因为存在特殊 header,按照 CORS 规范需要先发送 pre-flight 请求CORS_PREFLIGHT_URL_REQUEST这个 pre-flight 请求相关的内容URL_REQUEST_DELEGATE_CONNECTED发起请求的过程LOCAL_NETWORK_ACCESS_CHECK检查 local network access,结论是allowed-no-less-public
CORS_PREFLIGHT_RESULTpre-flight 请求成功,返回了允许的 header 和方法
在看的过程中,一共产生了五个超出我知识范围的问题:
LOCAL_NETWORK_ACCESS_CHECK是个什么检查?private_network_access是什么原因?为什么它会导致浏览器发送 pre-flight 请求?- 为什么在第一次 pre-flight 失败后,浏览器会再次发送 pre-flight 请求,而不是直接失败?
allowed-by-target-ip-address-space是什么结论?allowed-no-less-public是什么结论?为什么在检查中,我的参数和结论跟同事都不一样?
好嘛,问题有点多,就一个个入手吧。
什么是 Private Network Access
经过一番搜索,我找到了 Chrome 团队博客的文章:Private Network Access update: Introducing a deprecation trial,里面详细描述了这个特性,并给出了 相关规范的链接(这个规范已经改名为 Local Network Access 了,为了方便,下文都简称为 PNA)。
本节的概念和例子均取自规范,我稍作修改,并加上了一些个人的理解。
一个利用内网漏洞的例子
在 1996 年的 RFC 1918 中已经明确了 private 地址(本机、局域网)和 public 地址(互联网)。处于一些原因,业界的安全防护通常更多针对对外的系统,而忽略了内网系统的防护,所以一些内网设备如路由器、打印机等,不但有 Web 管理界面,还通常没有做好安全防护;公司的一些内部管理平台也可以免登录执行一些操作。
此外,UA(通常是浏览器)没有对二者做太多的区分,这会导致一个问题——来自互联网的页面可以向内网域名发起请求:
<!-- 来自 https://evil.com 的页面 -->
<iframe href="https://admin:[email protected]/set_dns?server1=123.123.123.123">
</iframe>这个请求会被 UA 发送到内网的路由器,而这个请求的目的是修改路由器的 DNS 设置,这是一个非常危险的行为,很可能会导致中间人攻击。由于使用了 iframe,CORS 并不能派上用场,Cookie 的 SameSite 策略也失去了作用。
三类 IP 地址
IP 地址可以被分成三类:
- 本地回环(loopback),如
127.0.0.1、::1等。有意思的是,按照 IPv4 的标准,整个127.0.0.1/8子网都属于本地回环,指向的都是本机,因此即使不安装网卡通常也能 ping 通。 - 局域网(local),如常见的
192.168.xx.yy、10.xx.yy.zz等。如果设备处在不同的局域网,这些 IP 会指向不同的地方。 - 公共网络(public),如互联网上的 IP。
这三类 IP 的“公共”(public)属性从上到下递减,例如 public 比 local 要更公共,local 比 loopback 更公共。
PNA 的思路
既然基于一些原因,内网设备的安全性普遍更低,那么依据安全设计十大原则之一的“默认安全”,只要浏览器发现当前页面的 IP 比请求的 IP 更公共,那么就会试图发一个 pre-flight 请求,如果目标允许当前页面发送请求(可以通过 Origin 头判断),则可以返回 Access-Control-Request-Private-Network: true 响应头,明确同意这个请求。
回看前文日志中的检测过程:
LOCAL_NETWORK_ACCESS_CHECK
--> client_address_space = "public"
--> resource_address_space = "local"
--> result = "blocked-by-policy-preflight-warn"可以看到,client_address_space 是当前页面的 IP 地址类型,resource_address_space 是请求的 IP 地址类型,result 则是检查的结果。在同事电脑的第一个检查中,请求的 IP 更加私有,于是被阻止了。
但是这并没能解决问题。跨域请求的域名也是个对外域名啊,为什么会被判定为 local?此外另外两个检查的问题也没能得到解答:
; 跟前面的检查参数相同,为什么结果不一样?
LOCAL_NETWORK_ACCESS_CHECK
--> client_address_space = "public"
--> resource_address_space = "local"
--> result = "allowed-by-target-ip-address-space"
; 为什么我的电脑上连 IP 地址类型都变了?
LOCAL_NETWORK_ACCESS_CHECK
--> client_address_space = "loopback"
--> resource_address_space = "unknown"
--> result = "allowed-no-less-public"内网 DNS 搞的鬼
我跟同事都 ping 了一下跨域请求的域名,发现被解析到了 10.131.xx.yy,是个内网 IP 地址……
这就很有意思了,于是我去公司的平台上找到了这个域名的记录,发现它有一条 private DNS 记录,是 CNAME 到了另一个域名,而后者又用 A 记录指向了这个 IP。此外前者还有一条 public DNS 记录,指向了公司外网的网关。
在内网走内网 IP 可以加速访问、减轻外网网关的压力,合情合理,只是并不是所有域名都配置了 private DNS,才导致了 PNA 的问题。这不会影响外网用户。
“被判定为 local”的问题得到了解答,还剩了几个问题。只靠 NetLog 和规范都已经到头了,还是得看 Chrome 是怎么实现的。不过还好这个字段名比较特殊,在源码中搜索应该不会特别费劲。
Chrome 的实现
试图找出 Chrome 是如何检查 PNA 规则的。我在 Chromium 的源码中搜索了 client_address_space,发现了一个 third_party/blink/common/security/address_space_feature.cc 这个文件中有一个符号定义,点击符号可以看到所有引用的地方:

里面提到了大量的 AddressSpace::kLoopback、AddressSpace::kLocal、AddressSpace::kPublic、AddressSpace::kUnknown,这应该就是 IP 地址类型的枚举值了。搜一下 AddressSpace::kLocal 发现了一个很可疑的函数 LocalNetworkAccessChecker::Check,里面调用了 LocalNetworkAccessChecker::CheckInternal,而后者就是详细的检查逻辑了。可以看到里面的返回值就是 NetLog 中的 result 字段,例如:
return Result::kBlockedByLoadOption;
return Result::kAllowedPotentiallyTrustworthySameOrigin;
return Result::kAllowedMissingClientSecurityState;
return Result::kAllowedByPolicyAllow;
return Result::kAllowedByTargetIpAddressSpace;
// ...这就好办了!我直接看到 blocked-by-policy-preflight-warn 对应的 Result::kBlockedByPolicyPreflightWarn,结果发现到了函数的最后面,这看起来是已经得出了“不通过”的结论,需要根据浏览器当前的策略来决定如何处理,而目前浏览器的策略是 Policy::kPreflightWarn,因此返回了这个错误:
switch (policy) {
case Policy::kAllow:
NOTREACHED(); // Should have been handled by the if statement above.
return Result::kAllowedByPolicyAllow;
case Policy::kWarn:
return Result::kAllowedByPolicyWarn;
case Policy::kBlock:
return Result::kBlockedByPolicyBlock;
case Policy::kPreflightWarn:
return Result::kBlockedByPolicyPreflightWarn;
case Policy::kPreflightBlock:
return Result::kBlockedByPolicyPreflightBlock;
}也就是说,检测的代码应该是在前文,只要通过了检测,就返回一个 Result::kAllowXXX 之类的值。那我先看一下同事电脑上通过的 allowed-by-target-ip-address-space 是怎么检测的。这是唯一一个返回此结果的代码:
// 如果 target_address_space_ 不是 Unknown,那么就会检查它是否与 resource_address_space 相同
// 在同事的电脑上,它肯定不是 Unknown(不然走不到这一步)
// 既然 resource_address_space 在日志里看到是 local,所以就是检查它是否等于 kLocal
if (target_address_space_ != mojom::IPAddressSpace::kUnknown) {
if (resource_address_space == target_address_space_) {
return Result::kAllowedByTargetIpAddressSpace;
}
// ...
}又冒出了个新名词:target_address_space_,经过追踪代码先后发现了 Request::CreateRequestWithRequestOrString、ResourceRequestHead::SetTargetAddressSpace 两个函数,后者的调用则需要我重点关注。点开这个符号,可以看到大部分的调用传参都是 kUnknown(第 2、3、7 条记录虽然没标明 kUnknown,但只是因为参数在下一行,点进去之后发现也是 kUnknwon),这也可以看出,对于大部分请求如 frame、xhr、manifest,由于设置了 kUnknown,是不会被这段逻辑放过的。

排除掉这些记录后,只剩下第一条和最后一条,但是继续追踪,发现调用链又绕回来了……
规范中的定义
当阅读代码遇到瓶颈时,我会回到规范中去寻找答案。在 PNA 规范的 3.1.2 CORS preflight 一节中,我看到了数个跟 target IP address space 相关的描述(这里不是全部的内容,只节选了相关的段落):
- 1. Add a new target IP address space property to the
requeststruct, initiallynull. 2. Amend the Local Network Access check algorithm to handle this new property...
2.1. If
request's target IP address space is notnull, then:- 2.1.1. If
connection's IP address space is not equal to therequest's target IP address space, then return anetwork error. - 2.1.2. Return null.
- 2.1.1. If
- 2.2. Let
clientAddressSpaceberequest's policy container's IP address space. 2.3. If
response's IP address space is less public thanclientAddressSpace, then:- 2.3.1. Let error be a
network error.
- 2.3.1. Let error be a
3. Define a new algorithm called
HTTP-no-service-workerfetch based on the existing steps in HTTP fetch...3.1. At the very start:
- 3.1.1. If
request's target IP address space is notnull, then setmakeCORSPreflighttotrue.
- 3.1.1. If
3.2. Immediately after running CORS-preflight fetch:
3.2.1. If
preflightResponseis anetwork error:- 3.2.1.1. If
preflightResponse's IP address space is null, returnpreflightResponse. - 3.2.1.2. Set
request's target IP address space topreflightResponse's IP address space. - 3.2.1.3. Return the result of running
HTTP-no-service-workerfetch givenfetchParams.
- 3.2.1.1. If
乍看上去似乎一切正常,但仔细观察会发现,在 3 里面定义了 HTTP-no-service-worker 的过程,而最后一行 3.2.1.3 是递归调用!而这就是出现了两次 pre-flight 请求的原因。稍微整理一下思路,在同事的电脑上是这样的:
fetch(request)request的 target IP address space 是null,不做任何处理(规则 3.1)【第一次 OPTIONS】
preflightResponse = fetch(innerRequest)(因为跨域所以发送一个 pre-flight 请求)innerRequest的 target IP address space 是null,不做任何处理(规则 3.1)innerRequest的 target IP address space 是null,不做任何处理(规则 2.1,这里没写重复,就是有两个相同的判断)clientAddressSpace赋值为innerRequest的 policy container(按照 规范 默认是client)的 IP address space,也就是日志里的public(规则 2.2)response的 IP address space 是local,小于clientAddressSpace,所以返回network error(规则 2.3)
发现
preflightResponse是network error(规则 3.2.1)preflightResponse的 IP address space 是local,不是null,所以将request的 target IP address space 设置为local(规则 3.2.1.2)【第二次 OPTIONS】返回
fetch(innerRequest)的值(规则 3.2.1.3)innerRequest的 target IP address space 此时已经是local,进入内部逻辑(规则 2.1)connection的 IP address space 是local,等于innerRequest的 target IP address space,返回null(规则 2.1.2)
结合前文展示的 Chromium 代码,可以得出最后成功的 pre-flight 就是被规则 allowed-by-target-ip-address-space 放过的。至此,重试请求的原因也清楚了。
我也明白了一个道理:难怪追代码的时候调用链绕了一圈又回来了,原来是因为 IP address space 实际上是运行时指定的,在代码中并不存在“将其赋值为某个常量”的场景。
还剩下最后一个问题:为什么我的电脑上 client_address_space 是 loopback、resource_address_space 是 unknown?
代理对地址类型的影响
我没有在规范中找到很明确的 kUnknown 的条件(除了“默认值是 kUnknown”),所以还得去看源码。在 LocalNetworkAccessChecker::Check 中我看到了这样一段:
mojom::IPAddressSpace resource_address_space =
TransportInfoToIPAddressSpace(transport_info);而里面调用的函数是这样写的:
IPAddressSpace TransportInfoToIPAddressSpace(const net::TransportInfo& info) {
switch (info.type) {
case net::TransportType::kDirect:
case net::TransportType::kCached:
return IPEndPointToIPAddressSpace(info.endpoint);
case net::TransportType::kProxied:
case net::TransportType::kCachedFromProxy:
return mojom::IPAddressSpace::kUnknown;
}
}也就是说,如果是 kDirect(直连)或者 kCached(缓存)则正常计算 IP 的 address space,但如果是 kProxied(代理)或者 kCachedFromProxy,那么就是 kUnknown。我把目光看向了浏览器右上角的 Switchy Omega 插件……
没错,当关闭了 Switchy Omega 插件或选择 direct 模式后,我的电脑也可以稳定复现这个问题了……
总结
这篇文章来来回回写了很多内容,总结一下,我的排查经历是:
- 查看 NetLog 发现了原因可能是违背了 PNA 规则;
- 了解了 PNA 的基本概念,并找出了内网 DNS 的原因;
- 通过查看 Chromium 源码发现了 target IP address space;
- 通过阅读规范了解了 target IP address space 的计算规则,并结合源码找出了重复请求的原因;
- 通过查看 Chromium 源码发现了代理插件对 IP address space 的影响。
过程中我提出了几个问题,它们都得到了解答:
PNA 是什么东西?
- PNA(private network access,又称 local network access)是一个针对浏览器设计的安全特性,用于防止外部网站(未经确认地)通过浏览器发起内网请求,从而导致防护相对较薄弱的内网遭遇攻击。
为什么同事电脑上的 fetch 请求被判定为是内网请求?为什么这违背了 PNA 规则?
- 因为公司平台的内网 DNS 配置包含了 fetch 的目标域名,但没有包含页面的域名,因此在浏览器看来,是在一个
public页面上发起了一个local请求,这在现阶段会被 PNA 的blocked-by-policy-preflight-warn规则拦截。
- 因为公司平台的内网 DNS 配置包含了 fetch 的目标域名,但没有包含页面的域名,因此在浏览器看来,是在一个
为什么第一次 pre-flight 失败后会重试?
- 因为第一次 pre-flight 请求返回了
network error,按照 PNA 规范,会递归调用HTTP-no-service-workerfetch 过程。
- 因为第一次 pre-flight 请求返回了
为什么在 NetLog 中看到两次 pre-flight 的待校验参数相同,但结果是第二次成功了?
- 这两次的待校验参数并不完全相同——除了
client_address_space和resource_address_space之外,还存在一个只在代码里出现过的target_address_space; - 第二次 pre-flight 请求的
target_address_space是local(第一次失败后被设置的),而第一次是null。
- 这两次的待校验参数并不完全相同——除了
为什么我的电脑上
client_address_space是loopback、resource_address_space是unknown?- 因为我使用了代理插件,它会导致 Chrome 将
resource_address_space判定为unknown。
- 因为我使用了代理插件,它会导致 Chrome 将
Update
Issues 面板中的错误
经群友提醒,PNA 的问题会在 issues 面板中被展示出来:

为什么我一开始没有看到呢?可能是因为之前 Issues 面板主要用来展示 SameSite Cookie 相关的一些问题,我觉得比较边缘(项目中暂时无法解决),就忽略掉了。看来以后查错误的时候,除了看 console 面板之外,还可以多看看 issues 面板。