R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

一次 PNA 问题的定位经验(Chrome NetLog Viewer 入门)

作为一个电商平台,用户下单时,我们提供了一个页面让其选择自提点。一天下午,同事突然发现这个页面会多请求一遍接口,具体现象是:

  • 先是一个 Pre-flight/OPTIONS 请求(因为接口跨域),但是这个接口失败了,从 F12 的 network 界面只能看到 net::ERR_FAILED,看不到具体的错误信息,从 console 界面也没有任何报错。
  • 然后是一个正常的 Pre-flight/OPTIONS 请求,这个请求成功了。
  • 然后是一个正常的 GET 请求,这个请求成功了。

奇怪的 request 行为

每个接口都会这样,因此应该是个共性问题。

我在我本地试了一下,得出了一个专业程序员常见的结论:在我本地没问题,是你们的问题,你看我有截图为证……截图的样子就是上图去掉了红色记录。

F12 看不到的问题

当然,说笑归说笑,这个问题在不止一个同事的电脑上可以稳定复现,所以我还是得认真排查一下。常见的请求问题排查直接用 F12 network 面板就可以了,但这个请求错误似乎没留任何痕迹。

从 F12 network 面板看不到任何有用的信息

F12 network 面板里的数据是经过解析的,里面一些很底层的内容(如 Connect 请求头)是看不到的。页面使用了 HTTPS,所以 Wireshark 似乎也没法抓到有效的数据。

Chrome 自身有没有可以查看底层数据的地方呢?有!Chrome 的 NetLog 可以把浏览器的网络事件完整记录下来,然后用官方提供的 NetLog Viewer 工具查看。

NetLog 雪中送炭

NetLog 是 Chrome 提供的一个底层调试工具,打开 chrome://net-export/ 页面,可以看到设计很原始:

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 里查看,在首页选择刚才生成的文件(或拖拽文件进来),页面会自动分析文件内容,并展示一些基本信息。

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

找到所需的 Events

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

与业务相关的 Events

直接点击记录就可以在右边看到一些具体的信息了(或者勾选第一列的复选框,可以在右边查看多条信息),例如我勾选 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.yy10.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 这个文件中有一个符号定义,点击符号可以看到所有引用的地方:

符号 client_address_space 的引用情况

里面提到了大量的 AddressSpace::kLoopbackAddressSpace::kLocalAddressSpace::kPublicAddressSpace::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::CreateRequestWithRequestOrStringResourceRequestHead::SetTargetAddressSpace 两个函数,后者的调用则需要我重点关注。点开这个符号,可以看到大部分的调用传参都是 kUnknown(第 2、3、7 条记录虽然没标明 kUnknown,但只是因为参数在下一行,点进去之后发现也是 kUnknwon),这也可以看出,对于大部分请求如 frame、xhr、manifest,由于设置了 kUnknown,是不会被这段逻辑放过的。

符号 SetTargetAddressSpace 的引用情况

排除掉这些记录后,只剩下第一条和最后一条,但是继续追踪,发现调用链又绕回来了……

规范中的定义

当阅读代码遇到瓶颈时,我会回到规范中去寻找答案。在 PNA 规范的 3.1.2 CORS preflight 一节中,我看到了数个跟 target IP address space 相关的描述(这里不是全部的内容,只节选了相关的段落):

  • 1. Add a new target IP address space property to the request struct, initially null.
  • 2. Amend the Local Network Access check algorithm to handle this new property...

    • 2.1. If request's target IP address space is not null, then:

      • 2.1.1. If connection's IP address space is not equal to the request's target IP address space, then return a network error.
      • 2.1.2. Return null.
    • 2.2. Let clientAddressSpace be request's policy container's IP address space.
    • 2.3. If response's IP address space is less public than clientAddressSpace, then:

      • 2.3.1. Let error be a network error.
  • 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 not null, then set makeCORSPreflight to true.
    • 3.2. Immediately after running CORS-preflight fetch:

      • 3.2.1. If preflightResponse is a network error:

        • 3.2.1.1. If preflightResponse's IP address space is null, return preflightResponse.
        • 3.2.1.2. Set request's target IP address space to preflightResponse's IP address space.
        • 3.2.1.3. Return the result of running HTTP-no-service-worker fetch given fetchParams.

乍看上去似乎一切正常,但仔细观察会发现,在 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)
    • 发现 preflightResponsenetwork 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_spaceloopbackresource_address_spaceunknown

代理对地址类型的影响

我没有在规范中找到很明确的 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 规则拦截。
  • 为什么第一次 pre-flight 失败后会重试?

    • 因为第一次 pre-flight 请求返回了 network error,按照 PNA 规范,会递归调用 HTTP-no-service-worker fetch 过程。
  • 为什么在 NetLog 中看到两次 pre-flight 的待校验参数相同,但结果是第二次成功了?

    • 这两次的待校验参数并不完全相同——除了 client_address_spaceresource_address_space 之外,还存在一个只在代码里出现过的 target_address_space
    • 第二次 pre-flight 请求的 target_address_spacelocal(第一次失败后被设置的),而第一次是 null
  • 为什么我的电脑上 client_address_spaceloopbackresource_address_spaceunknown

    • 因为我使用了代理插件,它会导致 Chrome 将 resource_address_space 判定为 unknown

Update

Issues 面板中的错误

经群友提醒,PNA 的问题会在 issues 面板中被展示出来:

Issues 面板中的 PNA 问题

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

更早:为 PNPM peer 添加 NPM alias 支持(PNPM 与 VSCode 动态调试入门)
更新:另一种形式的“微前端”——弹窗内嵌子应用

这是我们共同度过的

第 2868 天