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
- Crie um ViewModel só com os campos editáveis daquela tela.
- Pegue o id da rota, nunca do corpo.
- Carregue a entidade do banco filtrando também pelo dono, para um cliente não editar o pedido de outro.
- Valide o ViewModel e copie campo a campo para a entidade carregada.
- Salve com
SaveChangesAsync(), sem chamarUpdate(): o rastreamento de mudanças gera oUPDATEsó do que mudou. - 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 depoisUpdate(): 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
SetAfterSaveBehaviorcomIgnoreouThrow. - Total e outros derivados são recalculados no servidor.
- Existe teste que envia
CriadoEmeTotalalterados 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?
Referência: https://docs.microsoft.com/pt-br/ef/core/modeling/generated-properties