ASP.NET atrás de proxy no Docker: o cadeado fica na borda e a app precisa saber
Termine o TLS no proxy, configure os forwarded headers do ASP.NET para confiar só nele e acabe com loop de redirect, cookie sem Secure e redirect_uri em http.
A loja em ASP.NET subiu em container atrás de um Nginx com certificado válido. O cadeado aparecia no navegador, mas o login com o provedor de identidade falhava com "redirect_uri inválido". No log do provedor, a URL de retorno pedida era http://loja.exemplo.com.br/signin-oidc, sem o S. O site estava em HTTPS. A aplicação, dentro do container, achava que não.
A resposta curta: escolha um lugar para terminar o TLS, que na maioria das instalações é o proxy na borda. A aplicação escuta HTTP na rede interna e usa o middleware de forwarded headers para ler o protocolo original no cabeçalho X-Forwarded-Proto. O detalhe que derruba quase todo mundo: por padrão o ASP.NET só confia nesses cabeçalhos quando vêm do próprio localhost, e no Docker o proxy é outro container. Você precisa dizer qual proxy é confiável.
Os dois desenhos e por que misturar dá errado
TLS na borda. O proxy (Nginx, Traefik, Caddy, um balanceador da nuvem) tem o certificado, faz o redirecionamento de HTTP para HTTPS, envia o HSTS e repassa a requisição em HTTP para a aplicação na rede interna. É o desenho mais comum, porque a renovação do certificado acontece num lugar só, para todas as aplicações.
TLS na aplicação. O Kestrel apresenta o certificado direto ao visitante. Faz sentido quando não há proxy, ou quando a rede interna também precisa ser cifrada. O certificado precisa de dono e de renovação, e o container precisa recebê-lo por volume. O passo a passo, com certificado em PEM ou PFX e as variáveis do Kestrel, está em HTTPS no Kestrel dentro do Docker.
O problema nasce quando a equipe faz metade de cada. O proxy termina o TLS, mas a aplicação mantém UseHttpsRedirection e conhece uma porta HTTPS. Ela recebe a requisição em HTTP, manda o navegador para HTTPS, o proxy recebe em HTTPS, repassa em HTTP de novo, e o ciclo fecha: "esta página redirecionou você muitas vezes".
O que acontece quando a app não sabe que está em HTTPS
Mesmo sem loop, a aplicação que acredita estar em HTTP erra em vários lugares ao mesmo tempo:
- Monta URLs absolutas com
http://, como oredirect_urido OpenID Connect, e o provedor de identidade recusa. - Deixa de marcar cookies como
Securequando a política é "igual à requisição", e a sessão passa a ser enviada também em HTTP. - Gera links de redefinição de senha e de e-mail com o esquema errado.
- Registra nos logs o IP do proxy, não o do visitante, porque
X-Forwarded-Fortambém é ignorado.
O middleware de forwarded headers corrige isso: ele lê X-Forwarded-Proto e X-Forwarded-For e reescreve Request.Scheme e o IP remoto. Mas ele só faz isso se a requisição veio de um proxy da lista de confiança. Sem essa lista, qualquer um poderia mandar X-Forwarded-Proto: https e se passar por conexão segura. A lista padrão contém só o loopback, e é por isso que, no container, os cabeçalhos são ignorados em silêncio.
Como configurar o TLS na borda, passo a passo
- Dê ao proxy um endereço previsível na rede interna do Docker.
- Faça o proxy enviar os cabeçalhos
X-Forwarded-ProtoeX-Forwarded-For, e repassar oHostoriginal. - Configure o ASP.NET para confiar só nesse proxy e chame
UseForwardedHeadersantes de qualquer middleware que use esquema, host ou IP. - Deixe redirecionamento e HSTS no proxy. Na aplicação, retire o
UseHttpsRedirectionou garanta que ele nunca dispare, para não haver dois donos do mesmo redirect. - Não publique a porta da aplicação no host. Só o proxy fala com ela, o que impede alguém de contornar o proxy e forjar cabeçalhos.
- Teste no endereço público, não no
localhostdo container.
docker-compose.yaml
services:
proxy:
image: nginx:1.27
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
networks:
interna:
ipv4_address: 172.20.0.10
app:
image: registry.exemplo.com.br/loja:1.8.2
expose:
- "8080"
networks:
- interna
networks:
interna:
ipam:
config:
- subnet: 172.20.0.0/24
A partir do .NET 8, as imagens oficiais do ASP.NET escutam na porta 8080 por padrão. O expose documenta a porta sem publicá-la no host.
nginx.conf
server {
listen 80;
server_name loja.exemplo.com.br;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name loja.exemplo.com.br;
ssl_certificate /etc/letsencrypt/live/loja.exemplo.com.br/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/loja.exemplo.com.br/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
O redirecionamento permanente para HTTPS fica aqui, no bloco da porta 80. Como escolher entre 301 e 302 está em redirecionar URL do jeito certo.
Program.cs
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("172.20.0.10"));
});
var app = builder.Build();
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
// diagnóstico temporário: remova depois de conferir
app.MapGet("/diag/esquema", (HttpContext ctx) => new
{
esquema = ctx.Request.Scheme,
host = ctx.Request.Host.Value,
ip = ctx.Connection.RemoteIpAddress?.ToString()
});
app.Run();
Existe também a variável de ambiente ASPNETCORE_FORWARDEDHEADERS_ENABLED=true, que liga o middleware sem código. Ela confia em qualquer origem, então só é aceitável se a aplicação for inalcançável por qualquer caminho que não o proxy. A lista explícita é mais segura e documenta a intenção.
Como provar que ficou certo
Acesse https://loja.exemplo.com.br/diag/esquema. A resposta precisa mostrar "esquema":"https" e o seu IP público, não 172.20.0.10. Depois entre na loja pelo endereço público e confira o cookie de sessão. Pelo terminal, você vê os cabeçalhos de qualquer página que grave cookie:
curl -sI https://loja.exemplo.com.br/conta/entrar | grep -i set-cookie
No navegador, abra o DevTools, vá à aba Application (ou Armazenamento, no Firefox) e olhe a coluna Secure do cookie de autenticação. Se ele não estiver marcado como seguro e a política do cookie for "igual à requisição", a aplicação ainda acha que está em HTTP. Por fim, inicie o login e confira na barra de endereço que o redirect_uri enviado ao provedor começa com https://. Os atributos de cookie estão detalhados em cookie HttpOnly. Remova o endpoint de diagnóstico ao terminar.
Erros comuns
- Esquecer a lista de proxies confiáveis. O middleware está no código, mas ignora os cabeçalhos porque o proxy não é loopback, e nada avisa.
- Chamar
UseForwardedHeaderstarde demais. Autenticação e redirecionamento já leram o esquema errado antes da correção. - Redirect para HTTPS na app e no proxy. Basta a app conhecer uma porta HTTPS para o navegador entrar em loop.
- Confiar em todo mundo e publicar a porta 8080. Qualquer um fala direto com a app e manda
X-Forwarded-Proto: httpsou um IP falso. - Testar no
localhostdo container. Ali não há proxy nem cabeçalhos, e o teste não diz nada sobre o endereço público. - Configurar certificado nos dois lugares. Duas renovações para manter, e a que vence primeiro derruba o site.
Checklist
- Está escrito quem termina o TLS: o proxy ou a aplicação, não os dois.
- O proxy envia
X-Forwarded-Proto,X-Forwarded-Fore oHostoriginal. - O ASP.NET tem o IP do proxy em
KnownProxies. UseForwardedHeadersé o primeiro middleware do pipeline.- Redirect para HTTPS e HSTS ficam só no proxy.
- A porta da aplicação não está publicada no host.
- No endereço público, o esquema é
https, o cookie sai comsecuree oredirect_uricomeça comhttps://.
Pergunte agora ao seu sistema, pelo endereço público: quando a requisição chega ao ASP.NET, Request.Scheme diz https ou http, e quem apresenta o certificado que o visitante vê, o proxy ou a aplicação?
Referência: https://github.com/dotnet/dotnet-docker/blob/main/samples/host-aspnetcore-https.md