RE:NODE

Приложения11 мин чтения

ASP.NET Core за обратным прокси: проксированные заголовки

Как заставить ASP.NET Core видеть настоящий IP клиента и HTTPS за прокси: UseForwardedHeaders, KnownProxies, циклы редиректов, HSTS, WebSocket и таймауты.

0 прочтений

За обратным прокси ASP.NET Core видит каждый запрос как обычный HTTP с одного адреса - адреса прокси, - пока вы не скажете ему иного. Исправление - одно middleware, UseForwardedHeaders, настроенное читать X-Forwarded-For и X-Forwarded-Proto, зарегистрированное раньше всего остального и точно знающее, какому прокси можно верить. Сделайте это правильно, и IP клиента, схема, HTTPS-редирект, HSTS, защищённые cookie и OAuth-колбэки исправятся сами. Ошибитесь в сторону вседозволенности, и любой сможет выбрать себе IP-адрес, просто отправив заголовок.

В этой статье middleware разобрано параметр за параметром, а затем всё, что находится рядом с ним: циклы редиректов, HSTS, WebSocket и SignalR через прокси, а также таймауты и лимиты с обеих сторон.

Что ломается за прокси#

Когда прокси завершает TLS и пересылает запрос в Kestrel по обычному HTTP, соединение, которое видит Kestrel, идёт от прокси, а исходные данные сохраняются только в заголовках, добавленных прокси.

TLS на 443HTTP, выделенный портБраузер198.51.100.7, HTTPSОбратный проксидобавляет X-Forwarded-*Kestrelобычный HTTPUseForwardedHeadersпереписывает контекстВаши эндпоинтынастоящие IP и схема
Что видит Kestrel с проксированными заголовками и без них

Без этого middleware всё перечисленное ниже тихо оказывается неверным:

  • HttpContext.Connection.RemoteIpAddress - адрес прокси. Логи показывают одного посетителя; ограничитель частоты, разбитый по IP, складывает всех пользователей в одну корзину.
  • HttpContext.Request.Scheme равен http, а Request.IsHttps ложно.
  • UseHttpsRedirection перенаправляет запросы, которые уже пришли по HTTPS, и браузер ходит по кругу, пока не сообщит о слишком большом числе редиректов.
  • UseHsts никогда не отправляет свой заголовок, потому что добавляет его только к HTTPS-ответам.
  • Cookie с CookieSecurePolicy.SameAsRequest выдаются без флага Secure.
  • Сгенерированные абсолютные URL, включая redirect_uri, отправляемый Google, Microsoft или GitHub при внешнем входе, начинаются с http://, и провайдер отвергает их как несовпадающие.

Каждый из этих пунктов - симптом одной и той же отсутствующей настройки. Сторона прокси в целом описана в статье что на самом деле делает обратный прокси; эта статья о том, что с этим должен сделать ASP.NET Core.

Middleware проксированных заголовков#

Middleware входит в ASP.NET Core; никакой пакет не нужен.

Program.cs
using Microsoft.AspNetCore.HttpOverrides;using System.Net;var builder = WebApplication.CreateBuilder(args);builder.Services.Configure<ForwardedHeadersOptions>(options =>{    options.ForwardedHeaders =        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;    options.KnownProxies.Add(IPAddress.Parse("10.0.0.1")); // your proxy, see below});var app = builder.Build();app.UseForwardedHeaders();      // first, before anything that reads IP or schemeapp.UseHsts();app.UseHttpsRedirection();// authentication, rate limiting, endpoints...

Что оно делает с каждым запросом: если соединение пришло от доверенного прокси, оно читает проксированные заголовки, подменяет RemoteIpAddress и Scheme значениями из них и переносит исходные значения в X-Original-For и X-Original-Proto, чтобы вы всё ещё могли их видеть. Если соединение пришло не от доверенного прокси, оно не делает вообще ничего.

Порядок важен. Любое middleware, зарегистрированное до UseForwardedHeaders, видит запрос глазами прокси. Microsoft рекомендует запускать его раньше всего остального, кроме диагностики и middleware обработки ошибок. В частности, логирование запросов должно идти после него, иначе в ваших логах так и будет записываться прокси.

Параметры, которые имеют значение:

ПараметрПо умолчаниюЧто он определяет
ForwardedHeadersNoneКакие заголовки обрабатывать. Пока не задано, ничего не происходит
ForwardLimit1Сколько записей брать справа из X-Forwarded-For
KnownProxiesloopbackОтдельные адреса прокси, которым можно доверять
KnownNetworks127.0.0.0/8Диапазоны адресов, которым можно доверять
AllowedHostsпустоХосты, принимаемые из X-Forwarded-Host
RequireHeaderSymmetryfalseТребовать одинакового числа значений в каждом заголовке

ForwardedHeaders.XForwardedHost в примере отсутствует намеренно. Если его включить, прокси - или любой, кто достучится до приложения напрямую, - сможет менять Request.Host, а он попадает в сгенерированные ссылки и письма для сброса пароля. Включайте его только вместе с AllowedHosts, где перечислены ваши настоящие имена хостов, и только если прокси переписывает заголовок Host, а не передаёт его как есть. Большинство прокси передают исходный Host без изменений, и тогда он вам не нужен.

KnownProxies: кому верить#

X-Forwarded-For - это заголовок, а клиент может отправить любой заголовок, какой захочет. Прокси дописывает адрес, который он на самом деле увидел, к тому, что прислал клиент, так что для запроса с 198.51.100.7, содержащего подделанное значение, приложение получит:

code
X-Forwarded-For: 6.6.6.6, 198.51.100.7

Самую правую запись написал ваш прокси, и она правдива. Всё, что левее, прислал клиент, и этому верить нельзя. При ForwardLimit = 1 middleware берёт ровно одну запись справа - правдивую, - поэтому лимит по умолчанию верен для одного прокси. Если в цепочке два прокси (например, CDN перед вашим прокси), лимит равен 2, и доверять нужно обоим.

Вторая половина безопасности - KnownProxies. Middleware обрабатывает заголовки только на соединениях с адресов, которым доверяет, а по умолчанию это только loopback. Прокси с любым другим адресом игнорируется, поэтому только что настроенное приложение часто как будто ничего не делает: заголовки приходят, middleware видит неизвестный источник и молча их пропускает.

Чтобы узнать, с какого адреса подключается прокси, запишите его в лог до того, как сработает middleware:

csharp
app.Use(async (context, next) =>{    app.Logger.LogInformation("peer {Peer} xff {Xff}",        context.Connection.RemoteIpAddress,        context.Request.Headers["X-Forwarded-For"].ToString());    await next();});app.UseForwardedHeaders();

Сделайте один запрос через домен, возьмите адрес из лога, добавьте его в KnownProxies (или диапазон в KnownNetworks) и уберите логирование. После перезапуска проверьте, что адрес тот же; если исходный адрес прокси может меняться, доверяйте его сети, а не одному адресу.

В .NET 10 KnownNetworks помечен устаревшим в пользу KnownIPNetworks, который принимает более новый тип System.Net.IPNetwork. Поведение то же; старое свойство по-прежнему работает и только выдаёт предупреждение.

Сокращение через переменную окружения и его цена#

ASP.NET Core умеет включать middleware без кода:

env
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true

Это добавляет middleware с включёнными XForwardedFor и XForwardedProto - а ещё очищает KnownProxies и KnownNetworks, так что заголовки принимаются из любого источника. Это было задумано для платформ, где приложение доступно только через прокси, и больше их отправить просто некому.

На сервере, где порт приложения доступен ещё и напрямую, это та самая проблема поддельного IP из предупреждения выше. Для быстрой проверки это нормально, и приемлемо, когда IP клиента используется только для логов. Для всего, что принимает решения по IP клиента, - ограничений частоты, списков разрешённых IP, оценки мошенничества, ограничения попыток входа - настраивайте KnownProxies в коде. Почему подделываемый ключ хуже, чем отсутствие ограничения частоты вообще, объясняет статья лимиты частоты и злоупотребления.

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

csharp
builder.Services.AddRateLimiter(options =>{    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>        RateLimitPartition.GetFixedWindowLimiter(            ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",            _ => new FixedWindowRateLimiterOptions            {                PermitLimit = 100,                Window = TimeSpan.FromMinutes(1)            }));});

Без middleware этот ключ - прокси для каждого запроса, и сотый за минуту запрос от кого угодно блокирует всех. Если middleware доверяет всем источникам, ключ - то, что напишет атакующий, и он получает свежую сотню запросов, поменяв один заголовок. Только правильно ограниченная настройка даёт вам лимит на каждого настоящего клиента. UseRateLimiter тоже должен идти в конвейере после UseForwardedHeaders - по той же причине, что и логирование.

HTTPS-редирект без цикла редиректов#

UseHttpsRedirection отвечает на HTTP-запросы кодом 307 с HTTPS-адресом. За прокси, завершающим TLS, у него два режима отказа.

Цикл. Если X-Forwarded-Proto не обрабатывается, каждый запрос выглядит как HTTP, поэтому перенаправляется каждый запрос, включая те, что пришли по HTTPS. Настройка проксированных заголовков выше это исправляет.

Тихое бездействие. Middleware должно знать, на какой порт перенаправлять. Оно смотрит на HttpsRedirectionOptions.HttpsPort, затем на настройку https_port (ASPNETCORE_HTTPS_PORT в окружении), затем на HTTPS-эндпоинты, которые слушает Kestrel. За прокси у Kestrel нет HTTPS-эндпоинта, поэтому middleware пишет в лог «Failed to determine the https port for redirect» и ничего не делает. Если хотите, чтобы редирект делало приложение, задайте порт, который должен использовать браузер:

env
ASPNETCORE_HTTPS_PORT=443

Прежде чем это делать, проверьте, не перенаправляет ли прокси уже обычный HTTP на HTTPS для вашего домена. Если да, редирект приложения никогда не срабатывает, и middleware можно не подключать. Два слоя с редиректами безвредны; два слоя, которые расходятся во мнении о схеме, - вот как начинается цикл. В RE:NODE слот прокси тарифа для приложений хранит сертификат вашего домена - направьте запись A на показанный адрес, и сертификат будет выпущен и продлён автоматически в окне в 21 день, - а адрес клиента приходит в X-Forwarded-For. Сторона DNS описана в статье ваш домен и его сертификат.

HSTS#

HSTS говорит браузеру использовать HTTPS для вашего хоста в течение определённого срока, не спрашивая. В ASP.NET Core:

csharp
builder.Services.AddHsts(options =>{    options.MaxAge = TimeSpan.FromDays(180);    options.IncludeSubDomains = false;    options.Preload = false;});if (!app.Environment.IsDevelopment()){    app.UseHsts();}

MaxAge по умолчанию - 30 дней. Middleware добавляет заголовок только к HTTPS-ответам и никогда не добавляет его для localhost, 127.0.0.1 или [::1], поэтому при локальной проверке ничего не видно.

Будьте осторожны с двумя настройками. IncludeSubDomains применяет политику к каждому поддомену, включая тот, на котором по обычному HTTP работает старая админка, о которой вы забыли. Preload просит включить хост в список, поставляемый с браузерами, а это очень трудно отменить. Начните с короткого MaxAge, убедитесь, что все части сайта работают по HTTPS, а затем увеличьте его. Если прокси уже отправляет Strict-Transport-Security, не отправляйте его второй раз с другими значениями.

WebSocket и SignalR через прокси#

WebSocket начинается как HTTP-запрос с заголовками Upgrade: websocket и Connection: Upgrade, и прокси должен пропустить оба, а затем держать соединение открытым. Большинство современных прокси делают это для HTTP/1.1. Со стороны приложения:

csharp
app.UseWebSockets(new WebSocketOptions{    KeepAliveInterval = TimeSpan.FromSeconds(30)});

KeepAliveInterval по умолчанию - две минуты. Прокси, который закрывает простаивающие соединения через 60 секунд, оборвёт тихий WebSocket задолго до того, как приложение отправит первый ping, и клиент будет видеть необъяснимый разрыв каждую минуту. Держите интервал короче самого короткого таймаута простоя на пути от браузера до Kestrel.

SignalR в какой-то степени делает это за вас: по умолчанию сервер отправляет keep-alive каждые 15 секунд (KeepAliveInterval) и считает клиента ушедшим, если ничего не слышал от него 30 секунд (ClientTimeoutInterval). Если WebSocket через прокси не работает, SignalR откатывается на Server-Sent Events или long polling, которые работают, но стоят больше запросов и больше задержки. Посмотрите вкладку сети в браузере: соединение, которое проходит согласование, а затем переключается на long polling, обычно означает прокси, не пропустивший апгрейд. Подробности со стороны прокси - в статье WebSocket за обратным прокси, а настройка хабов - в статье приложения реального времени на SignalR.

Таймауты, размеры тела и keep-alive#

И у прокси, и у Kestrel есть свои лимиты, и запрос должен уложиться в оба.

Настройка KestrelПо умолчаниюПримечания
Limits.KeepAliveTimeout130 секундВремя простоя, после которого Kestrel закрывает соединение
Limits.RequestHeadersTimeout30 секундВремя на получение заголовков
Limits.MaxRequestBodySize30 000 000 байтОколо 28,6 МБ; переопределяется для отдельного эндпоинта
Limits.MaxConcurrentConnectionsбез ограниченийЗа прокси задавать редко имеет смысл

Двухминутный keep-alive у Kestrel длиннее таймаута простоя у большинства прокси, и это безопасный порядок: прокси закрывает простаивающие соединения первым, поэтому никогда не отправляет запрос по соединению, которое Kestrel вот-вот закроет. Эта гонка - классическая причина редких 502 при пустом логе приложения, и с Kestrel она встречается реже, чем с Node, именно из-за этого значения по умолчанию. Если вы уменьшали KeepAliveTimeout, верните его.

Для загрузок побеждает меньший из двух лимитов на тело запроса. Повышайте лимит Kestrel для одного нужного эндпоинта атрибутом [RequestSizeLimit] на действии контроллера или через IHttpMaxRequestBodySizeFeature в начале запроса, а не глобально; большой глобальный лимит - простой способ позволить одному клиенту заполнить вашу память. Запрос, который медленнее таймаута чтения у прокси, - длинный отчёт, большой экспорт, - должен стать фоновой задачей, которую клиент опрашивает, а не двухминутным HTTP-запросом. Этот паттерн показан в статье worker services и фоновые задачи в .NET.

Как проверить, что всё работает#

Добавьте временный диагностический эндпоинт, вызовите его один раз через домен и удалите:

csharp
app.MapGet("/debug/request", (HttpContext ctx) => new{    ip = ctx.Connection.RemoteIpAddress?.ToString(),    scheme = ctx.Request.Scheme,    host = ctx.Request.Host.Value,    originalFor = ctx.Request.Headers["X-Original-For"].ToString()});

Через домен ip должен быть вашим собственным публичным адресом, scheme - https, а originalFor должен показывать прокси. Затем обратитесь к приложению напрямую по IP и порту с поддельным заголовком:

bash
$ curl -H "X-Forwarded-For: 1.2.3.4" http://203.0.113.10:25571/debug/request

В ответе не должно быть 1.2.3.4. Если он там есть, вы доверяете источникам, которым доверять не следует. В RE:NODE порт приложения выделяется на адресе сервера, так что эта прямая проверка - реальный путь, которым может воспользоваться атакующий, а не гипотетический.

Последняя проверка - заголовок ответа: если вы включили HSTS, Strict-Transport-Security должен появляться в HTTPS-ответах через домен. Если ничего из этого не работает, а в логе пусто, вернитесь к логирующему middleware выше и посмотрите на адрес соединения; в девяти случаях из десяти его нет в KnownProxies.

FAQ#

Почему UseForwardedHeaders как будто ничего не делает?

Либо ForwardedHeaders всё ещё None, либо адреса прокси нет в KnownProxies или KnownNetworks. Middleware игнорирует заголовки из недоверенных источников и на уровне логирования по умолчанию ничего об этом не пишет, так что запишите в лог адрес соединения и сравните.

Стоит ли задать ForwardLimit равным null, чтобы читать весь заголовок?

Нет. Брать больше записей, чем у вас прокси, - значит доверять значениям, которые написал клиент. Задайте лимит равным числу ваших прокси, а для одного слота прокси это значение по умолчанию, 1.

Нужен ли UseHttpsRedirection, если прокси сам делает редирект?

Нет, если прокси уже перенаправляет каждый HTTP-запрос к вашему домену. Оставить его безвредно при условии, что X-Forwarded-Proto обрабатывается и приложение согласно с прокси в том, какие запросы уже пришли по HTTPS.

Почему внешний вход падает с ошибкой несовпадения redirect URI?

Приложение собрало URL обратного вызова из Request.Scheme, а там был http, потому что проксированный заголовок со схемой не обрабатывался. Исправьте проксированные заголовки, и сгенерированный URL станет https://, совпадая с тем, что вы зарегистрировали у провайдера.

Меняет ли прокси то, как настраивать порты Kestrel?

Kestrel слушает обычный HTTP на выделенном порту, на всех интерфейсах, без сертификата. Настройка ASPNETCORE_URLS описана в статье деплой приложения ASP.NET Core с GitHub.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000