RE:NODE

Hosting aplikací11 min čtení

ASP.NET Core za reverzní proxy: forwarded headers

Ať ASP.NET Core za proxy vidí skutečnou IP klienta a HTTPS: UseForwardedHeaders, KnownProxies, smyčky přesměrování, HSTS, WebSockets a timeouty.

2 čtenáři

Za reverzní proxy vidí ASP.NET Core každý požadavek jako obyčejné HTTP z jediné adresy - té proxy - dokud mu neřekneš jinak. Řešením je jeden middleware, UseForwardedHeaders, nastavený tak, aby četl X-Forwarded-For a X-Forwarded-Proto, zaregistrovaný před vším ostatním a s přesným určením, které proxy smí věřit. Když to uděláš správně, IP klienta, schéma, přesměrování na HTTPS, HSTS, secure cookies i callbacky OAuth se samy opraví. Když to uděláš špatně směrem k přílišné shovívavosti, každý si může zvolit vlastní IP adresu jedinou hlavičkou.

Tento příspěvek projde middleware volbu po volbě a pak věci, které s ním sousedí: smyčky přesměrování, HSTS, WebSockets a SignalR přes proxy a timeouty a limity na obou stranách.

Co se za proxy pokazí#

Když proxy ukončuje TLS a předává požadavek Kestrelu přes obyčejné HTTP, spojení, které Kestrel vidí, pochází z proxy a původní údaje přežijí jen jako hlavičky, které proxy přidala.

TLS na 443HTTP, přidělený portProhlížeč198.51.100.7, HTTPSReverzní proxypřidá X-Forwarded-*Kestrelobyčejné HTTPUseForwardedHeaderspřepíše kontextTvoje endpointyskutečná IP a schéma
Co vidí Kestrel s forwarded headers a bez nich

Bez middlewaru je tohle všechno nenápadně špatně:

  • HttpContext.Connection.RemoteIpAddress je adresa proxy. Logy ukazují jediného návštěvníka; rate limiter rozdělený podle IP dá všechny uživatele do jednoho koše.
  • HttpContext.Request.Scheme je http a Request.IsHttps je false.
  • UseHttpsRedirection přesměrovává požadavky, které už přišly přes HTTPS, a prohlížeč se točí v kruhu, dokud nehlásí příliš mnoho přesměrování.
  • UseHsts svou hlavičku nikdy neodešle, protože ji přidává jen k odpovědím HTTPS.
  • Cookies s CookieSecurePolicy.SameAsRequest se vydávají bez příznaku Secure.
  • Generované absolutní adresy, včetně redirect_uri posílaného Googlu, Microsoftu nebo GitHubu při externím přihlášení, začínají http:// a poskytovatel je odmítne jako neshodu.

Každý z těch příznaků pochází ze stejné chybějící konfigurace. Obecně o straně proxy pojednává co reverzní proxy ve skutečnosti dělá; tento příspěvek je o tom, co s tím musí udělat ASP.NET Core.

Middleware pro forwarded headers#

Middleware je součástí ASP.NET Core; žádný balíček není potřeba.

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...

U každého požadavku to funguje takto: pokud spojení přichází od důvěryhodné proxy, přečte forwarded hlavičky, nahradí RemoteIpAddress a Scheme hodnotami v nich a originály přesune do X-Original-For a X-Original-Proto, abys je pořád viděl. Pokud spojení od důvěryhodné proxy nepřichází, neudělá vůbec nic.

Záleží na pořadí. Každý middleware zaregistrovaný před UseForwardedHeaders vidí požadavek očima proxy. Doporučení Microsoftu je spouštět ho před vším kromě diagnostiky a middlewaru pro zpracování chyb. Zvlášť logování požadavků musí přijít až po něm, jinak logy dál zaznamenávají proxy.

Volby, na kterých záleží:

VolbaVýchozíCo řídí
ForwardedHeadersNoneKteré hlavičky zpracovat. Dokud ji nenastavíš, nestane se nic
ForwardLimit1Kolik záznamů brát zprava z X-Forwarded-For
KnownProxiesloopbackJednotlivé adresy proxy, kterým se věří
KnownNetworks127.0.0.0/8Rozsahy adres, kterým se věří
AllowedHostsprázdnéHostitelé přijímaní z X-Forwarded-Host
RequireHeaderSymmetryfalseVyžaduje stejný počet hodnot v každé hlavičce

ForwardedHeaders.XForwardedHost v příkladu záměrně chybí. Zapnutí dovolí proxy - nebo komukoli, kdo se k aplikaci dostane přímo - změnit Request.Host, což vstupuje do generovaných odkazů a e-mailů pro obnovu hesla. Zapínej ho jen s AllowedHosts nastaveným na tvé skutečné názvy hostitelů a jen pokud proxy hlavičku Host přepisuje a nepředává ji dál beze změny. Většina proxy původní Host předává beze změny, a v takovém případě ho nepotřebuješ.

KnownProxies: komu věřit#

X-Forwarded-For je hlavička a klient může poslat jakoukoli hlavičku, jakou chce. Proxy připojí adresu, kterou skutečně viděla, za to, co klient poslal, takže u požadavku z 198.51.100.7, který obsahoval padělanou hodnotu, aplikace dostane:

code
X-Forwarded-For: 6.6.6.6, 198.51.100.7

Pravý záznam zapsala tvoje proxy a je pravdivý. Všechno nalevo od něj dodal klient a pravdivé není. S ForwardLimit = 1 middleware bere přesně jeden záznam zprava - ten pravdivý - a proto je výchozí limit u jediné proxy správný. Pokud jsou v řetězci dvě proxy (třeba CDN před tvou proxy), limit je 2 a důvěřovat se musí obě.

Druhá polovina bezpečnosti je KnownProxies. Middleware zpracovává hlavičky jen u spojení z adresy, které věří, a ve výchozím stavu je to jen loopback. Proxy na jakékoli jiné adrese se ignoruje, takže čerstvě nastavená aplikace často vypadá, že nedělá nic: hlavičky dorazí, middleware vidí neznámý zdroj a tiše je přeskočí.

Abys zjistil, z jaké adresy se proxy připojuje, zaloguj ji před spuštěním middlewaru:

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();

Pošli jeden požadavek přes doménu, přečti adresu z logu, přidej ji do KnownProxies (nebo rozsah do KnownNetworks) a logování odstraň. Po restartu zkontroluj, že adresa je stejná; pokud se zdrojová adresa proxy může změnit, věř její síti, ne jedné adrese.

V .NET 10 je KnownNetworks označená jako zastaralá ve prospěch KnownIPNetworks, která bere novější typ System.Net.IPNetwork. Chování je stejné; stará vlastnost dál funguje a jen vyvolá varování.

Zkratka přes proměnnou prostředí a její cena#

ASP.NET Core umí middleware zapnout bez kódu:

env
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true

Tím se přidá middleware se zapnutými XForwardedFor a XForwardedProto - a zároveň se vyprázdní KnownProxies a KnownNetworks, takže přijímá hlavičky z jakéhokoli zdroje. Vznikl pro platformy, kde je aplikace dostupná jen přes proxy, takže tam není nikdo další, kdo by je mohl posílat.

Na serveru, kde je port aplikace dostupný i přímo, je to problém padělané IP z varování výše. Pro rychlý test to stačí a je to přijatelné, když se IP klienta používá jen v lozích. Pro cokoli, co se rozhoduje podle IP klienta - rate limity, povolené seznamy IP, vyhodnocování podvodů, omezování přihlášení - nastav KnownProxies v kódu. Proč je padělatelný klíč horší než žádný rate limit, vysvětlují rate limity a zneužití.

Tady je, co na tom v typické aplikaci závisí. Vestavěný rate limiter dělí podle libovolného klíče, který mu dáš, a obvyklý klíč je adresa klienta:

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)            }));});

Když middleware chybí, je tím klíčem u každého požadavku proxy a stý požadavek za minutu od kohokoli zablokuje všechny. Když middleware věří všem zdrojům, je klíčem to, co napíše útočník, a změnou jediné hlavičky dostane čerstvých sto požadavků. Teprve správně omezená verze dává limit na každého skutečného klienta. UseRateLimiter musí v pipeline také přijít po UseForwardedHeaders, ze stejného důvodu jako logování.

Přesměrování na HTTPS bez smyčky#

UseHttpsRedirection odpovídá na požadavky HTTP přesměrováním 307 na adresu HTTPS. Za proxy, která ukončuje TLS, má dva způsoby selhání.

Smyčka. Když se X-Forwarded-Proto nezpracovává, vypadá každý požadavek jako HTTP, takže se přesměruje každý, včetně těch, které přišly přes HTTPS. Výše uvedená konfigurace forwarded headers to opravuje.

Tiché nic. Middleware musí vědět, na jaký port přesměrovat. Dívá se na HttpsRedirectionOptions.HttpsPort, pak na nastavení https_port (ASPNETCORE_HTTPS_PORT v prostředí) a pak na koncové body HTTPS, na kterých Kestrel naslouchá. Za proxy nemá Kestrel žádný koncový bod HTTPS, takže middleware zaloguje „Failed to determine the https port for redirect“ a nedělá nic. Pokud chceš, aby přesměrování dělala aplikace, nastav port, který má prohlížeč použít:

env
ASPNETCORE_HTTPS_PORT=443

Než to uděláš, ověř, jestli proxy už nepřesměrovává obyčejné HTTP na HTTPS pro tvou doménu. Pokud ano, přesměrování v aplikaci se nikdy nespustí a middleware můžeš vynechat. Dvě vrstvy, které přesměrovávají, nevadí; dvě vrstvy, které se neshodnou na schématu, jsou začátek smyčky. Na RE:NODE drží slot proxy v tarifu pro aplikace certifikát pro tvou doménu - nasměruj záznam A na zobrazenou adresu a certifikát se vystaví a obnovuje automaticky v 21denním okně - a adresa klienta přichází v X-Forwarded-For. Stranu DNS pokrývá tvoje doména a její certifikát.

HSTS#

HSTS říká prohlížeči, aby pro tvého hostitele po určitou dobu používal HTTPS, aniž se ptá. V 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();}

Výchozí MaxAge je 30 dní. Middleware přidává hlavičku jen k odpovědím HTTPS a nikdy ji nepřidá pro localhost, 127.0.0.1 ani [::1], a proto lokální testování nic neukáže.

Dej pozor na dvě nastavení. IncludeSubDomains uplatní politiku na každou subdoménu, včetně té, na které běží zapomenutý starý admin nástroj přes obyčejné HTTP. Preload žádá o zařazení do seznamu, který prohlížeče dodávají s sebou, a to je velmi těžké vzít zpět. Začni s krátkým MaxAge, potvrď, že všechny části webu fungují přes HTTPS, a pak ho prodluž. Pokud proxy už Strict-Transport-Security posílá, neposílej ji podruhé s jinými hodnotami.

WebSockets a SignalR přes proxy#

WebSocket začíná jako požadavek HTTP s hlavičkami Upgrade: websocket a Connection: Upgrade a proxy musí obě předat dál a pak spojení držet otevřené. Většina současných proxy to pro HTTP/1.1 dělá. Na straně aplikace:

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

Výchozí KeepAliveInterval jsou dvě minuty. Proxy, která zavírá nečinná spojení po 60 sekundách, přeruší tichý WebSocket dávno předtím, než aplikace pošle první ping, a klient vidí nevysvětlené odpojení každou minutu. Drž interval kratší, než je nejkratší timeout nečinnosti mezi prohlížečem a Kestrelem.

SignalR to do jisté míry řeší za tebe: server posílá keep-alive ve výchozím stavu každých 15 sekund (KeepAliveInterval) a považuje klienta za odpojeného po 30 sekundách bez zprávy od něj (ClientTimeoutInterval). Pokud WebSockets přes proxy selžou, SignalR přejde na Server-Sent Events nebo long polling, což funguje, ale stojí víc požadavků a větší latenci. Podívej se na záložku sítě v prohlížeči: spojení, které se dohodne a pak přepne na long polling, je obvykle proxy, která upgrade nepředala. Detaily na straně proxy má WebSockets za reverzní proxy a konfiguraci hubů pokrývá aplikace v reálném čase se SignalR.

Timeouty, velikosti těla a keep-alive#

Proxy i Kestrel mají své limity a požadavek se musí vejít do obou.

Nastavení KestreluVýchozíPoznámky
Limits.KeepAliveTimeout130 sekundDoba nečinnosti, po které Kestrel zavře spojení
Limits.RequestHeadersTimeout30 sekundČas povolený na přijetí hlaviček
Limits.MaxRequestBodySize30 000 000 bajtůAsi 28,6 MB; přepiš pro jednotlivý endpoint
Limits.MaxConcurrentConnectionsneomezenoZa proxy je málokdy důvod to nastavovat

Dvouminutový keep-alive Kestrelu je delší než timeout nečinnosti většiny proxy, což je bezpečný směr: proxy zavře nečinná spojení první, takže nikdy neposílá požadavek do spojení, které Kestrel právě zahodí. Tenhle souběh je klasická příčina občasných 502 bez čehokoli v logu aplikace a u Kestrelu je méně častý než u Node právě kvůli této výchozí hodnotě. Pokud jsi KeepAliveTimeout snížil, vrať ho zpět.

U uploadů vyhrává menší ze dvou limitů těla. Limit Kestrelu zvyš pro ten jeden endpoint, který to potřebuje, pomocí [RequestSizeLimit] na akci controlleru nebo přes IHttpMaxRequestBodySizeFeature na začátku požadavku, místo globálně; velký globální limit je snadný způsob, jak nechat jednoho klienta zaplnit tvou paměť. Požadavek pomalejší než čtecí timeout proxy - dlouhý report, velký export - by se měl stát úlohou na pozadí, na kterou se klient dotazuje, ne dvouminutovým požadavkem HTTP. Vzor ukazují worker služby .NET a úlohy na pozadí.

Jak ověřit, že to funguje#

Přidej dočasný diagnostický endpoint, jednou ho zavolej přes doménu a odstraň ho:

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()});

Přes doménu by ip měla být tvoje vlastní veřejná adresa, scheme by měl být https a originalFor by měl ukazovat proxy. Pak zavolej aplikaci přímo na její IP a port s padělanou hlavičkou:

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

Odpověď nesmí obsahovat 1.2.3.4. Pokud ano, věříš zdrojům, kterým bys věřit neměl. Na RE:NODE je port aplikace přidělen na adrese serveru, takže tento přímý test je skutečná cesta, kterou by mohl útočník použít, ne hypotetická.

Poslední kontrola je hlavička odpovědi: pokud jsi zapnul HSTS, měla by se u odpovědí HTTPS přes doménu objevit Strict-Transport-Security. Pokud nic z toho nefunguje a log nic neukazuje, vrať se k logovacímu middlewaru výše a podívej se na adresu protistrany; v devíti případech z deseti není v KnownProxies.

FAQ#

Proč UseForwardedHeaders vypadá, že nedělá nic?

Buď je ForwardedHeaders pořád None, nebo adresa proxy není v KnownProxies ani KnownNetworks. Middleware ignoruje hlavičky z nedůvěryhodných zdrojů a na výchozí úrovni nic nezaloguje, takže zaloguj adresu protistrany a porovnej ji.

Mám nastavit ForwardLimit na null, aby četl celou hlavičku?

Ne. Brát víc záznamů, než máš proxy, znamená věřit hodnotám, které napsal klient. Nastav limit na počet proxy, které provozuješ, což je u jednoho slotu proxy výchozí hodnota 1.

Potřebuju UseHttpsRedirection, když přesměrovává proxy?

Ne, pokud proxy už přesměrovává každý požadavek HTTP pro tvou doménu. Nechat ho tam nevadí, pokud se zpracovává X-Forwarded-Proto, aby se aplikace s proxy shodovala v tom, které požadavky už jsou HTTPS.

Proč externí přihlášení selhávají s neshodou redirect URI?

Aplikace složila adresu callbacku z Request.Scheme, které bylo http, protože se hlavička forwarded proto nezpracovala. Oprav forwarded headers a vygenerovaná adresa bude https://, tedy shodná s tím, co jsi zaregistroval u poskytovatele.

Mění proxy způsob, jak nastavuju porty Kestrelu?

Kestrel naslouchá na obyčejném HTTP na přiděleném portu, na všech rozhraních, bez certifikátu. Nastavení ASPNETCORE_URLS najdeš v nasazení aplikace ASP.NET Core z GitHubu.


Komentáře

Zcela anonymně: žádný účet, žádný e-mail, žádné cookie. Ukládáme jen jméno, které napíšeš, text a čas - nic dalšího. Počet odkazů je omezený a značkování se nevykresluje.

0/2000