链接预览 HTTP GET 违反规范

我一直被一个尚未被报告的问题困扰。我为其中涉及的复杂环节致歉,但我会尝试简明扼要地描述。

TL;DR:当我在消息中粘贴链接时,最终向该 URL 发送 HTTP GET 请求以获取嵌入数据的 Ruby Gem 所发出的请求,被某些 HTTP 代理视为不符合规范,从而无效。这导致在某些情况下预览功能无法正常工作:

稍详细的说明如下:我们使用了一个名为 Gitbook.io 的优秀文档服务。Gitbook 是一项托管解决方案,他们使用 Cloudflare Workers 进行站点内部的跳转。其 Cloudflare Workers 的一部分涉及使用 Node Fetch API 来代理 HTTP 请求。Node Fetch 的开发团队对遵循规范极其严格,他们会拒绝任何带有 HTTP 请求体的 GET 请求,甚至拒绝带有 Content-Length 头的请求,即使该头被设置为 0

实际情况正是如此。发出 HTTP 请求的 Ruby Gem 会发送一个包含以下请求头的请求:

Content-Length: 0

这令 Node Fetch 代理极为不满,最终导致请求被远程服务器拒绝。关于“GET 请求是否允许包含请求体”或“仅包含 Content-Length 头是否有效”的问题,在不同论坛上曾引发大量争论。我个人对此并无异议,但这并未阻止 Node Fetch 的开发团队关闭每一个请求他们允许此类语义的议题。

不幸的是,我夹在中间左右为难:

  • Node Fetch 项目 拒绝将这些 HTTP 请求视为有效
  • Cloudflare 支持团队拒绝提供帮助,因为我对相关的基于 Node 的 Workers 没有控制权。
  • Gitbook 的支持团队也拒绝协助,因为他们认同 Fetch 开发者的观点(而且我不确定他们是否真的在意)。
  • 而 HTTPrb 库 拒绝移除该请求头,因为他们认为这是完全有效的。

因此,我只能在此发帖询问:是否有任何方法可以控制或修改用于链接预览的 HTTP GET 请求,使其包含一组可接受的 HTTP 请求头,从而避免被使用像 Node Fetch 这样极其严格的库的代理所拒绝?

如果您想尝试复现,这里有一个托管在 Gitbook 服务器上并使用其基于 Node Fetch 的 Cloudflare Worker 的示例 URL:

6 个赞

@jamie.wilson / @techAPJ 是否知道为什么我们的请求会发送 Content-Length 为 0?能否确认这一行为?我认为这对 HEAD 请求是合理的,但对 GET 请求呢?

2 个赞

@sam,HTTP 请求似乎是由一个名为 httprb 的 Ruby 库发出的,该库具有这种行为。如果你查看“HTTPrb 库拒绝移除该标头,因为它认为该标头完全有效”这一条中的链接,你会看到该库的开发者在论证他为何是在遵循而非违背 HTTP 规范。

我在整个互联网上四处尝试,试图说服各方达成共识,最终成功促使有人向 httprb 提交了一个拉取请求,这或许能解决问题。

我不是 Ruby 开发者,甚至不知道如何测试这个修复。我推测,最终该 Gem 会发布包含此修复的版本,随后 Discourse 也会更新以使用该版本。如果有人能测试一下是否有效,那就太好了。复现步骤非常简单——只需将上面的链接粘贴到我的 Gitbook URL,然后查看预览是否被拒绝。

1 个赞

我看到的如下(与您第一个帖子中的图片一致):

文本“Getting Started Guide”表明请求已成功——它从 og:title 元标签中提取该字符串:

<meta property="og:title" content="Getting Started Guide" data-react-helmet="true">

描述缺失的错误/警告也是正确的。页面内容如下:

<meta property="og:description" content="" data-react-helmet="true">

图片 URL 来自 og:image 标签,内容如下:

<meta data-react-helmet="true" property="og:image" content="https://app.gitbook.com/share/space/thumbnail/-LA-UVvV3_TgzQyCXMWK.png">

如果我将 https://app.gitbook.com/share/space/thumbnail/-LA-UVvV3_TgzQyCXMWK.png 复制并粘贴到我的浏览器(macOS 上的最新 Safari)中,会收到以下错误:

Error: could not handle the request

通过 curl 发出相同的请求会得到相同的响应:

curl -v https://app.gitbook.com/share/space/thumbnail/-LA-UVvV3_TgzQyCXMWK.png
*   Trying 104.18.8.111...
* TCP_NODELAY set
* Connected to app.gitbook.com (104.18.8.111) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/cert.pem
  CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-ECDSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
*  subject: C=US; ST=California; L=San Francisco; O=Cloudflare, Inc.; CN=sni.cloudflaressl.com
*  start date: Jun 16 00:00:00 2021 GMT
*  expire date: Jun 15 23:59:59 2022 GMT
*  subjectAltName: host "app.gitbook.com" matched cert's "*.gitbook.com"
*  issuer: C=US; O=Cloudflare, Inc.; CN=Cloudflare Inc ECC CA-3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x142809200)
> GET /share/space/thumbnail/-LA-UVvV3_TgzQyCXMWK.png HTTP/2
> Host: app.gitbook.com
> User-Agent: curl/7.64.1
> Accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 256)!
< HTTP/2 500
< date: Mon, 23 Aug 2021 17:40:04 GMT
< content-type: text/plain; charset=utf-8
< content-length: 36
< cf-ray: 68361fb8ea9b4009-YYZ
< age: 432
< vary: Accept-Encoding
< via: magic cache
< cf-cache-status: HIT
< expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
< x-cache: HIT
< x-cloud-trace-context: 9d4cbd24a15138451c88b2ced35a32f1;o=1
< x-content-type-options: nosniff
< x-magic-hash: f46ac4bf6b6dc125a68e9ad566b48481631bb27eec2165532a7c0f538e93c4f6
< x-release: gitbook-28427-6.25.14
< server: cloudflare
<
Error: could not handle the request
* Connection #0 to host app.gitbook.com left intact
* Closing connection 0

如果您将 og:image URL 复制并粘贴到浏览器中,能看到图片吗?

总之:根据原始 URL 的响应,Onebox 预览似乎按预期运行。

3 个赞

@jamie.wilson 感谢您抽出时间调查此事。不过,能否请您澄清一下:您上面的测试是使用包含上述拉取请求的最新版 httprb gem 进行的,还是使用的是旧版库?

我最初在 Onebox 预览中看到的错误是目标 URL 返回了 500 状态码。在我发布此问题之前的某个时间点,Onebox 预览开始转而显示关于缺少 OpenGraph 元数据的提示。由于我在向 GitBook 支持团队发布此问题之前已经排查了数月,期间可能已发生某些变化。

如果 GitBook URL 实际上能够加载,只是缺少某些元数据或图片,那么这与请求被拒绝的情况是不同的。不过,我可以肯定的是,任何我自行发送的包含 Content-Length: 0 HTTP 请求头的请求,都会被远程服务器上的 CloudFlare Workers 拒绝。也许 Discourse 中用于发起请求的 HTTP 客户端已发生变化?我对 Discourse 的源代码并不了解,甚至无法百分之百确定 httprb 库就是实际发起 HTTP 请求的源头。

我完全不认为我们使用了 httprb 这个 gem。Oneboxing 流程(即生成链接预览的部分)使用的是 Ruby 标准库中的 Net::HTTP,同时也使用了 Excon gem 作为流程的一部分。

深入探究后,我发现我们确实有时会生成带有 Content-Length: 0 头部的请求。不过,至少就提供的这个 URL 而言,这并没有干扰 Onebox 的生成。

可能有过小版本的升级,但并没有像重构请求方式或更换所用库这样的大改动。

我们确实进行了一些改进,使 Oneboxing 总体上更加稳健,这或许可以解释为什么以前返回 500 错误的 URL 现在能成功生成 Onebox。

如果您有目前在进行 Oneboxing 时返回错误(或在 Discourse 其他部分未按预期工作)的 URL,欢迎随时发给我!

3 个赞

啊,这真是非常有用的信息。到了这个阶段,我不得不胡乱猜测涉及了哪些库,这主要是因为维护 CloudFlare 代理的 Gitbook 团队几乎没有提供任何帮助。

明白了。虽然我在上面没有提到,但我从 Gitbook 那里唯一能得到的信息是,他们的 CloudFlare 错误日志中拒绝来自 Discourse 的预览请求的错误信息如下:

使用 GET 或 HEAD 方法的请求不能包含请求体。

目前尚不清楚来自 Discourse 的 GET 请求是否真的包含了请求体,还是仅仅包含了 Content-Length: 0 头部。无论如何,根据一些人(包括 Cloudflare 方面的人)的说法,这确实违反了 Fetch 规范。

是的,在某个时间点,Onebox 的错误似乎从通用的 500 错误变成了现在包含一些具体数据。谁也说不准有哪些库被升级了(在此期间我也已经更新了 Discourse)。我希望有一种方法可以确切地捕获从 Discourse 发送的头部信息,但即使我访问像 http://httpbin.org/get 这样的 URL,我也无法“看到”返回的内容,因为结果完全被 Onebox 消费了,据我所知并没有记录在任何地方。

如果空的 content-length 头部现在已经消失,那么我至少可以和 Gitbook 合作修复他们的嵌入功能(虽然这不太可能发生,因为他们目前正在从头重写整个应用程序,并拒绝修复任何现有错误 :/,但至少这不是 Discourse 的问题)。

首先我要说明,上面写的很多内容远远超出了我的理解范围,但我正在尽力尝试。如果我跑题了,请直说我犯了蠢,这完全没问题。

这种情况我们遇到的很多,因为我们在社区中经常发布指向帮助中心(知识库)的链接。

以下是一些无法 Onebox 的链接示例:

https://help.republicwireless.com/hc/en-us/articles/115014150828--How-to-Add-an-E911-Address

从我输入时的预览面板中:

1 个赞

经过进一步排查,是 Excon gem 添加了 Content-Length: 0,但并未出现在 GET 请求中。

不过那段代码已经存在了 8 或 9 年,所以很可能不是问题所在。

Gemfile.lock 文件会显示核心 Discourse 所使用的 gem。

2 个赞

该网站受 Cloudflare 验证码保护,导致 Discourse 无法抓取任何信息 :slightly_frowning_face:

2 个赞

在浏览器中查看该页面时,它确实包含构建 Onebox 所需的元标签。然而,尝试获取该 URL 时,似乎出现了错误!

oneboxer 预览 URL: https://help.republicwireless.com/hc/en-us/articles/115014150828--How-to-Add-an-E911-Address
headers: {"User-Agent" => "Discourse Forum Onebox v2.8.0.beta4"}
helpers 响应代码: 403

这意味着我们使用 User-Agent “Discourse Forum Onebox v2.8.0.beta4” 请求了该 URL,但远程 Web 服务器返回了 403 状态码

同样,使用命令行工具 wget

wget https://help.republicwireless.com/hc/en-us/articles/115014150828--How-to-Add-an-E911-Address
--2021-08-23 17:38:30--  https://help.republicwireless.com/hc/en-us/articles/115014150828--How-to-Add-an-E911-Address
正在解析 help.republicwireless.com (help.republicwireless.com)... 104.16.53.111, 104.16.51.111
正在连接 help.republicwireless.com (help.republicwireless.com)|104.16.53.111|:443... 已连接。
已发送 HTTP 请求,正在等待响应... 403 Forbidden

这也表达了同样的意思……我们发送了有效的请求,但远程 Web 服务器拒绝返回结果。help.republicwireless.com 的负责人能否解除对这些有效请求的封锁?

这两个网站没有 OpenGraph 标题/描述标签,但它们确实包含其他标题/描述,Onebox 应该可以回退使用。这是我们需要着手修复的问题。

2 个赞

:confused: 不过它多年来一直运行正常。

这里有一个例子,来自同一网站的链接展示了有效的 Onebox:https://forums.republicwireless.com/t/4-digit-pin-which-i-have-forgot/37655/2

该链接指向 https://help.republicwireless.com/hc/en-us/articles/115012101188-Can-t-Get-Past-the-Screen-Lock-on-the-Phone

1 个赞

Cloudflare 会不断更新其机器人检测算法。如果您希望 Discourse 不被屏蔽,建议联系其支持团队,询问请求被拦截的原因。

5 个赞

抱歉,此问题已关闭,因为它已过时。我们需要新的问题重现才能重新打开。