Предварительный просмотр ссылки через HTTP GET нарушает спецификацию

Я уже довольно давно сталкиваюсь с проблемой, которая, похоже, ещё не была зафиксирована. Извините за сложность ситуации с множеством задействованных компонентов, но я постараюсь описать её кратко.

Суть в том, что при вставке ссылки в сообщение Ruby-гем, который в итоге выполняет HTTP GET-запрос к этому URL для получения данных встраивания, отправляет запрос, который некоторые HTTP-прокси считают недействительным согласно спецификации. Это в некоторых случаях препятствует работе предпросмотра:

Более подробное описание: мы используем отличный сервис Gitbook.io для нашей документации. Gitbook — это хостинговое решение, и они используют Cloudflare Workers для внутренних перенаправлений на своём сайте. Часть работы этих Cloudflare Workers связана с использованием Node Fetch API для проксирования HTTP-запросов. Разработчики Node Fetch очень педантичны в соблюдении спецификации и отклоняют любой GET-запрос, содержащий тело HTTP или даже заголовок Content-Length, даже если этот заголовок установлен в 0.

Именно это и происходит. Ruby-гем, выполняющий HTTP-запрос, отправляет заголовок:

Content-Length: 0

Это вызывает сильное недовольство у прокси на базе Node Fetch, и в результате запрос отклоняется удалённым сервером. На различных форумах ведутся долгие споры о том, является ли тело запроса в GET или просто наличие заголовка Content-Length допустимым согласно спецификации HTTP. У меня лично нет возражений против этого, но это не остановило разработчиков Node Fetch от закрытия всех созданных ими тем, в которых просили разрешить такую семантику.

К сожалению, я оказался в центре этого конфликта:

Поэтому я обращаюсь сюда с вопросом: существует ли возможность контролировать или изменять HTTP GET-запросы, используемые для предпросмотра ссылок, так, чтобы они включали допустимый набор заголовков, и прокси, использующие крайне педантичные библиотеки вроде Node Fetch, не отклоняли бы эти запросы?

Если хотите попробовать, вот пример URL, размещённый на серверах Gitbook и использующий их Cloudflare Worker на базе Node Fetch:

6 лайков

@jamie.wilson / @techAPJ, есть ли какие-то идеи, почему мы отправляем Content-Length равный 0 в наших запросах? Можете подтвердить это поведение? Я предполагаю, что это имеет смысл для HEAD, но для GET?

2 лайка

Привет, @sam! Похоже, что HTTP-запросы выполняются библиотекой Ruby под названием httprb, которая ведёт себя именно так. Если вы перейдёте по ссылке в пункте «Библиотека HTTPrb отказывается удалять заголовок, так как считает его полностью корректным», то увидите, как разработчик этой библиотеки обосновывает своё решение: он не нарушает спецификацию HTTP, а лишь немного её интерпретирует.

Пока я искал по всему интернету способ добиться согласия, мне удалось найти человека, который отправил этот pull-запрос в httprb, что может решить проблему.

Я не разработчик на Ruby, поэтому даже не знаю, как протестировать это. Предполагаю, что в конечном итоге этот Gem выпустит версию с исправлением, а затем Discourse обновится и начнёт использовать её. Было бы здорово, если бы кто-то мог проверить, работает ли это. Сценарий воспроизведения очень прост — просто вставьте ссылку выше на мой URL в Gitbook и посмотрите, будет ли предпросмотр отклонён.

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 в свой браузер (последняя версия Safari на MacOS), я получу ошибку:

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

Видите ли вы изображение, если скопируете и вставите URL из og:image в свой браузер?

В итоге: предпросмотр Onebox, судя по ответу исходного URL, работает как положено.

3 лайка

@jamie.wilson Спасибо за уделённое время на рассмотрение этого вопроса. Однако, не могли бы вы уточнить, был ли ваш тест выше проведён с новейшей версией гема httprb, включающей упомянутый pull request, или с более старой версией библиотеки?

Первоначальная ошибка, которую я наблюдал в предпросмотре Onebox, заключалась в том, что целевой URL возвращал код состояния 500. В какой-то момент до публикации этого сообщения предпросмотр Onebox начал вместо этого показывать уведомление об отсутствии метаданных Open Graph. Поскольку я занимался устранением этой проблемы в течение нескольких месяцев до обращения в поддержку Gitbook, возможно, что-то изменилось за это время.

Если URL Gitbook действительно загружается, но просто не хватает некоторых метаданных или изображений, то это отличается от отклонения запроса. Однако я точно знаю, что любые запросы, которые я отправляю сам и которые содержат заголовок HTTP Content-Length: 0, отклоняются работниками CloudFlare на удалённом сервере. Возможно, HTTP-клиент, используемый для выполнения запросов в Discourse, изменился? Я ничего не знаю об исходном коде Discourse и даже не на 100% уверен, что библиотека httprb является фактическим источником HTTP-запросов.

Я не думаю, что мы вообще используем гем httprb. Процесс Oneboxing (то есть механизм, генерирующий превью ссылок) использует Net::HTTP из стандартной библиотеки Ruby, а также гем Excon как часть этого процесса.

Поглубже копнув, я вижу, что иногда мы генерируем запросы с заголовком Content-Length: 0. Однако в случае предоставленного URL это, по крайней мере, не мешает генерации Onebox.

Возможно, было обновление на minor-версию, но ничего кардинального, вроде перестройки архитектуры запросов или замены используемых библиотек.

Произошли некоторые изменения, направленные на повышение общей надёжности Oneboxing, что может объяснить, почему URL, которые ранее вызывали ошибку 500, теперь успешно обрабатываются через Onebox.

Если у вас есть URL, которые сейчас возвращают ошибки при Oneboxing (или не работают как ожидалось в другой части Discourse), пожалуйста, не стесняйтесь присылать их мне!

3 лайка

Ах, это очень полезная информация. На данный момент мне приходится делать дикие предположения о том, какие библиотеки вообще задействованы, в основном из-за того, что команда Gitbook, поддерживающая прокси CloudFlare, практически не помогает.

Понял. Я не упоминал об этом выше, но единственная информация, которую мне удалось получить от Gitbook, касалась ошибки в их логах CloudFlare, из-за которой отклонялись запросы превью от Discourse:

Запрос с методом GET или HEAD не может иметь тело.

Неясно, содержал ли GET-запрос от Discourse фактически тело или только заголовок Content-Length: 0. В любом случае, согласно некоторым источникам (включая специалистов Cloudflare), это нарушает спецификацию Fetch.

Да, в какой-то момент ошибка Onebox изменилась с общей 500 на ту, что теперь содержит данные. Невозможно сказать, какие библиотеки могли быть обновлены (и за это время я обновил сам Discourse). Я бы хотел иметь возможность точно зафиксировать, какие заголовки отправляются из Discourse, но даже если я обращусь к URL вроде http://httpbin.org/get, я не могу «увидеть» результат, поскольку данные полностью потребляются Onebox и, насколько мне известно, нигде не логируются.

Если заголовок пустого content-length теперь исчез, то я хотя бы смогу работать с Gitbook над исправлением их функционала встраивания (хотя этого, скорее всего, не произойдет, так как они сейчас переписывают своё приложение с нуля и отказываются исправлять существующие баги :/, но это, по крайней мере, не проблема Discourse).

Сначала хочу сказать, что многое из написанного выше мне совершенно не понятно, но я пытаюсь ухватиться за соломинку. Если я пишу не по теме, пожалуйста, не стесняйтесь сказать мне, что я не прав.

Мы часто сталкиваемся с этим, поскольку часто публикуем ссылки на наш Центр помощи (базу знаний) в нашем Сообществе.

Некоторые примеры ссылок, которые не работают при Oneboxing:

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

Из панели предпросмотра во время ввода:

1 лайк

После более тщательного анализа выяснилось, что заголовок Content-Length: 0 добавляет гем Excon, но только не для запросов GET.

Однако этот код существует уже 8 или 9 лет, так что, скорее всего, проблема не в нём.

Файл Gemfile.lock покажет вам гемы, используемые ядром Discourse.

2 лайка

Этот сайт защищён Cloudflare с капчей, и она блокирует Discourse от получения любой информации с него :slightly_frowning_face:

2 лайка

При просмотре в браузере эта страница действительно содержит необходимые мета-теги для создания Onebox. Однако при попытке получить доступ к URL кажется, что возникает ошибка!

oneboxer preview 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 response code: 403

Это означает, что мы отправили запрос к этому URL с User Agent “Discourse Forum Onebox v2.8.0.beta4”, но удалённый веб-сервер вернул код состояния 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
Resolving help.republicwireless.com (help.republicwireless.com)... 104.16.53.111, 104.16.51.111
Connecting to help.republicwireless.com (help.republicwireless.com)|104.16.53.111|:443... connected.
HTTP request sent, awaiting response... 403 Forbidden

Это говорит о том же: мы отправляем корректный запрос, но удалённый веб-сервер отказывается возвращать результат. Возможно ли, чтобы ответственные за help.republicwireless.com разблокировали эти корректные запросы?

У этих двух сайтов отсутствуют теги OpenGraph title/description, однако у них есть другие заголовки и описания, к которым 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 лайков

Извините, закрываю эту тему как устаревшую. Для открытия новой потребуется новое воспроизведение проблемы.