Programação Por Equipe DevBras

Capa do artigo: Overposting no ASP.NET Core: id, data de criação e total não vêm do formulário

Overposting no ASP.NET Core: id, data de criação e total não vêm do formulário

Como impedir que um POST altere colunas geradas pelo banco no EF Core: ViewModel só com o editável, entidade carregada do banco, SetAfterSaveBehavior e total recalculado no servidor.

A tela de edição de pedidos tinha campos ocultos com Id, CriadoEm e Total, "para não perder no POST". A action recebia a entidade Pedido inteira e chamava _context.Update(pedido). Um cliente abriu o DevTools, trocou o Total de 1.240,00 para 12,40 e salvou. O banco aceitou. A data de criação de outro pedido apareceu como 2001, porque alguém testou o mesmo truque.

A resposta curta: o que o banco ou o servidor gera não volta do navegador. Receba um ViewModel só com os campos que a pessoa pode mudar, carregue a entidade do banco pelo id da rota (conferindo o dono), copie os campos permitidos e salve. No EF Core, marque as colunas geradas para serem ignoradas em atualização com SetAfterSaveBehavior. Total se recalcula no servidor, sempre.

Por que o formulário consegue mudar o que não mostra

O model binding do ASP.NET Core preenche toda propriedade pública do tipo do parâmetro que tiver um campo com o mesmo nome na requisição. Se o parâmetro é a entidade, cada coluna dela é um campo aceito, inclusive as que a tela nem exibe. Esse ataque tem nome: overposting, ou mass assignment. Não é preciso ter um campo oculto na tela: basta acrescentar CriadoEm=2001-01-01 ao corpo da requisição.

O _context.Update(pedido) completa o estrago. Ele anexa a entidade ao contexto e marca todas as propriedades como modificadas. O UPDATE gerado regrava cada coluna com o que veio da requisição, inclusive com valores padrão nas que não vieram. Um campo que a tela esqueceu de mandar vira null ou zero no banco.

O valor gerado em si, e por que a aplicação não deve inventá-lo, é tema de coluna gerada pelo banco. Este guia trata da outra metade: impedir que ele seja sobrescrito na edição.

Como receber a edição do jeito certo

  1. Crie um ViewModel só com os campos editáveis daquela tela.
  2. Pegue o id da rota, nunca do corpo.
  3. Carregue a entidade do banco filtrando também pelo dono, para um cliente não editar o pedido de outro.
  4. Valide o ViewModel e copie campo a campo para a entidade carregada.
  5. Salve com SaveChangesAsync(), sem chamar Update(): o rastreamento de mudanças gera o UPDATE só do que mudou.
  6. Recalcule no servidor tudo o que é derivado, como o total.
public class EditarPedidoViewModel
{
    [Required, StringLength(200)]
    public string EnderecoEntrega { get; set; } = "";

    [StringLength(500)]
    public string? Observacao { get; set; }
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Editar(int id, EditarPedidoViewModel modelo)
{
    var clienteId = int.Parse(User.FindFirstValue("cliente_id")!);

    var pedido = await _context.Pedidos
        .SingleOrDefaultAsync(p => p.Id == id && p.ClienteId == clienteId);

    if (pedido is null)
        return NotFound();

    if (!ModelState.IsValid)
        return View(modelo);

    pedido.EnderecoEntrega = modelo.EnderecoEntrega;
    pedido.Observacao = modelo.Observacao;

    await _context.SaveChangesAsync();
    return RedirectToAction(nameof(Detalhes), new { id });
}

Agora, um POST com Total=12.40 ou CriadoEm=2001-01-01 é ignorado: o ViewModel não tem essas propriedades, e o binder não tem onde colocá-las. Um POST para o pedido de outro cliente devolve 404, porque a consulta filtra por ClienteId. O UPDATE enviado ao banco contém só as colunas que realmente mudaram.

Alternativa: TryUpdateModelAsync com lista de campos

Se a tela tem muitos campos e você não quer um ViewModel, o controller oferece TryUpdateModelAsync com a lista explícita do que pode ser alterado:

var ok = await TryUpdateModelAsync(pedido, "",
    p => p.EnderecoEntrega,
    p => p.Observacao);

Funciona porque a entidade foi carregada do banco antes. Já o atributo [Bind] no parâmetro, sozinho, é ruim para edição: as propriedades fora da lista ficam com valor padrão, e um Update() em seguida grava esses valores vazios por cima dos reais.

Proteção no modelo do EF Core

O código da action é a primeira barreira. A segunda fica no modelo, para o caso de alguém, no futuro, escrever outra action do jeito antigo:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Pedido>(e =>
    {
        e.Property(p => p.CriadoEm)
            .HasDefaultValueSql("SYSUTCDATETIME()")
            .Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore);

        e.Property(p => p.ClienteId)
            .Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Throw);

        e.Property(p => p.Total).HasPrecision(18, 2);
        e.Property(p => p.Versao).IsRowVersion();
    });
}

O SYSUTCDATETIME() é do SQL Server; em outro banco, use a função equivalente. Com PropertySaveBehavior.Ignore, o EF não inclui CriadoEm em nenhum UPDATE, mesmo que a propriedade esteja marcada como modificada. Com Throw, uma tentativa de alterar ClienteId depois de salvo lança exceção, o que é bom para campos que nunca deveriam mudar: o erro aparece no teste, não no relatório do mês. O PropertySaveBehavior fica no namespace Microsoft.EntityFrameworkCore.Metadata.

O IsRowVersion() é um bônus: dois atendentes editando o mesmo pedido não sobrescrevem um ao outro em silêncio, porque o segundo SaveChanges recebe uma exceção de concorrência.

Total é cálculo, não campo

O total do pedido depende dos itens, do frete e do desconto. Se ele vem do formulário, o cliente decide quanto paga. Recalcule a partir dos itens carregados do banco, com os preços do banco, toda vez que algo que o afeta mudar. Para valores monetários, a precisão do tipo importa tanto quanto a origem; veja dinheiro em centavos. E em listas de itens editadas no mesmo formulário, o cuidado com índices está em lista que volta do formulário cria item novo.

Em API JSON vale o mesmo

Numa API, o equivalente ao ViewModel é um DTO de entrada. O System.Text.Json ignora propriedades desconhecidas por padrão, então um "criadoEm" extra no JSON simplesmente não chega ao DTO. A partir do .NET 8 é possível configurar o serializador para rejeitar membros não mapeados, o que transforma a tentativa num erro 400 explícito, útil para pegar clientes mal escritos.

Erros comuns

  • Receber a entidade como parâmetro da action: toda coluna vira campo aceito, inclusive total e dono.
  • Chamar Update() com o objeto vindo da tela: todas as colunas são regravadas, e as ausentes viram vazias.
  • Pegar o id do corpo: o cliente troca o número e edita o pedido de outra pessoa.
  • Guardar valores gerados em campo oculto: passa a impressão de que estão protegidos, mas qualquer DevTools os altera.
  • Confiar no total enviado pelo navegador: o preço vira escolha do cliente.
  • Usar [Bind] em edição e depois Update(): campos fora da lista são zerados no banco.

Checklist

  • Nenhuma action de edição recebe a entidade do EF diretamente.
  • O id vem da rota e a consulta filtra pelo dono.
  • Só campos editáveis são copiados para a entidade carregada.
  • Colunas geradas usam SetAfterSaveBehavior com Ignore ou Throw.
  • Total e outros derivados são recalculados no servidor.
  • Existe teste que envia CriadoEm e Total alterados e confere que nada mudou.

Abra a tela de edição do seu sistema, acrescente pelo DevTools um campo oculto com name="CriadoEm" e uma data de 2001, e salve: a data de criação do registro mudou no banco?

Avalie este artigo

Referência: https://docs.microsoft.com/pt-br/ef/core/modeling/generated-properties