Segurança

Este documento explica como o Beyou se defende: como usuários provam quem são, como cada requisição é validada e limitada, como ações destrutivas exigem um segundo fator e quais guardas se recusam a sequer subir o servidor quando a produção está mal configurada. Termina com uma avaliação honesta do que ainda falta.

Uma nota de enquadramento antes: o backend nunca termina TLS. O HTTPS, e portanto a segurança de cada cookie e header abaixo, é trabalho do Cloudflare Tunnel e do ingress do cluster na frente do backend, que não tem porta pública própria. O tópico de infraestrutura cobre essa camada.

Segurança em uma olhada

flowchart LR
  subgraph client["Cliente"]
    FE["⚛️ Web / 📱 Mobile<br/>JWT em memória"]
  end

  subgraph filters["Pipeline de requisição"]
    RL["🚦 RateLimitFilter<br/>faixas bucket4j"]
    SF["🛡️ SecurityFilter<br/>validação do JWT"]
    DS["🔏 DocsImportSecretFilter"]
  end

  subgraph server["Lado do servidor"]
    LA["🔒 Lockout de login<br/>por conta"]
    TS["🔑 TokenService<br/>HMAC256, 15 min"]
    RT["🔄 Refresh tokens<br/>hash SHA-256, rotacionados"]
    OWN["👤 Checagens de posse<br/>em cada service"]
  end

  FE -->|"Authorization: Bearer"| SF
  FE -->|"cookie / corpo de refresh"| RT
  SF --> RL --> OWN
  SF --> TS
  FE <-->|"OAuth 2.0"| GO["🔐 Google"]
  LA -.-|"protege"| TS
  DS -.-|"protege /docs/admin"| OWN

O design em cinco linhas:

Endpoints de autenticação

Endpoint Método Auth Propósito
/auth/login POST Não Login com e-mail + senha
/auth/register POST Não Registro (verificação de e-mail exigida antes do login)
/auth/verify-email GET Não Consome o token de verificação de 24 horas
/auth/resend-verification POST Não Emite um novo token de verificação e o envia por e-mail; sempre o mesmo 200
/auth/google GET Não Troca de código do Google OAuth (web)
/auth/google/mobile POST Não Verificação de ID token do Google (mobile)
/auth/oidc/providers GET Não Os provedores federados configurados; lista vazia quando não há nenhum
/auth/oidc/{provider} POST Não Login federado por ID token (web)
/auth/oidc/{provider}/mobile POST Não Login federado por ID token (mobile)
/auth/oidc/{provider}/link POST Sim Liga uma identidade federada à conta que faz o pedido

Como o login funciona

E-mail + senha

sequenceDiagram
  participant U as Usuário
  participant BE as Backend
  participant DB as Banco

  U->>BE: POST /auth/login
  BE->>BE: Checa lockout (10 falhas / 15 min por e-mail)
  BE->>DB: Busca usuário pelo e-mail
  BE->>BE: BCrypt.matches(entrada, hash)
  BE->>BE: emailVerified? senão 403 EMAIL_NOT_VERIFIED
  BE->>DB: Cria refresh token (hash guardado)
  BE-->>U: JWT em X-Access-Token + cookie de refresh

A ordem das checagens é a parte interessante:

  1. O lockout por conta roda antes de tudo. Dez falhas na janela travam o e-mail por 15 minutos, e o contador registra e-mails desconhecidos também, então o próprio lockout não serve para descobrir quais contas existem.
  2. Conta travada e senha errada devolvem o mesmo corpo 401. Sem oráculo.
  3. Contas Google nunca passam nessa checagem: a senha guardada é um marcador literal, não um hash BCrypt, então o matches sempre falha.
  4. Uma conta não verificada recebe um 403 EMAIL_NOT_VERIFIED distinto. Esse caminho troca deliberadamente um pouco de resistência a enumeração por uma mensagem usável de "confira sua caixa de entrada", e a tela de login agora pendura um botão de reenvio nela, então a troca compra uma saída em vez de só uma explicação.
  5. O sucesso zera o contador de falhas.

Registro e verificação de e-mail

O registro guarda o usuário com um token de verificação de 32 bytes (expira em 24 horas) e envia o e-mail de confirmação. O token é de uso único: consumi-lo marca emailVerified e anula as duas colunas. A política de senha é imposta na camada de service, com os DTOs de reforço: pelo menos 12 caracteres e pelo menos 2 das 4 classes de caracteres.

Um tradeoff honesto, declarado no código: o registro responde "Email already in use" para um endereço ocupado, então é um oráculo de enumeração por escolha. O balde de 5 por 15 minutos por IP é o que impede isso de ser explorado em escala.

Google OAuth

Dois caminhos separados, um por plataforma:

Os dois caminhos fazem find-or-create pelo e-mail. Contas criadas via Google ganham isGoogleAccount=true, um marcador de senha que não é hash e pulam a verificação de e-mail (o Google já a fez).

Os dois caminhos também recusam uma conta encontrada que seja de senha e com endereço não verificado, devolvendo o mesmo 403 EMAIL_NOT_VERIFIED do login. Até essa guarda existir, o doLogin era o único lugar do backend que lia emailVerified, então o Google era um desvio do portão — de leve, como cura acidental de um e-mail de verificação perdido, e a sério assim: qualquer um cadastra um endereço que não é seu, e a linha não verificada que sobra engoliria o login Google do dono de verdade, sem clique e sem aviso. O dono enche aquela linha com seus dados, e se um dia seguir o link de verificação que chegou quando o invasor se cadastrou, a flag vira e a senha do invasor abre a conta. Agora uma regra só vale em todas as portas, e ela é recuperável em vez de apenas rígida porque o endpoint de reenvio chegou junto. Uma conta de senha já verificada continua podendo vincular o Google à vontade.

Identidade federada, além do Google

O Google era o único provedor externo, e os dois caminhos dele encontram a conta por e-mail. Isso só é seguro enquanto o único emissor de facto prova a posse do endereço que afirma. Acrescente um segundo provedor com a mesma regra e toda conta do beyou passa a ser alcançável por quem operar aquele emissor: emitir um token afirmando o endereço e entrar na conta, inclusive contas de pessoas que nunca ouviram falar daquele provedor.

Por isso a identidade passou para o par que só o emissor controla e não pode reatribuir, (issuer, subject), guardado em federated_identities (V29) sob uma restrição UNIQUE. O endereço afirmado sobrevive como email_at_link, um registro do que foi dito, para suporte e auditoria. Nada se autentica nele, e não existe de propósito nenhum método de repositório que encontre um usuário por ele.

FederatedIdentityService.resolve é o único ponto de decisão, e todo provedor passa por ele:

  1. Par (iss, sub) conhecido - entra. É a única busca que autentica alguém.
  2. Endereço não confiável - recusa com LinkRequired(EMAIL_NOT_TRUSTED). Cobre tanto um provedor em que não confiamos nesse ponto (trustEmailVerified: false, o padrão) quanto um token que admitiu email_verified: false. Nada é criado, nada é casado, e o endereço não é nem consultado.
  3. Endereço confiável e já em uso - recusa com LinkRequired(ACCOUNT_EXISTS). A conta existe; juntar uma porta nova a ela é decisão de quem já está dentro, por POST /auth/oidc/{provider}/link. Aqui o comportamento difere do Google de propósito: o Google entra numa conta existente pelo endereço. A razão é o raio de alcance: quem se cadastrou com senha nunca concordou que um terceiro pudesse abrir a conta dele.
  4. Endereço confiável e desconhecido - cria a conta e o vínculo juntos.

As duas recusas chegam ao cliente como um 403 no envelope de erro padrão: errorKey FEDERATED_LINK_REQUIRED, com o motivo e o slug do provedor em details. Quase toda outra recusa da API é um 400 (as chaves de não-encontrado do calendário e do quadro de Ideias, e os dois 409 do quadro, são as outras exceções, ver Posse), e esta não é, porque não é uma falha. O cliente decide por ela e manda a pessoa entrar do jeito de sempre e depois vincular.

trustEmailVerified é por provedor e o padrão é false. Ligá-lo é uma afirmação sobre o operador daquele emissor, não sobre o código dele: diz que um true ali significa que alguém provou controlar o endereço, e que ninguém vira a coluna à mão.

A verificação do token (OidcIdTokenVerifier) checa assinatura contra o JWKS publicado pelo provedor, depois emissor, audience, azp e expiração. O algoritmo é exigido como RS256 pelo nome em vez de lido do cabeçalho, o que é o que mantém alg: none de fora. O documento de descoberta é descartado a menos que o issuer dele seja igual ao configurado: sem isso, redirecionar nossa busca de descoberta deixaria um atacante nomear o JWKS contra o qual verificamos. As chaves são cacheadas por emissor, e um kid desconhecido dispara no máximo uma rebusca por minuto, de modo que a rotação se cura sem deploy e uma sondagem não transforma cada tentativa de login numa requisição externa.

O Google não é migrado em lote: o subject dele nunca foi guardado, então não há de onde migrar. recordSeenIdentity escreve a linha no próximo login Google da conta, e falhas ali são engolidas - um login não vale ser derrubado por escrituração, e o caminho por endereço para o qual ele recai continua correto para o Google especificamente.

Um provedor ausente da configuração não existe: /auth/oidc/providers o omite e os endpoints de login respondem 400 OIDC_PROVIDER_UNKNOWN. É o interruptor, e não precisa de mudança de código. | /auth/refresh | POST | Não | Rotaciona o refresh token, emite novo JWT | | /auth/logout | POST | Não | Limpa o cookie, revoga o token | | /auth/verify | GET | Sim | Sonda de sessão; devolve "authenticated" | | /auth/forgot-password | POST | Não | Pede o e-mail de redefinição | | /auth/reset-password/validate | GET | Não | Pré-valida um token de reset | | /auth/reset-password | POST | Não | Define a nova senha |

Todos os endpoints de auth sem autenticação dividem um mesmo balde de rate limit: 5 requisições por 15 minutos por IP.

Tokens

JWT de acesso

Propriedade Valor
Algoritmo HMAC256 (auth0 java-jwt)
TTL 15 minutos
Claims iss=auth-api, sub=e-mail, exp. Nada além
Entrega Header de resposta X-Access-Token
Consumo Header de requisição Authorization: Bearer
Armazenamento Só na memória do frontend

As claims são mínimas de propósito. O papel não está no token; o SecurityFilter relê a linha do usuário a cada requisição, então uma mudança de papel ou uma conta apagada vale em uma requisição, não em um tempo de vida de token. O custo é uma leitura de banco por requisição autenticada, uma troca real.

HMAC256 em vez de RSA porque só este backend assina e verifica: não há terceiro para receber uma chave pública.

Refresh token

O token do cliente é {rowId}.{segredo}: um UUID nomeando a linha do banco mais 32 bytes aleatórios. O banco guarda só o hash SHA-256 do segredo, no formato sha256:<hex>, então uma tabela vazada não contém nada reutilizável.

Por que SHA-256 e não BCrypt. O BCrypt é lento de propósito, para que um segredo adivinhável como uma senha custe tempo real a cada tentativa. Estes segredos são 256 bits aleatórios; não há o que adivinhar, então essa lentidão não protegia nada e custava tempo a todo usuário. O refresh fazia duas operações BCrypt(12) (conferir o segredo antigo, gerar o hash do novo), cerca de 400 ms cada na máquina de produção, o que segurava o POST /auth/refresh perto de 850 ms no p50. Com SHA-256 leva poucos milissegundos. A comparação é em tempo constante.

Linhas gravadas antes da troca guardam hash BCrypt e continuam sendo conferidas com BCrypt, então o deploy não deslogou ninguém. Cada uma é substituída por uma linha SHA-256 na próxima rotação, e a última expira 15 dias depois do deploy.

flowchart TD
  CR["🔑 32 bytes aleatórios"] --> HASH["🔒 Hash SHA-256"]
  HASH --> DB["💾 Linha: id + hash + expiresAt + revokedAt"]
  CR --> OUT["📤 Para o cliente: id.segredo"]
  OUT --> REF["🔄 POST /auth/refresh"]
  REF --> MATCH["matches(segredo, hash)?<br/>expirado? revogado?"]
  MATCH --> ROT["Revoga a linha antiga, emite novo par"]

O pipeline de requisição

Três filtros próprios cooperam, e a ordem importa:

Ordem Filtro Trabalho
1 SecurityFilter (antes do UsernamePasswordAuthenticationFilter) Lista de bypass para caminhos públicos; caso contrário extrai o Bearer token, valida assinatura/expiração/emissor, carrega o usuário e popula o SecurityContext. Falhas respondem 401 com ApiErrorResponse chaveado (JWT_NOT_FOUND, AUTH_HEADER_INVALID, JWT_INVALID, USER_NOT_FOUND)
2 DocsImportSecretFilter (depois do UsernamePasswordAuthenticationFilter) Comparação em tempo constante do header X-Docs-Import-Secret para /docs/admin/import/*; segredo configurado em branco falha fechado com 403
3 RateLimitFilter (filtro servlet comum) Roda depois da cadeia de segurança, exatamente o que permite chavear baldes por usuário autenticado

Dois detalhes valem conhecer antes de mexer nesse código. Primeiro, a lista de caminhos públicos existe duas vezes: como matchers permitAll no SecurityConfig e como as condições de bypass do SecurityFilter. Elas concordam hoje, mas casam de formas diferentes (equals versus startsWith), e a deriva entre as duas é silenciosa. Segundo, dispatches assíncronos passam pela cadeia porque o stream SSE do agente redespacha; a invariante compensatória é que todo endpoint protegido precisa autenticar e checar posse no dispatch inicial.

Rate limiting

Baldes bucket4j em um cache Caffeine, a primeira faixa que casa vence:

Faixa Endpoints Limite Chaveado por
auth login, register, forgot-password, resend-verification, google, google/mobile, POST oidc/{provider} e oidc/{provider}/mobile 5 / 15 min IP
unsubscribe POST /notification/unsubscribe 5 / 15 min IP
agent POST /ai/agent/chats/* 30 / hora usuário
docs /docs/* (público) 30 / min IP
photo GET /user/photo/* 120 / min IP
onboarding POST /onboarding/suggestions 30 / hora usuário
account-deletion POST /user/deletion/* 10 / hora usuário
feedback POST /feedback 10 / hora usuário
feedback-attachment POST /feedback/*/attachments 20 / hora usuário
export GET /user/export 5 / hora usuário
briefing GET /daily-briefing 10 / hora usuário
notebook-ai POST /notebook/ai/** 60 / hora usuário
notebook-source POST /notebook/pages//sources/ 20 / hora usuário
write qualquer outro POST/PUT/DELETE 30 / min usuário
read qualquer outro GET 60 / min usuário

O Resumo do Dia fica acima da faixa de leitura genérica pelo motivo dele: a primeira chamada do dia de um usuário cria uma linha e pode segurar o pedido por até oito segundos à espera do LLM que escreve o texto do resumo, e o orçamento de 60 por minuto foi dimensionado para leituras de lista. Dez por hora cobre dois clientes, um reload e uma repetição, contra uma resposta que fica guardada na linha pelo resto do dia. O irmão POST /daily-briefing/seen fica de propósito na faixa de escrita genérica, por ser uma atualização de uma coluna sem modelo nenhum atrás. O GET /daily-briefing/narrative também fica de fora da faixa, porque a rota do resumo é comparada pelo caminho exato: é o diálogo consultando o texto que chegou depois dos oito segundos, umas dez leituras por chave primária ao longo de um minuto, e com dez por hora ele acabaria já nesse primeiro minuto.

O export fica acima da faixa de leitura genérica por um motivo que vale registrar: é um GET, mas devolve a conta inteira em uma resposta, dos hábitos e rotinas ao diário, às tarefas do calendário, ao quadro de Ideias, ao caderno, ao histórico de foco, aos resumos do dia e a cada conversa com o assistente, montados em memória e serializados de uma vez. Medido numa conta sintética com um ano de uso pesado, isso dá uns 2,5 MB de JSON montados em menos de meio segundo, e por isso ele continua sendo um pedido comum e não um job em segundo plano. Sessenta por minuto disso ainda é um jeito de segurar a heap, e ninguém que está levando os próprios dados precisa de uma sexta cópia dentro da hora.

O caderno de estudos tem duas faixas próprias, as duas checadas antes da faixa de escrita genérica. Toda rota em /notebook/ai/ é uma chamada de modelo: o chat e as saídas da sala de estudo, o rascunho de roadmap, as sugestões, o "explicar" e os flashcards da IA. Uma sessão de estudo é uma sequência de perguntas curtas sobre as mesmas fontes, e dividir os 30 por hora do assistente deixaria uma noite de estudo trancar a pessoa fora do assistente, então o caderno ganha 60, uma pergunta por minuto durante uma hora. Adicionar uma fonte lê um PDF em memória ou busca uma página na internet, e depois lê e embeda em segundo plano, e 20 por hora é uma lista de leitura inteira de uma vez. As rotas do ciclo de estudos, /notebook/cycles/** e /notebook/sessions/**, não têm faixa própria: as leituras gastam o orçamento genérico de leitura e todos os outros métodos o de escrita, inclusive a prévia POST /notebook/cycles/plan do editor, mesmo sem gravar nada.

O login federado divide o bucket de auth porque é o mesmo tipo de porta: anônima, e capaz de criar uma conta. Antes ele caía na faixa de escrita, que deixa passar qualquer pedido sem id de usuário, então não tinha limite nenhum enquanto as rotas do Google ao lado tinham cinco. Só as rotas POST de login contam. O GET /auth/oidc/providers é o que a tela de login lê a cada visita e não pode gastar essas cinco tentativas, e o /link é autenticado, então a faixa de escrita já usa o usuário como chave.

Rejeições respondem 429 com header Retry-After; sucessos carregam X-Rate-Limit-Remaining. Os dois estão citados no Access-Control-Expose-Headers, sem o que nenhum navegador consegue ler nenhum deles: nenhum está na safelist do CORS, então a espera ia no fio e era inalcançável para o cliente web.

O IP do cliente vem do header CF-Connecting-IP, não do X-Forwarded-For, e a razão vale lembrar: o Cloudflare acrescenta ao X-Forwarded-For em vez de substituí-lo, então a entrada mais à esquerda é controlada pelo atacante, e honrá-la entregaria um balde de login novo por requisição. Quando o header falta, o filtro cai para o endereço do socket, que atrás de um túnel colapsa em um balde compartilhado. Esse caso degradado é exatamente o motivo de o lockout de login por conta existir como segunda camada independente.

O subsistema inteiro fica desligado nos perfis e2e e test, e as faixas chaveadas por usuário deixam passar requisições sem autenticação.

Redefinição de senha

Exclusão de conta

Excluir a conta é a única ação onde uma sessão logada deliberadamente não basta: o fluxo exige prova de acesso à caixa de entrada.

  1. POST /user/deletion/code envia por e-mail um código de seis dígitos. Hash BCrypt em repouso, TTL de 15 minutos, cooldown de 60 segundos entre pedidos, e cada código novo invalida os anteriores.
  2. POST /user/deletion/confirm checa, nesta ordem: já usado, expirado, tentativas demais (5) e então a comparação do hash. O contador de tentativas incrementa na própria transação REQUIRES_NEW, porque a exceção que segue um palpite errado desfaz a transação externa, e contar inline deixaria o teto inalcançável.
  3. Gastar o código apaga sua linha na mesma transação, o que dobra como lock: um segundo confirm em corrida bloqueia, perde e recebe um erro chaveado.
  4. A exclusão em si remove refresh tokens e tokens de reset explicitamente, as seis coleções possuídas pelo cascade do JPA (categorias, hábitos, tarefas, metas, rotinas, snapshots), os chats com a memória de IA, as linhas de histórico por cascades no nível do banco, e os registos de humor com o texto do diário dentro, também por cascade do banco. Toda tabela criada depois disso tem cascade a partir de users do mesmo jeito: foco, logins vinculados, o resumo do dia, o caderno e os ciclos de estudo dele, os e-mails de engajamento, as tarefas do calendário, as notas do quadro de Ideias. O AccountDeletionIntegrationTest e o teste do runbook leem do schema as tabelas que apontam para users, colocam uma linha em cada, apagam e esperam que nada sobre, então uma tabela nova que esquecer o cascade falha ali. Os arquivos em disco são purgados depois do commit, em melhor esforço: os anexos de feedback e a foto de perfil. A foto passou batido no começo, porque as linhas do banco cascateiam e os bytes em disco não, então o JPEG sobrevivia à conta com um nome de arquivo que ainda era o id do usuário apagado.
  5. O cookie de refresh só é limpo após o sucesso, então um código recusado deixa a sessão intacta.

O diário, e o que se faz com ele

O diário é o único sítio do produto onde alguém escreve longamente, para si próprio, sobre a própria vida. Fica guardado como texto simples numa linha que lhe pertence. Não há cifragem em repouso além da que a base de dados e o disco dão, e dizer o contrário seria pior do que dizer isto. O cuidado está à volta:

O caderno de estudos: anotações, fontes e um servidor que busca URLs

O caderno guarda o segundo tipo de texto pessoal do produto, as anotações de estudo, e traz a única funcionalidade que faz o servidor buscar uma URL digitada por alguém. As duas coisas moldaram a segurança dele.

Posse: o modelo de autorização

Não existe segurança em nível de método no código, de propósito. O modelo é uma regra aplicada em todo lugar: cada método de service recebe o id do usuário autenticado e o compara com o dono da entidade carregada, lançando um erro chaveado no desencontro (CATEGORY_NOT_OWNED, HABIT_NOT_OWNED, TASK_NOT_OWNED, GOAL_NOT_OWNED, ROUTINE_NOT_OWNED, SNAPSHOT_NOT_OWNED, CHAT_NOT_OWNED, FEEDBACK_NOT_OWNED, NOTEBOOK_PAGE_NOT_OWNED, FOCUS_MICRO_TASK_NOT_OWNED, STUDY_CYCLE_NOT_OWNED). Schedules passam pela rotina dona, o que fechou um IDOR antigo. O caderno de estudos passa todo card, fonte, saída, nó e aresta de quadro pela página a que pertence, via NotebookOwnership. Um ciclo de estudos confere o próprio dono no StudyCycleService, e os blocos dele só são alcançados por ele, enquanto os tópicos e páginas que um ciclo cita recebem as recusas de página do caderno. Uma sessão de estudo lançada que pertence a outra pessoa responde STUDY_SESSION_NOT_FOUND, igual a uma que não existe. Tudo isso aparece como HTTP 400 com errorKey; os clientes discriminam pela chave, não pelo status.

O calendário é feito ao contrário, e foi a primeira exceção a esse 400. CalendarTaskRepository.findByIdAndUserId coloca o dono dentro da busca em vez de conferir depois, então uma tarefa de outra pessoa e um id que ninguém nunca usou recebem a mesma resposta em toda rota (editar, apagar, concluir, confirmar): 404 CALENDAR_TASK_NOT_FOUND. Uma chave NOT_OWNED conta a quem chamou que o id existe; esta resposta não conta nada. O status vem de GlobalExceptionHandler.NOT_FOUND_KEYS, uma lista opcional que tem essa chave e a do quadro de Ideias. Os domínios mais antigos continuam respondendo 400 para as próprias chaves de não-encontrado, porque os clientes web e mobile foram escritos em cima disso, e mudar isso é uma mudança coordenada à parte. Um domínio desenhado daqui em diante coloca a sua chave de não-encontrado na lista desde o começo. O calendar.spec.ts manda as quatro escritas a partir de uma segunda conta e espera o 404 em cada uma.

O e-mail de lembrete do calendário leva o título e a nota da tarefa, que são palavras do próprio usuário. Os dois passam por escape de HTML, o título tem os caracteres de controle achatados antes de virar o cabeçalho de assunto, e um envio que falha registra só ids.

O quadro de Ideias copiou o formato do calendário de propósito. QuickNoteRepository.findByIdAndUserId leva o dono, e o gêmeo com trava também (a conversão em tarefa lê a nota sob uma trava de linha), assim como a leitura em lote por trás de salvar o layout, então uma nota de outra pessoa responde 404 QUICK_NOTE_NOT_FOUND em toda rota. Um layout que cite uma nota dessas é recusado inteiro, e nada se mexe. O quadro também trouxe as primeiras chaves de erro de negócio que respondem 409 (até então só o DATA_CONFLICT respondia, por um handler próprio para uma violação de constraint inesperada), por uma segunda lista opcional ao lado da primeira, CONFLICT_KEYS: um quadro cheio (QUICK_NOTE_LIMIT_REACHED) e uma segunda conversão da mesma nota (QUICK_NOTE_ALREADY_CONVERTED). Os erros de limite mais antigos, do caderno e dos ciclos de estudo, continuam 400 pelo mesmo motivo das chaves de não-encontrado antigas. O quick-notes.spec.ts manda seis escritas a partir de uma segunda conta, espera o 404 em cada uma e confere que essa conta vê um quadro vazio.

Uma nota é texto livre que alguém escreveu para si, então o slice quickNotes fica na blacklist de persistência da web ao lado de mood e notebook, e o mobile o mantém em memória. O assistente trata as notas de outro jeito que o diário: consegue ler as do quadro pelo getQuickNotes, com o texto, porque pegar uma ideia do quadro é a razão de essa ferramenta existir. As notas arquivadas não entram nessa leitura.

Existe exatamente uma regra de papel: /feedback/admin/** exige ADMIN. O papel ADMIN é concedido apenas por update manual no banco. Nenhum seed, endpoint ou variável de ambiente cria um admin.

Endurecimento de uploads

Os dois caminhos de upload de imagem (foto de perfil, anexos de feedback) dividem a mesma forma defensiva:

O terceiro caminho de upload, um PDF do caderno de estudos, não guarda arquivo nenhum; as regras dele estão na seção do caderno acima.

Removendo a foto de perfil

Uma foto fica guardada em dois lugares sem relação entre si e é lida em ordem de prioridade, e é exatamente por isso que remover precisou de um endpoint próprio. O upload escreve {upload-dir}/user-photos/{userId}.jpg e nunca toca na linha do usuário; perfilPhoto, na linha, guarda uma URL do CDN do Google, gravada só no login OAuth. O UserMapper procura o arquivo primeiro e cai na coluna depois.

O DELETE /user/photo limpa os dois. Limpar uma metade só sempre deixa uma foto na tela: apague apenas o arquivo e uma conta Google volta para o avatar que tinha antes; limpe apenas a coluna e o arquivo enviado continua sendo servido. O segundo caso também explica por que PUT /user com photo vazio nunca funcionou como remoção, e foi nisso que os usuários bateram.

O arquivo é apagado antes de a coluna ser limpa, e uma falha ao apagar desfaz tudo. A ordem inversa pode gravar "esta conta não tem foto" sobre um JPEG que ainda está em disco e ainda ganha a prioridade, que é o único resultado pior do que recusar.

O id da conta vem do token, nunca do caminho, então o endpoint não tem nada da superfície de enumeração que o GET precisou de assinatura para fechar.

Servindo uma foto de perfil

Ler a foto de volta é o único lugar daqui onde a autorização não viaja num header. Quem chama é uma <img src> na web e uma <Image uri> no celular, e nenhuma das duas manda header, então GET /user/photo/{userId} respondia a qualquer um que soubesse citar um id de usuário. Toda foto enviada era legível percorrendo o espaço de UUIDs.

A URL carrega a própria prova:

/api/v1/user/photo/{userId}?v={mtime}&exp={epoch}&sig={HMAC-SHA256(userId|exp)}

O custo é que a URL funciona para quem estiver com ela até o exp passar, incluindo qualquer um que receber ela encaminhada. Ela expõe uma única imagem que quem mandou já podia ver.

Guarda-corpos do agente de IA

O chat do agente chama ferramentas reais, então seu modelo de autoridade importa:

Headers, CORS e guardas de boot

Headers definidos pelo backend em toda resposta:

CORS: padrões de origem vindos de uma única variável de ambiente, separados por vírgula (a produção lista o web app e o site de documentação), credenciais habilitadas e três headers expostos: X-Access-Token, Retry-After e X-Rate-Limit-Remaining. Os preflights levam Access-Control-Max-Age: 3600; sem isso o navegador guardava um preflight por uns cinco segundos, e mais ou menos uma requisição em cada três em produção era uma ida e volta extra de OPTIONS. Dev roda curinga; produção o recusa (próximo parágrafo).

Validadores de boot, a camada do "recusa a subir":

Guarda Recusa o boot quando
SecurityConfigValidator (só prod) Padrão de CORS é *, segredo do JWT com menos de 32 caracteres, cookie.secure falso, ou qualquer atalho de e2e (exposição do código de exclusão, e-mail auto-verificado) habilitado
SchemaOwnershipGuard Flyway ligado mas o ddl-auto do Hibernate diferente de validate ou none
E2eSafetyCheck (perfil e2e) A URL do datasource não parece um banco de teste

Postura operacional: o actuator vive em porta de management própria, separada da API, que em produção só o Prometheus e as sondas de uptime podem alcançar (quem garante é uma network policy, não o endereço de bind), com lista fixa de endpoints (o override por ambiente é descartado lá de propósito); o Swagger fica desligado em produção; o log de AOP registra contagem de argumentos, nunca valores; o container roda como usuário não-root; o CI roda CodeQL e uma checagem semanal de dependências OWASP.

Avaliação de segurança

O que está bem feito

O que pode melhorar

Área Estado atual Nota honesta
2FA / MFA Não implementado O código por e-mail da exclusão é o único segundo fator do produto
Log de auditoria Não implementado Logins falhos, resets e refreshes não deixam trilha dedicada
Vínculo do refresh token Sem vínculo a dispositivo ou IP A rotação limita a janela de dano, mas um token roubado funciona em qualquer lugar até lá
Vinculação de conta Google Find-or-create por e-mail, só contas verificadas Uma conta de senha VERIFICADA ainda é logada por uma identidade Google coincidente sem etapa explícita de vinculação. O caso não verificado, que era o perigoso, agora é recusado
Enumeração no registro "Email already in use" por escolha Com rate limit, e um tradeoff de usabilidade, mas ainda um oráculo
Nuance do cooldown de reset 400 dentro do cooldown para contas reais Um sondador paciente distingue endereços conhecidos num segundo pedido
Throttle do verify-email Sem limite GET sem autenticação que escapa das faixas por usuário; a entropia do token é a única guarda. O POST /auth/resend-verification irmão ESTÁ na faixa auth
Token de verificação em repouso Coluna em texto plano na linha do usuário O token de reset é guardado como hash BCrypt; este sai legível de um dump do banco
Segredo do docs import Comparado em tempo constante, falha fechado em branco Nada valida seu comprimento ou entropia no boot
Prompt injection Defesa só por instrução Sem filtragem programática do texto do usuário antes do modelo
Teste de regressão do CSP O teste garante a existência do header, não o valor Um enfraquecimento silencioso do CSP passaria na suíte
Fontes de link e DNS rebinding Todo endereço resolvido é checado, e os redirects são rechecados à mão A conexão não fica presa ao endereço checado, então um nome que muda entre a checagem e a conexão passa. Os alvos internos úteis não respondem nada que um extrator de texto aproveite

Resumo do modelo de ameaças

Ameaça Mitigada? Como
Roubo de senha em vazamento do banco Sim BCrypt custo 12; segredos de reset/exclusão com hash BCrypt, segredos de refresh com hash SHA-256
XSS roubando tokens Em grande parte JWT em memória, refresh em cookie HttpOnly, CSP nas respostas da API
CSRF Sim Auth bearer stateless; cookie lido só por refresh/logout; SameSite Strict em prod
Força bruta no login Sim 5/15min por IP mais lockout de conta em 10 falhas
Enumeração de usuários Em grande parte Login e reset são silenciosos; o registro e o cooldown do reset são as exceções documentadas
Replay de token após rotação Sim Refresh tokens antigos são revogados transacionalmente
IDOR Sim Checagem de posse em cada service, erros chaveados, schedule roteado pela rotina, buscas do calendário e do quadro de Ideias restritas ao dono
Bombas de descompressão Sim Teto de pixels no cabeçalho antes do decode
Ferramentas de IA como confused deputy Sim ToolContext montado no servidor; ferramentas herdam só a autoridade do chamador
Fixação de sessão Sim Sessões não existem
SSRF pelas fontes de link do caderno Em grande parte Só endereços públicos, redirects checados a cada salto, corpos com teto; DNS rebinding é a lacuna documentada