Start Debugging

Correção: o WebSocket do hot reload do Blazor no dotnet watch falha em um domínio local personalizado (403)

Desde os SDKs .NET de setembro de 2026 (10.0.112, 10.0.401, 11 RC1), o dotnet watch rejeita WebSockets de browser-refresh vindos de origens desconhecidas. Defina DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS com o nome do seu host.

Defina DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS com o nome do seu host personalizado (apenas o host, como myapp.localhost, sem esquema e sem porta) no shell que inicia o dotnet watch e reinicie-o. Os SDKs de 8 de setembro de 2026 (10.0.112, 10.0.401, 9.0.121, 9.0.318, 8.0.131, 8.0.425 e 11.0.100-rc.1) corrigiram o CVE-2026-58649. Desde então, o WebSocket de browser-refresh só aceita um Origin igual a localhost, 127.0.0.1, [::1] ou a um host que você liste nessa variável. Qualquer outro recebe um 403. Medi tudo abaixo no macOS com o SDK 10.0.302 (antes da correção) e o 10.0.401 (depois), usando o template padrão dotnet new blazor.

O erro em contexto

Você abre o app em um nome como http://myapp.localhost:5080, https://shop.test ou um alias no arquivo hosts, em vez de localhost puro. A página renderiza e o circuito do próprio Blazor conecta, mas o console do navegador mostra isto:

Failed to load resource: the server responded with a status of 403 (Forbidden)
WebSocket connection to 'ws://localhost:5599/' failed:
WebSocket failed to connect.
WebSocket connection to 'wss://localhost:63038/' failed:
WebSocket failed to connect.
Unable to establish a connection to the browser refresh server.

As três últimas linhas são saída de console.debug do aspnetcore-browser-refresh.js, então você só as vê com o nível “Verbose” ativado nas DevTools do Chrome ou do Edge. Os números de porta são aleatórios, a menos que você os fixe. Enquanto isso, o terminal do dotnet watch parece completamente saudável:

dotnet watch ⌚ Files updated: ./Components/Pages/Home.razor
dotnet watch 🔥 C# and Razor changes applied in 109ms.

O terminal diz que a alteração foi aplicada e o navegador discorda. Essa incoerência é o que torna o problema tão confuso. Muitas pessoas que esbarram nele também não mudaram nada no projeto: o SDK foi atualizado por baixo dos panos pelo Visual Studio, pelo Homebrew ou por um global.json com rollForward: latestPatch. O relato em dotnet/sdk#56291 diz exatamente isso: o hot reload quebrou em bug.dev.localhost com o SDK 10.0.401, e fixar o 10.0.400 fez voltar a funcionar.

Por que o socket de browser-refresh agora retorna 403

Sob o dotnet watch, os apps web recebem um pequeno script injetado, _framework/aspnetcore-browser-refresh.js. Esse script abre um WebSocket de volta para um servidor hospedado dentro do processo do dotnet watch. O servidor escuta em 127.0.0.1 em uma porta aleatória, mais uma porta WSS quando o certificado de desenvolvimento está disponível. O socket transporta recarregamentos de página, atualizações de CSS, deltas do Blazor WebAssembly e diagnósticos. A URL injetada sempre aponta para localhost, seja qual for o nome de host a partir do qual a própria página foi carregada:

// injected by dotnet watch, SDK 10.0.401
const webSocketUrls = 'ws://localhost:5599,wss://localhost:63038'.split(',');

Assim, uma página em http://myapp.localhost:5080 faz uma requisição WebSocket cross-origin para ws://localhost:5599, e o navegador envia Origin: http://myapp.localhost:5080 junto. Antes de setembro de 2026, o servidor de refresh ignorava o cabeçalho Origin por completo. Qualquer página aberta no seu navegador, em qualquer site, podia se conectar a ele, e o socket transporta payloads de atualização de IL e PDB. Esse é o CVE-2026-58649, classificado como CWE-346 (Origin Validation Error), CVSS 6.5.

A correção (dotnet/sdk#56198 em release/11.0.1xx, portada para main como #56246) adiciona esta verificação antes de o WebSocket ser aceito:

// src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs (SDK fix for CVE-2026-58649)
if (!Uri.TryCreate(context.Request.Headers.Origin.FirstOrDefault(), UriKind.Absolute, out var originUri) ||
    !webSocketConfig.GetAllowedOriginDomains().Contains(originUri.Host, StringComparer.OrdinalIgnoreCase))
{
    context.Response.StatusCode = StatusCodes.Status403Forbidden;
    return;
}

GetAllowedOriginDomains() retorna localhost, 127.0.0.1, [::1], cada entrada de DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS e o valor de DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME, se estiver definido. A comparação é uma correspondência exata, sem diferenciar maiúsculas de minúsculas, contra Uri.Host. Não há curingas, nem correspondência por sufixo, nem tratamento especial para *.localhost. Uma requisição sem nenhum cabeçalho Origin também é rejeitada.

Repro mínimo

# .NET SDK 10.0.401, macOS 26 (any OS behaves the same)
dotnet new blazor -o BlazorRepro
cd BlazorRepro
DOTNET_WATCH_AUTO_RELOAD_WS_PORT=5599 dotnet watch run --urls http://localhost:5080

Fixar a porta com DOTNET_WATCH_AUTO_RELOAD_WS_PORT só facilita sondar o socket. Navegadores Chromium resolvem qualquer nome *.localhost para o loopback sem uma entrada no arquivo hosts, então acesse http://myapp.localhost:5080/ e você obtém a saída de console acima. Você nem precisa de um navegador. Um handshake WebSocket cru com curl mostra a decisão diretamente:

# .NET SDK 10.0.401, while dotnet watch is running
curl -s -o /dev/null -w '%{http_code}\n' --http1.1 \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  -H 'Origin: http://myapp.test:5000' \
  http://127.0.0.1:5599/

Executei esse handshake nos dois SDKs, com valores diferentes da nova variável:

Cabeçalho Origin10.0.30210.0.40110.0.401 + ORIGINS=myapp.test;bug.dev.localhost
http://localhost:5000101101101
http://myapp.test:5000101403101
https://myapp.test101403101
http://bug.dev.localhost:5000101403101
https://evil.example101403403
(sem Origin)101403403

No 10.0.302, o 101 (Switching Protocols) para https://evil.example é a própria vulnerabilidade. No 10.0.401, todo nome personalizado é recusado até você listá-lo.

A correção: permita o nome do seu host

O dotnet watch lê a variável do seu próprio ambiente de processo quando inicia. Defina-a no shell, no task runner ou no contêiner que inicia o dotnet watch e reinicie o watcher. Um watcher em execução não a captura.

# bash / zsh, .NET SDK 10.0.401+
export DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS="myapp.localhost"
dotnet watch run --urls http://localhost:5080
# PowerShell, .NET SDK 10.0.401+
$env:DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS = "myapp.localhost"
dotnet watch run

Para mais de um nome, separe-os com ; ou ,. Espaços em branco ao redor de cada entrada são removidos:

# .NET SDK 10.0.401+
export DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS="shop.test;admin.shop.test,api.shop.test"

Se você inicia o watcher pelo VS Code, coloque a variável na task, não no launch.json. O env de uma configuração de launch coreclr vai para o app, e o app não é o processo que faz a verificação:

// .vscode/tasks.json, .NET SDK 10.0.401+
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "watch",
      "type": "process",
      "command": "dotnet",
      "args": ["watch", "run", "--project", "BlazorRepro/BlazorRepro.csproj"],
      "options": { "env": { "DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS": "myapp.localhost" } },
      "isBackground": true
    }
  ]
}

Com DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS=myapp.localhost definido, a mesma página em http://myapp.localhost:5080 conectou, e as edições chegaram ao navegador sem um refresh manual. Uma alteração de texto no Home.razor de SSR estático e uma mudança de cor em wwwroot/app.css apareceram na aba aberta.

O que realmente quebra, e por que algumas edições ainda parecem funcionar

O sintoma depende de onde o componente renderiza, o que faz o bug parecer intermitente. No 10.0.401 sem a variável, com a página aberta em myapp.localhost:

Portanto, “o hot reload funciona na página do contador mas não na página inicial” é esse mesmo bug, não dois diferentes. Se você não tem certeza em qual categoria um componente se encaixa, veja como o Blazor decide qual modo de renderização executa um componente.

Pegadinhas e casos parecidos

O valor é um nome de host, não uma origem. A verificação compara Uri.Host, então http://myapp.test e myapp.test:5000 nunca correspondem a nada. Nas minhas execuções, DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS="myapp.test:5000,*.localhost" e "http://myapp.test" continuaram retornando 403 para toda origem personalizada. Liste cada subdomínio explicitamente.

O launchSettings.json não funciona. O environmentVariables de um perfil é passado ao processo do app. O dotnet watch já construiu sua lista de permissões até lá. Adicionei a variável nos dois perfis do launchSettings.json do template e ainda recebi 403 para myapp.test. A mesma correção também mudou a forma como as variáveis do perfil de launch chegam ao app: agora elas vão por RPC para o agente de hot reload, em vez de argumentos -e (CVE-2026-69806, mesmo PR). Nada disso afeta as configurações do próprio watcher, porém. Se você quer isso atrelado ao repositório, um script no estilo .env ou a definição de task acima é o lugar certo.

DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME é outro botão. O host dela também é adicionado à lista de permissões (com HOSTNAME=myapp.test, as origens myapp.test retornaram 101 no meu teste), mas ela faz mais do que isso. Ela muda o host ao qual o servidor de refresh se vincula e a URL à qual o script injetado se conecta. O Kestrel trata um nome de host que não é IP como “escutar em todas as interfaces”, então o socket passa a ser acessível a partir da sua rede. Use HOSTNAME apenas quando o navegador realmente não consegue alcançar localhost, por exemplo quando o dotnet watch roda dentro de um contêiner ou em uma máquina de desenvolvimento remota. Quando apenas o nome da página difere, use ORIGINS.

Fixar o SDK antigo “corrige” trazendo a vulnerabilidade de volta. Um global.json fixado em 10.0.400 ou 10.0.302 faz o 403 sumir, porque esses SDKs aceitam qualquer origem, inclusive https://evil.example. Trate isso como um passo de diagnóstico, não como uma correção.

A tabela do advisory e as notas de versão discordam nos números de versão. O advisory lista 10.0.111 e 10.0.400 como “corrigidos”. Os metadados de release colocam o CVE-2026-58649 na release de 8 de setembro (runtime 10.0.12, SDKs 10.0.112 e 10.0.401). A medição e o autor do relato concordam com os metadados de release: o 10.0.400 aceita qualquer origem, e o 10.0.401 aplica a verificação. As tags públicas v10.0.400 e v10.0.401 apontam para o mesmo commit porque as correções de segurança são compiladas a partir do repositório interno, então não perca tempo comparando as tags.

Um 403 na própria página é outra coisa. Se o documento inteiro retorna 403, verifique o que de fato está escutando naquela porta. No macOS, a porta 5000 pertence ao AirPlay Receiver na Central de Controle, que responde 403 para qualquer caminho. Navegadores podem resolver *.localhost para ::1 antes de 127.0.0.1, então um Kestrel vinculado apenas a 127.0.0.1:5000 perde essa conexão para o AirPlay. Esbarrei exatamente nisso ao montar o repro, e é por isso que os comandos acima usam --urls http://localhost:5080.

Nenhum domínio personalizado envolvido e mesmo assim sem refresh? Se você acessa localhost e o socket ainda falha, a causa está em outro lugar: uma página HTTPS tentando wss:// sem um certificado de desenvolvimento confiável, DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH=1 esquecido no ambiente, ou um middleware que reescreve a resposta de modo que o script nunca seja injetado. O que o dotnet watch adiciona ao dotnet run explica a injeção e as variáveis de ambiente que ele define.

Isso deixa de existir no futuro. No branch main do SDK, o dotnet/sdk#56118 (merge em 2026-09-21) roteia o WebSocket das browser-tools pela própria origem da aplicação e o encaminha para um provedor somente de loopback. Nesse desenho, a página e o socket compartilham uma origem, e um comentário no código diz que DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME “no longer applies to this hop”. Essa mudança não foi incluída em nenhum SDK até o momento em que escrevo, então no 10.0.401 e no 11 RC1 você ainda precisa da variável.

Leituras relacionadas

Esta é a segunda vez que uma atualização do SDK quebra silenciosamente um app Blazor sem uma única mudança no projeto. A primeira foi o 404 do blazor.server.js após instalar o SDK do .NET 10. Para saber o que o dotnet watch ganhou recentemente além do socket do navegador, veja dotnet watch no .NET 11 Preview 3 com hosts Aspire e recuperação de falhas. Se você usa o Visual Studio em vez da CLI, o auto-restart do Hot Reload no Visual Studio 2026 cobre como a IDE lida com edições que não consegue aplicar. Não testei a conexão de navegador do próprio Visual Studio para este post.

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar