Дайджесты новостей
Архитектурный разрез сетевого шлюза в изометрии 45°: строгий фильтр отсекает транзитные заголовки перед передачей запроса к микросервису Go.

Ловушка httputil.ReverseProxy в Go: почему Director теряет заголовки

Представь классическую задачу: ты строишь легковесный 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:

  1. Прокси вызывает твою функцию Director(req), и ты добавляешь req.Header.Set("X-App-User", "authorized-user-id").
  2. После этого стандартный код 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 порядок перевернули:

  1. Прокси клонирует входящий запрос r.In в исходящий r.Out.
  2. Все hop-by-hop заголовки и поля из Connection удаляются из r.Out до вызова твоего кода.
  3. Удаляются транзитные поля вроде Forwarded и X-Forwarded-* для защиты от IP-спуфинга.
  4. Вызывается твой колбэк 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 занимает пару строк, но навсегда закрывает проблему тихой потери служебных заголовков.