Знаете, кому 29 апреля стукнуло 30 лет? Спецификации FastCGI. Тридцать лет с 1996 года. Погодите. Эта заметка не про ностальгию по .fcgi-скриптам, которые на каждый запрос форкали отдельный процесс и которыми сегодня никто не пользуется. И не про CGI вообще.
Разговор о другом. У нас всех в проде между прокси и бэкендом обычно стоит HTTP. nginx перед Go-приложением, Caddy перед Python-сервисом, Apache перед PHP-FPM, неважно, поверх там HTTP/1.1 или HTTP/2. И вот Эндрю Айер на agwa.name к юбилею FastCGI собрал аргументы, что этот участок инфраструктуры всё это время сидит на не самом удачном протоколе. Айер основатель SSLMate, и в SSLMate всё крутится на FastCGI в проде уже больше десяти лет. Так что пишет не теоретически.
Заметка короткая и по делу. HN-тред собрал сотню комментариев, для 2026 года это не топ, но там пишут люди, которые знают, о чём говорят. Если попроще, аргумент такой: у HTTP как протокола между прокси и бэкендом есть два структурных бага, которых у FastCGI нет, и индустрия за тридцать лет так и не нашла повода переехать. А обсуждение в треде ушло дальше: почему вообще HTTP победил, если он хуже технически. И ответ оказался любопытнее самого аргумента.
Что у HTTP сломано — десинк-атаки
Первый структурный баг это request smuggling, он же desync. Звучит страшно, но суть простая.
У HTTP/1.1 есть свойство, которое Айер называет «it’s just text!»: вроде бы всё прозрачно. А на деле распарсить HTTP корректно очень непросто. В протоколе нет явного фрейминга. Сообщение само описывает, где оно заканчивается: либо через Content-Length, либо через Transfer-Encoding, иногда через оба сразу. И в правилах разбора накопилось столько edge case’ов, что две реализации HTTP могут по-разному определять, где одно сообщение закончилось, а второе началось.
Дальше история становится неприятной. Допустим, в одном звене у вас reverse proxy, в другом бэкенд. Парсят они HTTP чуть-чуть по-разному. Атакующий может протащить второй запрос внутри тела первого. Прокси обработает один запрос, а бэкенд распарсит его как два. Второй запрос приедет на бэкенд с привилегиями чужой сессии. Так и работает desync.
Каноничный пример: смесь Content-Length и Transfer-Encoding в одном запросе.
POST / HTTP/1.1Host: example.comContent-Length: 35Transfer-Encoding: chunked0GET /admin HTTP/1.1Host: example.com
Прокси смотрит на Content-Length: 35 и считает, что тело POST занимает все 35 байт ниже (включая «GET /admin…»). Бэкенд смотрит на Transfer-Encoding: chunked, видит 0\r\n\r\n и считает, что тело закончилось. Хвост GET /admin HTTP/1.1 он распарсит как новый запрос, пришедший по той же сессии. Класс называется CL.TE smuggle, есть и зеркальный TE.CL.
Свежий пример, с которого Айер начинает: десинк в Discord media proxy, раскрытый в начале этого года. Можно было читать чужие приватные вложения. Класс багов старый, его подробно описала Watchfire ещё в 2005 году, и тогда же предупредила. Попытки лечить это в духе «починим парсеры» обречены, потому что парсеров много и они продолжат расходиться. Двадцать лет спустя Джеймс Кеттл из PortSwigger примерно раз в год находит новые варианты. После последней пачки запустил сайт с одним адресом и одним сообщением: http1mustdie.com, «HTTP/1.1 must die».
HTTP/2 эту проблему решает, но только если он стоит и на прокси, и на бэкенде. Тогда у сообщений есть чёткие границы. FastCGI решил то же самое в 1996 году, более простым протоколом, потому что у него границы тоже чёткие. И вот тут Айер замечает интересную деталь. nginx поддерживает FastCGI-бэкенды с самого первого релиза. А HTTP/2 на бэкенд у nginx появился только в конце 2025-го. У Apache бэкендный HTTP/2 до сих пор экспериментальный. FastCGI был под рукой всё это время.
Что у HTTP сломано — путаница с заголовками
Второй баг — про заголовки.
У HTTP нет нормального способа отличить доверенные данные о запросе от того, что прислал клиент. Реальный IP клиента, имя авторизованного пользователя, детали клиентского сертификата — всё это прокси хочет передать дальше. Но передаёт через те же HTTP-заголовки, которые отправляет и клиент. Обычная схема такая: внутри команды договорились, что X-Real-IP, X-Forwarded-For, True-Client-IP это «наши» прокси-заголовки. Прокси сам их выставляет, а в клиентских запросах их заранее режет.
В теории нормально. На практике это минное поле, потому что между «доверенным заголовком» и «недоверенным заголовком» в HTTP нет никакой структурной разницы. Это всё просто заголовки. И где-нибудь в стеке у вас сидит библиотека, которая читает не тот заголовок, который вы зачистили, а соседний.
Конкретно у Айера такой пример. Go-шный middleware Chi сначала читает True-Client-IP, и только если его нет, читает X-Real-IP. Вы добросовестно зачистили X-Real-IP на прокси. Атакующий шлёт True-Client-IP. Chi читает его. И реальный IP клиента в вашем приложении это то, что прислал атакующий.
У FastCGI этого бага не может быть по определению. Параметры запроса и HTTP-заголовки от клиента живут в одном словаре key/value, но клиентские заголовки имеют префикс HTTP_. Прокси выставляет, например, REMOTE_ADDR напрямую. Чтобы клиент сумел подделать REMOTE_ADDR, ему нужно прислать заголовок с буквальным именем HTTP_REMOTE_ADDR, а бэкенд распознает его как клиентский, а не как доверенный.
На уровне wire-формата это видно сразу. Параметры приходят к бэкенду как набор пар ключ-значение:
REMOTE_ADDR = 203.0.113.42 # доверенный (выставил прокси)SERVER_NAME = example.com # доверенныйHTTP_HOST = example.com # клиентский (был в Host: header)HTTP_USER_AGENT = Mozilla/5.0 # клиентскийHTTP_X_FORWARDED_FOR = evil.com # клиентский (попытка прикинуться прокси)
Префикс HTTP_ буквально на уровне формата отделяет то, что прислал клиент, от того, что выставил прокси. По именам столкнуться нельзя.
То есть в FastCGI попытка подделки выглядит иначе, чем настоящая атака. Класса коллизий по именам заголовков там просто нет: разделение «доверенное / недоверенное» заложено в сам формат wire-протокола.
Сводка по двум классам багов:
|
Структурный баг |
HTTP/1.1 |
HTTP/2 |
FastCGI |
|---|---|---|---|
|
Request smuggling / desync |
уязвим |
защищён, если используется на всём пути |
защищён по конструкции |
|
Подделка |
уязвим |
уязвим |
защищён через |
FastCGI это не CGI
Главное возражение в HN-треде звучит сразу: FastCGI ассоциируется со старыми .fcgi-скриптами. Те самые, что на каждый запрос форкали процесс и которыми сегодня никто не пользуется. Айер аккуратно разделяет эти две сущности: FastCGI как wire-протокол и FastCGI как process model это разные вещи. Process model устарел. Wire-протокол всё ещё актуален. Сегодня FastCGI это просто альтернативный транспорт для HTTP-запросов поверх TCP- или Unix-сокета.
В Go переход делается тривиально. Было:
l, _ := net.Listen("tcp", "127.0.0.1:8080")http.Serve(l, handler)
Стало:
l, _ := net.Listen("tcp", "127.0.0.1:8080")fcgi.Serve(l, handler)
fcgi.Serve это drop-in замена для http.Serve. Хендлер тот же. http.ResponseWriter и http.Request остаются те же типы.
nginx, Apache, Caddy, HAProxy: у каждого FastCGI-бэкенды настраиваются одной-двумя строчками конфига. Например, в nginx разница между HTTP-бэкендом и FastCGI-бэкендом одна строчка плюс инклуд:
# HTTP бэкендlocation / { proxy_pass http://127.0.0.1:8080;}# FastCGI бэкендlocation / { fastcgi_pass 127.0.0.1:8080; include fastcgi_params;}
По объёму работы это как прикрутить TLS-терминирование. Только потом возни сильно меньше.
Айер ещё отмечает приятный момент. Стандартная Go-шная либа net/http/fcgi сама достаёт Request.RemoteAddr из доверенного REMOTE_ADDR. И сама же выставляет ненулевое поле Request.TLS, если прокси сообщил, что клиент пришёл по HTTPS. Тот middleware, который в Go-сервисах обычно достаёт реальный IP из X-Forwarded-For, в случае с FastCGI просто не нужен. «It Just Works», пишет Айер, и это редкий момент эмоций в очень сухом тексте.
Честные минусы у FastCGI тоже есть, и Айер их перечисляет.
WebSocket’ов в FastCGI нет, так и не доделали. Тулинг победнее: curl его не понимает, хотя умеет в FTP, Gopher и SMTP. Когда Айер прогонял бенчмарки Go-шного FastCGI-сервера через разные reverse proxy, на части нагрузок пропускная способность оказывалась хуже, чем на HTTP/1.1 или HTTP/2. Сам он это объясняет тем, что код-пути FastCGI в индустрии оптимизировали в разы меньше, чем HTTP. Не врождённый дефект протокола, а недополученное внимание. И сегодняшний облачный подход в духе «просто давайте HTTP, мы обработаем» места под FastCGI в инфраструктуре особо не оставил, хотя технически большинство компонентов его поддерживают.
Почему HTTP всё-таки победил
Тут начинается интересное. Если FastCGI и правда структурно лучше, и при этом он тридцать лет как готовый, то почему рынок остался на HTTP?
Самый полезный комментарий в HN-треде объясняет это через end-to-end principle. Идея этого принципа простая: пусть сеть будет максимально тупой, а вся прикладная логика живёт на концах. Тогда между endpoint’ами можно набрасывать любые промежуточные звенья: кеш, фильтр от DDoS, региональный роутер, TLS-терминатор, шлюз авторизации. И каждое из них может ничего не знать про остальные. HTTP-везде это и есть буквальное воплощение принципа для веба. У вас одно приложение работает в дев-режиме с прямым подключением из браузера, и оно же работает в проде за пятью слоями прокси. И кода при этом менять не надо.
А у Айера в статье на знамени стоит другой принцип, principle of least privilege. По-русски «минимально необходимые привилегии», но в контексте сетевой безопасности удобнее переводить как «доверять только тому, что явно ожидаешь». «Allowlist your communications to only what you expect, so that you aren’t unwittingly contributing to a compromise elsewhere in the network», пишет Айер. Не доверяй заголовкам, которых не просил. И не оставляй неоднозначностей фрейминга в формате: разделяй «доверенное» и «недоверенное» структурно.
Оба принципа правильные. И вот в чём дело: они спорят за одно и то же место. End-to-end даёт вебу композициональную гибкость, ту самую, благодаря которой веб тридцать лет уделывает любые закрытые альтернативы. Least privilege ловит ровно тот класс багов, про который пишет Айер: где у пользователя Discord утекают приватные вложения, потому что два парсера разошлись в edge case’е Content-Length. И там, и там вред реальный. Вопрос только в том, какой класс вреда сейчас больнее. Практический ответ: смотря что вы запускаете.
Вторая ветка обсуждения в треде идёт в другую сторону. Аргумент такой: connection caching и multiplexing, которыми современный HTTP-стек пользуется буквально на каждом шагу, и так уже нарушают end-to-end. Класса desync-багов мы получили не потому, что выбрали HTTP. Мы получили его, потому что выбрали HTTP, плюс закешировали соединения, плюс мультиплексировали запросы по этим соединениям через весь стек. И при этом продолжали делать вид, что топология осталась end-to-end. Десинк-атаки живут ровно в тех точках, где эта иллюзия рассыпается.
Третья ветка приводит ещё один кейс. Гугл уже давно внутри своих сервисов пользуется собственным RPC под названием Stubby. Stubby оборачивает HTTP-семантику в другой wire-протокол для service-to-service трафика. На границе с интернетом у Гугла HTTP. А между сервисами внутри границы уже не HTTP. По этому аргументу, композициональная гибкость имеет смысл на границе сети. Внутри границы, на участке прокси-к-бэкенду, никакой границы уже нет. Принцип применяется там, где ему делать нечего.
Если посмотреть под этим углом, вся статья Айера читается иначе. Айер не утверждает, что HTTP плохой протокол. HTTP отлично справляется с участком браузер-к-краю, где как раз и нужна end-to-end-композициональность. Он показывает, что участок прокси-к-бэкенду уже не end-to-end, и HTTP туда импортирует класс багов, которого там быть не должно. FastCGI как wire-формат это и есть HTTP, переосмысленный под актуальные ограничения этого участка: явный фрейминг, структурное разделение доверенного и недоверенного, нет класса коллизий имён.
Что говорят те, кто давно в проде
Тред в основном собран из спокойных свидетельств людей, которые давно с этим живут в проде.
SSLMate на FastCGI больше десяти лет — это один пример. Другие в комментариях писали, что у них «все веб-клиенты» живут на FastCGI больше десяти лет.
Подробнее всего в треде разбирали uWSGI. Один комментатор писал, что в нескольких местах прокси-бэкенд жил на uWSGI много лет, и в основном это был положительный опыт. Ещё один описывал собственный протокол под названием WAS (Web Application Socket), который у него на работе в CM4all лет пятнадцать назад спроектировали с нуля как отдельный открытый протокол. WAS до сих пор крутится в проде CM4all, в хостинговых окружениях с PHP.
У всех таких историй один общий вывод: если на участке прокси-бэкенд использовать не-HTTP протокол и какое-то время его погонять, целый класс багов из стека просто пропадает.
В треде звучали и возражения, и они тоже по делу. Один комментатор описывал, как в эпоху FastCGI/SCGI/HTTP основывал Web 2.0-стартап и выбрал HTTP, потому что «вместо ещё одного протокола в стеке можно просто использовать HTTP, который вам всё равно нужен на гейтвее». Экономия на сетапе реальная. nginx тогда работал заметно быстрее большинства FastCGI/SCGI-модулей того времени и был стабильнее. Это тоже не выдумка. Когда годы спустя стоимость security-багов выросла до бесконечной серии CVE, стоимость миграции уже выросла соразмерно.
Сам Айер в треде дал отдельное уточнение про CGI, и это важная сноска. У классического CGI был свой footgun, httpoxy: переменные окружения, через которые передавались HTTP-заголовки, могли совпасть с переменной HTTP_PROXY и переадресовать исходящий трафик приложения. К FastCGI это не относится, потому что FastCGI передаёт параметры не через окружение, а через свой словарь. Различие конструктивное, не косметическое.
Так что же, переезжать?
Если без пафоса, ответ для большинства сервисов скучный: скорее всего, нет.
Стоимость миграции реальная. Сервис у вас уже работает на HTTP, у вас отлажены конфиги прокси, есть мониторинг, есть привычки команды. Threat model можно держать в рабочем состоянии и без смены wire-протокола: аккуратно зачищать заголовки на прокси, держать актуальный HTTP/2-стек на маршруте прокси-бэкенд, следить за странностями в логах. Если вы не Discord и не личный кабинет крупного банка, шанс, что вы первый словите свежий вариант desync-атаки, невелик.
Но интересный вопрос здесь не «переезжать или нет». Интересный вопрос: почему индустрия двадцать лет лечит request smuggling в стиле «починим парсеры построже», а не в стиле «возьмём протокол, у которого этого класса багов нет в принципе». Статья Watchfire вышла в 2005 году. С тех пор счёт CVE по request smuggling идёт на сотни. Каждый раз новая порция, потому что парсеры опять разошлись в новой точке. А ответ из индустрии всегда один: вместо «может, проблема в самой спецификации» получаем «давайте быстренько подкрутим спецификацию».
Айер в финале пишет, что выбор wire-протокола на стыке прокси-бэкенда — это решение про безопасность, и что мы тридцать лет делаем вид, что это решение про удобство. Счёт по CVE отражает разницу. И тридцатилетие FastCGI это хороший повод запомнить, что счёт ещё пополняется, и что альтернатива доступна — совсем не новая, не экзотическая, и поставить её можно без особых сложностей. «Happy 30th birthday, FastCGI», заканчивает он. Запомним год, в котором этот вопрос задали ещё раз, даже если ответ снова не сдвинет рынок ближайшие десять лет.
Оригинал — мой авторский разбор для англоязычной аудитории. Перевод и доводка — с AI на подхвате.
ссылка на оригинал статьи https://habr.com/ru/articles/1030882/