Programação Por Equipe DevBras

Capa do artigo: ASP.NET atrás de proxy no Docker: o cadeado fica na borda e a app precisa saber

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 o redirect_uri do OpenID Connect, e o provedor de identidade recusa.
  • Deixa de marcar cookies como Secure quando 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-For també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

  1. Dê ao proxy um endereço previsível na rede interna do Docker.
  2. Faça o proxy enviar os cabeçalhos X-Forwarded-Proto e X-Forwarded-For, e repassar o Host original.
  3. Configure o ASP.NET para confiar só nesse proxy e chame UseForwardedHeaders antes de qualquer middleware que use esquema, host ou IP.
  4. Deixe redirecionamento e HSTS no proxy. Na aplicação, retire o UseHttpsRedirection ou garanta que ele nunca dispare, para não haver dois donos do mesmo redirect.
  5. 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.
  6. Teste no endereço público, não no localhost do 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 UseForwardedHeaders tarde 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: https ou um IP falso.
  • Testar no localhost do 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-For e o Host original.
  • 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 com secure e o redirect_uri começa com https://.

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?

Avalie este artigo

Referência: https://github.com/dotnet/dotnet-docker/blob/main/samples/host-aspnetcore-https.md