¿Qué está pasando?
Em uma instância self-hosted em produção com uso intenso de equipes e visibilidade restrita, identificamos um problema de fan-out do ActionCable: eventos de conversas são enviados para usuários que são membros do inbox, mas que não têm permissão para visualizar a conversa pela API HTTP.
O efeito prático é que o frontend recebe eventos como message.created, conversation.updated, assignee.changed ou team.changed para conversas que o usuário não pode abrir. Em seguida, o frontend tenta hidratar a conversa chamando:
GET /api/v1/accounts/:account_id/conversations/:conversation_id
A API responde corretamente com 401 Unauthorized, mas novos eventos continuam chegando via WebSocket e o navegador tenta hidratar novamente. Em escala, isso gera um volume muito alto de requisições 401, aumento de CPU no PostgreSQL, aumento de latência em endpoints de conversas e lentidão generalizada no CRM.
No nosso ambiente, a situação ocorreu com:
- um inbox principal com aproximadamente
248 membros;
12 administradores na conta;
- dezenas de times com
restrict_visibility_to_assignee = true;
- conversas atribuídas a times/assignees específicos;
- vários atendentes com o CRM aberto em múltiplas abas durante o dia.
Durante o incidente, observamos em uma amostra de aproximadamente 14 minutos:
GET /api/v1/accounts/:id/conversations/:id -> 43.438 respostas 401
GET /api/v1/accounts/:id/conversations/meta -> 32.323 chamadas aproximadamente
GET /api/v1/accounts/:id/conversations/unread_counts -> 12.310 chamadas aproximadamente
Um único IP de provedor/NAT do cliente chegou a concentrar mais de 92.000 requests na amostra, mas o IP não era a causa raiz: ele representava vários usuários/abas legítimos do cliente atrás do mesmo provedor.
A causa raiz aparente está no desalinhamento entre:
- a lista de destinatários usada nos broadcasts ActionCable; e
- a regra de visibilidade aplicada pela API HTTP ao carregar a conversa.
Pelo código observado no runtime, vários métodos do listener montam destinatários com todos os membros do inbox:
user_tokens(account, conversation.inbox.members)
Isso ocorre em fluxos como:
message_created
message_updated
first_reply_created
conversation_created
conversation_read
conversation_status_changed
conversation_updated
assignee_changed
team_changed
conversation_contact_changed
- eventos de typing
Por outro lado, o endpoint HTTP de conversa aplica regras mais restritas, similares ao comportamento de Conversations::PermissionFilterService, considerando inbox, time, participação e a flag teams.restrict_visibility_to_assignee.
Assim, o WebSocket informa o cliente sobre uma conversa que o mesmo cliente não consegue carregar pela API.
Pasos para reproducirlo
- Criar ou usar uma instância self-hosted com Mega/Chatwoot
v4.17.0.
- Criar uma conta com um inbox compartilhado por muitos usuários.
- Criar vários times vinculados à conta.
- Ativar
restrict_visibility_to_assignee nos times.
- Manter usuários como membros do inbox, mas não necessariamente como membros de todos os times.
- Criar uma conversa no inbox e atribuí-la a um time restrito e/ou a um assignee específico.
- Logar no frontend com um usuário que é membro do inbox, mas que não deveria visualizar aquela conversa por regra de time/assignee.
- Gerar eventos na conversa restrita, por exemplo nova mensagem, atualização de conversa, alteração de assignee ou alteração de time.
- Observar no navegador/rede ou nos logs de proxy que o usuário recebe evento via WebSocket e tenta buscar:
GET /api/v1/accounts/:account_id/conversations/:conversation_id
- Observar a resposta
401 Unauthorized.
- Repetir o cenário com muitos usuários/abas e conversas ativas. O volume de
401 cresce rapidamente e pode saturar banco/CPU.
Comportamiento esperado
O broadcast via ActionCable deveria respeitar a mesma visibilidade efetiva que a API HTTP usa para conversas.
Para conversas sem restrição de time, manter o comportamento atual de notificar membros do inbox parece adequado.
Para conversas com team.restrict_visibility_to_assignee = true, os eventos deveriam ser enviados apenas para usuários que podem realmente ver a conversa, por exemplo:
- administradores da conta;
- assignee atual, quando existir;
- membros do time, quando a conversa restrita ainda não tem assignee e a regra de produto permitir que membros do time vejam a conversa;
- participantes explícitos da conversa, se aplicável ao modelo de permissões;
- contato da conversa em eventos customer-facing, quando aplicável.
Em eventos de troca de time ou assignee, também é importante considerar os destinatários anteriores, para que clientes que antes viam a conversa possam removê-la/atualizá-la corretamente na UI.
Além disso, o frontend deveria ter proteção adicional: se uma tentativa de hidratação retornar 401 ou 403, deveria existir cache negativo/backoff por conversation_id, evitando novas tentativas imediatas para a mesma conversa.
Versión de Mega
sendingtk/chatwoot:v4.17.0
Também reproduzido em imagem derivada dessa versão antes da correção local.
Entorno
Docker / VM Linux self-hosted.
Nosso runtime atual roda em Oracle Cloud Infrastructure, com Docker Compose, Traefik, PostgreSQL e Redis self-managed na VM core.
Plataforma
Navegador.
Sistema operativo
Servidor: Linux em Oracle Cloud Infrastructure.
Clientes: múltiplos usuários em navegadores desktop. O problema é independente do sistema operacional do cliente; ele aparece pelo fluxo WebSocket + API.
Navegador y versión
Reproduzido/observado em navegador desktop moderno. A causa parece ser de backend/frontend application flow, não de uma versão específica de navegador.
Contexto adicional
Evidência operacional
Antes da mitigação local, uma janela de logs mostrou grande volume de 401 em endpoints de conversa:
GET /api/v1/accounts/:id/conversations/:id 401 -> 43.438 ocorrências
Os IDs de conversas com maior volume de 401 pertenciam ao mesmo inbox principal, mas estavam distribuídos entre times restritos diferentes. Exemplos da nossa análise interna:
conversation display_id 731362 -> team restrito, assignee específico
conversation display_id 731949 -> team restrito, assignee específico
conversation display_id 732470 -> team restrito, assignee específico
Essas conversas não eram visíveis para todos os membros do inbox, mas os eventos chegavam para usuários que não podiam carregá-las pela API.
Mitigação local aplicada
Como mitigação emergencial, criamos uma imagem derivada adicionando um initializer Rails que faz prepend no ActionCableListener e substitui a montagem de destinatários dos eventos de conversa/mensagem por uma função baseada em visibilidade efetiva.
A lógica local aplicada foi:
- administradores continuam recebendo;
- conversa sem time restrito continua indo para membros do inbox;
- conversa com time restrito e assignee vai para assignee + administradores;
- conversa com time restrito sem assignee vai para membros do time + administradores;
- eventos de mudança de assignee/time incluem também os destinatários anteriores;
- tokens do contato continuam incluídos em eventos customer-facing.
Após o rollout local e um teste sintético com 100 abas autenticadas por 180s, observamos:
GET /api/v1/accounts/:id/conversations/:id -> 203 x HTTP 200
GET /api/v1/accounts/:id/conversations/:id -> 0 x HTTP 401
O problema de 401 em massa deixou de ocorrer no nosso ambiente após alinhar o fan-out do ActionCable com a visibilidade da conversa.
Risco em escala
Em instalações pequenas, esse problema pode passar despercebido. Em instalações com muitos usuários no mesmo inbox, muitos times restritos e várias abas abertas, cada evento de conversa pode virar múltiplas tentativas de hidratação não autorizada.
Isso amplifica carga em:
- Rails web;
- PostgreSQL;
- endpoint de conversa individual;
- endpoints de contadores/metadados acionados em cascata pelo frontend.
O comportamento esperado é que usuários que não podem ver uma conversa não recebam eventos que fazem o frontend tentar carregá-la.
Sugestão de correção oficial
Recomendamos corrigir em duas camadas:
-
Backend / ActionCable
- Centralizar uma função de
visible_user_tokens(account, conversation, changed_attributes: nil).
- Reutilizar a mesma semântica do filtro de permissão de conversas.
- Aplicar essa função nos eventos de conversa/mensagem/typing que hoje usam
conversation.inbox.members indiscriminadamente.
- Considerar destinatários anteriores em mudanças de
team_id e assignee_id.
-
Frontend
- Ao receber
401 ou 403 em getConversation, aplicar cache negativo/backoff por conversation_id.
- Evitar repetir hidratação imediata da mesma conversa em eventos subsequentes.
Essa correção deve reduzir drasticamente o volume de requests inválidos em ambientes com times restritos e alta concorrência.
¿Qué está pasando?
Em uma instância self-hosted em produção com uso intenso de equipes e visibilidade restrita, identificamos um problema de fan-out do ActionCable: eventos de conversas são enviados para usuários que são membros do inbox, mas que não têm permissão para visualizar a conversa pela API HTTP.
O efeito prático é que o frontend recebe eventos como
message.created,conversation.updated,assignee.changedouteam.changedpara conversas que o usuário não pode abrir. Em seguida, o frontend tenta hidratar a conversa chamando:A API responde corretamente com
401 Unauthorized, mas novos eventos continuam chegando via WebSocket e o navegador tenta hidratar novamente. Em escala, isso gera um volume muito alto de requisições401, aumento de CPU no PostgreSQL, aumento de latência em endpoints de conversas e lentidão generalizada no CRM.No nosso ambiente, a situação ocorreu com:
248membros;12administradores na conta;restrict_visibility_to_assignee = true;Durante o incidente, observamos em uma amostra de aproximadamente 14 minutos:
Um único IP de provedor/NAT do cliente chegou a concentrar mais de
92.000requests na amostra, mas o IP não era a causa raiz: ele representava vários usuários/abas legítimos do cliente atrás do mesmo provedor.A causa raiz aparente está no desalinhamento entre:
Pelo código observado no runtime, vários métodos do listener montam destinatários com todos os membros do inbox:
Isso ocorre em fluxos como:
message_createdmessage_updatedfirst_reply_createdconversation_createdconversation_readconversation_status_changedconversation_updatedassignee_changedteam_changedconversation_contact_changedPor outro lado, o endpoint HTTP de conversa aplica regras mais restritas, similares ao comportamento de
Conversations::PermissionFilterService, considerando inbox, time, participação e a flagteams.restrict_visibility_to_assignee.Assim, o WebSocket informa o cliente sobre uma conversa que o mesmo cliente não consegue carregar pela API.
Pasos para reproducirlo
v4.17.0.restrict_visibility_to_assigneenos times.401 Unauthorized.401cresce rapidamente e pode saturar banco/CPU.Comportamiento esperado
O broadcast via ActionCable deveria respeitar a mesma visibilidade efetiva que a API HTTP usa para conversas.
Para conversas sem restrição de time, manter o comportamento atual de notificar membros do inbox parece adequado.
Para conversas com
team.restrict_visibility_to_assignee = true, os eventos deveriam ser enviados apenas para usuários que podem realmente ver a conversa, por exemplo:Em eventos de troca de time ou assignee, também é importante considerar os destinatários anteriores, para que clientes que antes viam a conversa possam removê-la/atualizá-la corretamente na UI.
Além disso, o frontend deveria ter proteção adicional: se uma tentativa de hidratação retornar
401ou403, deveria existir cache negativo/backoff porconversation_id, evitando novas tentativas imediatas para a mesma conversa.Versión de Mega
Também reproduzido em imagem derivada dessa versão antes da correção local.
Entorno
Docker / VM Linux self-hosted.
Nosso runtime atual roda em Oracle Cloud Infrastructure, com Docker Compose, Traefik, PostgreSQL e Redis self-managed na VM core.
Plataforma
Navegador.
Sistema operativo
Servidor: Linux em Oracle Cloud Infrastructure.
Clientes: múltiplos usuários em navegadores desktop. O problema é independente do sistema operacional do cliente; ele aparece pelo fluxo WebSocket + API.
Navegador y versión
Reproduzido/observado em navegador desktop moderno. A causa parece ser de backend/frontend application flow, não de uma versão específica de navegador.
Contexto adicional
Evidência operacional
Antes da mitigação local, uma janela de logs mostrou grande volume de
401em endpoints de conversa:Os IDs de conversas com maior volume de
401pertenciam ao mesmo inbox principal, mas estavam distribuídos entre times restritos diferentes. Exemplos da nossa análise interna:Essas conversas não eram visíveis para todos os membros do inbox, mas os eventos chegavam para usuários que não podiam carregá-las pela API.
Mitigação local aplicada
Como mitigação emergencial, criamos uma imagem derivada adicionando um initializer Rails que faz
prependnoActionCableListenere substitui a montagem de destinatários dos eventos de conversa/mensagem por uma função baseada em visibilidade efetiva.A lógica local aplicada foi:
Após o rollout local e um teste sintético com
100abas autenticadas por180s, observamos:O problema de
401em massa deixou de ocorrer no nosso ambiente após alinhar o fan-out do ActionCable com a visibilidade da conversa.Risco em escala
Em instalações pequenas, esse problema pode passar despercebido. Em instalações com muitos usuários no mesmo inbox, muitos times restritos e várias abas abertas, cada evento de conversa pode virar múltiplas tentativas de hidratação não autorizada.
Isso amplifica carga em:
O comportamento esperado é que usuários que não podem ver uma conversa não recebam eventos que fazem o frontend tentar carregá-la.
Sugestão de correção oficial
Recomendamos corrigir em duas camadas:
Backend / ActionCable
visible_user_tokens(account, conversation, changed_attributes: nil).conversation.inbox.membersindiscriminadamente.team_ideassignee_id.Frontend
401ou403emgetConversation, aplicar cache negativo/backoff porconversation_id.Essa correção deve reduzir drasticamente o volume de requests inválidos em ambientes com times restritos e alta concorrência.