Представь классическую задачу: ты строишь легковесный API Gateway на Go. Сервис принимает запрос клиента, валидирует JWT-токен и пересылает запрос дальше во внутренний микросервис. Чтобы бэкенд знал, кто авторизован, прокси прикрепляет служебный заголовок с ID пользователя.
Многие разработчики годами решали эту задачу одинаково — брали проверенный httputil.ReverseProxy из стандартной библиотеки и настраивали в нем поле Director. Но если в твоем продакшене до сих пор работает такой код, в безопасности системы есть серьезный изъян.
Вот как выглядел этот привычный, но уязвимый паттерн:
package main
import (
"net/http"
"net/http/httputil"
"net/url"
)
func NewLegacyProxy(target *url.URL) *httputil.ReverseProxy {
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// Прокидываем ID пользователя на внутренний сервис
req.Header.Set("X-App-User", "authorized-user-id")
}
return proxy
}
Казалось бы, что здесь может сломаться? Мы явно установили заголовок перед отправкой. Но в один момент сервис начинает получать пустые значения, а авторизация загадочно сбоит.
Анатомия бага: коварный заголовок Connection
Чтобы понять причину, заглянем под капот httputil.ReverseProxy и вспомним стандарты HTTP (RFC 7230 и RFC 9110).
В протоколе есть понятие hop-by-hop (поузловых) заголовков. Поля вроде Keep-Alive, Upgrade и Connection имеют смысл только для одного сетевого соединения, и прокси обязан вычищать их перед передачей запроса дальше.
Но самое интересное: стандарт разрешает клиенту перечислять в Connection имена любых других заголовков, требуя считать их транзитными!
А теперь посмотри на фатальный порядок действий в старом ReverseProxy:
- Прокси вызывает твою функцию
Director(req), и ты добавляешьreq.Header.Set("X-App-User", "authorized-user-id"). - После этого стандартный код Go очищает hop-by-hop поля: он читает заголовок
Connection, находит в нем имена полей и вырезает их из исходящего запроса.
Если внешний клиент пришлет входящий запрос с заголовком:
GET /api/profile HTTP/1.1
Host: api.example.com
Connection: X-App-User
то Director честно добавит заголовок пользователя, а внутренний алгоритм Go на следующем шаге бесследно сотрет его! Бэкенд получит анонимный запрос, что открывает путь к обходу аутентификации.
Безопасная схема через Rewrite
Начиная с Go 1.20, поле Director объявлено устаревшим, а на замену пришел интерфейс Rewrite.
В Rewrite порядок перевернули:
- Прокси клонирует входящий запрос
r.Inв исходящийr.Out. - Все hop-by-hop заголовки и поля из
Connectionудаляются изr.Outдо вызова твоего кода. - Удаляются транзитные поля вроде
ForwardedиX-Forwarded-*для защиты от IP-спуфинга. - Вызывается твой колбэк
Rewrite(r).
Теперь добавленный в r.Out.Header заголовок гарантированно доедет до бэкенда:
package main
import (
"net/http/httputil"
"net/url"
)
func NewSecureProxy(target *url.URL) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
r.Out.Header.Set("X-App-User", "authorized-user-id")
r.SetXForwarded()
},
}
}
Метод r.SetURL(target) переписывает r.Out.Host на целевой адрес. Если бэкенд ожидает исходный хост, укажи r.Out.Host = r.In.Host. И никогда не задавай Director и Rewrite одновременно — Go ответит немедленной паникой.
Входящий объект r.In внутри Rewrite предназначен только для чтения. Переход на Rewrite занимает пару строк, но навсегда закрывает проблему тихой потери служебных заголовков.
