IDOR e autorização

· Padrão Frontend
Juan Kalleo
Juan Kalleo
Senior Fullstack
This page hasn't been translated to English yet — showing the original Portuguese content.

IDOR (Insecure Direct Object Reference) é o que acontece quando uma rota confia no id que o cliente manda pra decidir qual registro devolver, sem checar se aquele usuário tem permissão pra aquele registro específico — trocar /usuarios/42 por /usuarios/43 na URL e ver dado de outra pessoa/tenant. O OWASP Authorization Cheat Sheet cobre isso e mais: negar por padrão, checar permissão em toda requisição, nunca confiar em checagem feita só no cliente, e logar/testar decisão de autorização.

No padrão frontend: nunca confia em checagem do cliente

Já correto, e documentado antes mesmo desta revisão. O front não esconde menu nem rota por papel/permissão — todo item de navegação aparece pra qualquer usuário autenticado, sem condicional de role/papel em lugar nenhum (confirmado por grep). Isso parece, à primeira vista, o oposto do que o OWASP pede — mas é exatamente o comportamento seguro: o item 8 do cheat sheet diz pra nunca tratar checagem client-side como decisiva. Como o front não tenta essa checagem de jeito nenhum, não existe o risco de alguém confiar numa tela que "parece" proteger mas não protege — a única fonte de verdade é a resposta real da API. CurrentUserSerializer nem manda lista de papel/permissão pro front (só id, nome, email, a_tenant, a_tipo_usuario) — não tem nem como montar uma checagem de UI com esse dado, de propósito. O gap real aqui é de UX (item de menu que vai dar erro ao clicar, em vez de já aparecer escondido), não de segurança — já registrado como limite conhecido em Medidas de segurança.

Na API: fetch-then-authorize, não IDOR clássico

Todo controller de admin segue o mesmo padrão — api/app/controllers/api/v1/admin/a_unidades_controller.rb:

def show
  authorize! :CONSULTAR, @a_unidade
  render_success(data: @a_unidade, message: "Unidade encontrado com sucesso")
end

def set_a_unidade
  @a_unidade = AUnidade.find(params[:id])
end

AUnidade.find(params[:id]) sozinho, sem escopo, pareceria um IDOR clássico — mas authorize! roda depois do fetch e reavalia a instância carregada contra condições reais de escopo de tenant, definidas em api/app/models/ability.rb:

AUnidade => lambda { |escopo|
  case escopo
  when ATenant then { a_orgao: { a_tenant_id: escopo.id } }
  when AOrgao  then { a_orgao_id: escopo.id }
  when AUnidade then { id: escopo.id }
  end
},

O CanCanCan resolve essas condições recursivamente contra as associações reais do registro carregado — um admin escopado num tenant que tenta show numa AUnidade de outro tenant recebe CanCan::AccessDenied → 403 (error_handler.rb), mesmo o find tendo carregado o registro sem filtro nenhum. É o idioma documentado do próprio CanCanCan (buscar, depois autorizar), aplicado de forma consistente em todo controller verificado.

Nuance real, não um IDOR completo: como o find roda antes do authorize!, um admin restrito que adivinha o id de outro tenant recebe 403 (prova que o registro existe) em vez de 404 (esconderia a existência). Nenhum dado do registro de outro tenant é devolvido — só a mensagem genérica de "Acesso não permitido" — então é um oráculo de existência de baixa severidade, não vazamento de dado.

index (listagem) usa um caminho mais estrito — AOrgao.accessible_by (@ability).ransack(...), que já filtra no SQL, então um admin restrito nem consegue enumerar registro de outro tenant via listagem, só via id adivinhado num show/update/destroy direto.

O que falta: log e teste da decisão de negar

Log de autorização negada: não existe. render_forbidden (error_handler.rb) não chama Rails.logger — diferente dos outros handlers de erro do mesmo arquivo (render_foreign_key_error, render_internal_error etc., que chamam log_controlado). Uma tentativa de acesso negado por CanCan::AccessDenied vira 403 sem deixar rastro server-side de quem tentou, o quê, quando.

Teste de autorização: só no nível de model, não no de rota. api/test/models/ability_test.rb testa negação de verdade — não só sucesso:

test "usuário cujo papel só tem permissão que não resolve pra uma classe real não ganha acesso nenhum" do
  ability = Ability.new(users(:two))
  assert ability.cannot?(:manage, APapel)
  assert ability.cannot?(:read, AUnidade)
end

Mas nenhum teste de controller (os 16 arquivos em api/test/controllers/ api/v1/admin/) usa outra fixture além de users(:one) — o admin wildcard "Plataforma", que pode tudo. users(:two) (escopado, o único fixture não-wildcard) nunca aparece em teste de controller — só no teste de model acima. Ou seja: não existe hoje uma prova automatizada, rodando contra uma rota HTTP real, de que um usuário restrito realmente recebe 403 ao tentar acessar registro de outro escopo — a garantia atual vem só da leitura do código (ability.rb + CanCanCan), não de um teste que rode isso de ponta a ponta. Ver Autorização automatizada pra o que o OWASP recomenda especificamente pra fechar esse tipo de gap.