IP клиента в Go подменяется: кому доверять в X-Forwarded-For
Ограничение запросов по IP работало до тех пор, пока клиент не начал присылать произвольный X-Forwarded-For. Middleware брал первую строку заголовка и считал её адресом посетителя. На самом деле заголовок оказался обычным пользовательским входом. Чтобы использовать его для аудита или лимита, приложение должно знать, кто именно установил значение.
Начните с непосредственного соседа
RemoteAddr отражает адрес сетевого участника, с которым сервер установил соединение. За reverse proxy это адрес прокси, а не посетителя. X-Forwarded-For переносит сведения о предыдущих участниках, но доверие к ним возникает только из настроенной схемы: доверенный прокси должен очищать входящий заголовок и записывать проверенное значение по согласованному правилу.
Для одного прокси можно принять строгий контракт: приложение принимает ровно один IP в X-Forwarded-For, только когда RemoteAddr входит в выделенную сеть прокси. Сам прокси всегда перезаписывает заголовок адресом клиента. При прямом запросе любой пользовательский X-Forwarded-For игнорируется. Если система имеет несколько прокси, понадобится другой алгоритм разбора цепочки с проверкой доверенных переходов справа налево.
Проверьте доверие раньше заголовка
В примере сеть 10.20.0.0/24 принадлежит единственному прокси. Прямой посетитель пытается подставить другой IP и получает в результате собственный адрес соединения. Запрос от прокси получает переданный клиентский адрес. Заголовок из двух адресов отклоняется, потому что нарушает выбранный контракт одного прокси.
package main
import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
)
var trusted = netip.MustParsePrefix("10.20.0.0/24")
func clientIP(r *http.Request) (netip.Addr, error) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return netip.Addr{}, err
}
peer, err := netip.ParseAddr(host)
if err != nil {
return netip.Addr{}, err
}
peer = peer.Unmap()
if !trusted.Contains(peer) {
return peer, nil
}
values := r.Header.Values("X-Forwarded-For")
if len(values) != 1 || strings.Contains(values[0], ",") {
return netip.Addr{}, errors.New("one proxy address required")
}
ip, err := netip.ParseAddr(strings.TrimSpace(values[0]))
return ip.Unmap(), err
}
func main() {
for _, c := range []struct{ peer, header string }{
{"203.0.113.7:3210", "198.51.100.1"}, {"10.20.0.5:9000", "198.51.100.1"},
{"10.20.0.5:9000", "198.51.100.1, 192.0.2.1"},
} {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = c.peer
r.Header.Set("X-Forwarded-For", c.header)
ip, err := clientIP(r)
if err != nil {
fmt.Println(false)
} else {
fmt.Println(ip)
}
}
}
Программа выводит адрес прямого клиента, затем адрес посетителя за прокси и false для некорректной цепочки. Пример специально не выбирает первый элемент через strings.Split: такое поведение замаскировало бы ошибку настройки прокси. При несовпадении схемы полезнее увидеть диагностируемый отказ, чем незаметно принять подставной идентификатор.
Ограничьте доверенную сеть точно
Не объявляйте все приватные сети доверенными только потому, что прокси работает во внутренней инфраструктуре. Другой контейнер или сервис в той же сети тоже сможет послать заголовок. Выделите адреса реального прокси и ограничьте сетевой доступ к приложению. В контейнерной среде учитывайте адрес после NAT: он может отличаться от адреса самого процесса прокси.
Если приложение доступно и напрямую, и через прокси, тестируйте оба пути. У IPv6 RemoteAddr содержит квадратные скобки и порт, поэтому нельзя удалять порт обычным Split по двоеточию. net.SplitHostPort и netip.ParseAddr разделяют транспортный адрес и IP. Метод Unmap нормализует IPv4-mapped IPv6 перед сравнением с IPv4-сетью.
В реальном middleware сохраните вычисленный адрес как отдельное типизированное значение контекста. Следующие обработчики должны использовать его, а не заново читать заголовок по собственным правилам. В журнале полезно различать peer_ip и client_ip: первое помогает проверить схему доверия, второе используется для анализа запросов. Несогласованные реализации в логгере и ограничителе затрудняют разбор инцидента.
IP не заменяет идентификатор пользователя. За одним адресом могут находиться офис, мобильная сеть или корпоративный шлюз. Применение жёсткой квоты ко всему IP затронет независимых людей. Для авторизованного API основным ключом лимита обычно становится учётная запись или организация, а IP остаётся дополнительным сигналом и способом ограничить анонимные запросы.
Перед выпуском проверьте конфигурацию настоящего прокси: отправьте входящий поддельный заголовок и посмотрите, какое значение доходит до приложения. Unit-тест функции не докажет, что прокси перезаписывает его. Сохраните этот интеграционный сценарий рядом с настройкой инфраструктуры. При добавлении CDN или второго ingress контракт одного прокси нужно пересмотреть до переключения трафика.
Проверьте конфигурацию при изменении инфраструктуры
Список доверенных адресов должен обновляться контролируемо. Если новый прокси ещё не включён, приложение начнёт видеть его как обычного клиента; это заметно по резкому объединению разных посетителей под одним peer_ip. Если доверие расширили слишком сильно, проблема менее заметна: подставные адреса снова начинают проходить. Поэтому при изменении ingress проверьте разрешённый путь и прямой обход. Тестовые адреса в примере принадлежат документальным диапазонам и ничего не говорят о вашей сети. Подставьте точную сеть прокси из схемы развёртывания, затем подтвердите её наблюдаемым RemoteAddr.
Продолжите до взаимодействия сервисов
Доверенный IP — одна часть входного контракта. Дальше предстоит согласовать идентификаторы запросов, ошибки и дедлайны при обращении к соседним приложениям. В платном курсе микросервисов на Go вы будете проектировать HTTP- и gRPC-вызовы, читать логи и трассировки и проверять сценарии отказа между сервисами. Откройте программу курса «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker».
Продолжить на практике
- Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗
От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.
