以下语言可用: 简体中文 正體中文 English
作为一个电商平台,用户下单时,我们提供了一个页面让其选择自提点。一天下午,同事突然发现这个页面会多请求一遍接口,具体现象是:
- 先是一个 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_ERROR
pre-flight 请求失败,错误码是 26,错误信息是ERR_FAILED
CHECK_CORS_PREFLIGHT_REQUIRED
再次检查是否需要发起 pre-flight 请求,结论是需要,因为private_network_access
CORS_PREFLIGHT_URL_REQUEST
这个 pre-flight 请求相关的内容URL_REQUEST_DELEGATE_CONNECTED
发起请求的过程LOCAL_NETWORK_ACCESS_CHECK
检查 local network access,结论是allowed-by-target-ip-address-space
CORS_PREFLIGHT_RESULT
pre-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_RESULT
pre-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
request
struct, 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
clientAddressSpace
berequest
'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-worker
fetch 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 setmakeCORSPreflight
totrue
.
- 3.1.1. If
3.2. Immediately after running CORS-preflight fetch:
3.2.1. If
preflightResponse
is 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-worker
fetch 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-worker
fetch 过程。
- 因为第一次 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 面板。