Programação Por Equipe DevBras

Capa do artigo: Página de perfil segura: o formulário que não deixa a pessoa se promover a admin

Página de perfil segura: o formulário que não deixa a pessoa se promover a admin

Mostre só o que a pessoa pode editar, aceite no servidor apenas os campos de uma lista branca e trate troca de e-mail e senha como fluxos à parte.

Num teste de segurança do portal de assinantes da Revista Quintal, o auditor abriu a página de perfil, apertou F12 e acrescentou ao formulário um campo <input name="papel" value="admin">. Salvou. Recarregou a página e o menu de administração apareceu. Levou menos de um minuto, e o código do perfil tinha só doze linhas.

A resposta curta: a página de perfil mostra o que a pessoa pode alterar, e o servidor aceita somente os campos de uma lista branca, ignorando qualquer outro que chegue no POST. Papel, id, datas de auditoria e hash de senha nunca vão para o formulário nem passam pelo mesmo caminho de gravação. E-mail de login e senha têm fluxos próprios, com confirmação.

Por que o perfil vira porta de entrada

O jeito mais rápido de fazer uma tela de perfil é gerar o formulário a partir das colunas da tabela e, no servidor, gravar tudo que chegou. O código fica curto e genérico: percorre $_POST e monta o UPDATE. O problema é que o navegador está nas mãos da pessoa. Ela pode acrescentar campos, mudar valores ocultos e reenviar a requisição com qualquer ferramenta. Esse tipo de falha tem nome: atribuição em massa (mass assignment).

A mesma tela costuma ter uma segunda falha: o id da conta vem de um campo oculto. Troque id=12 por id=13 e você edita o perfil de outra pessoa. A conta a ser alterada precisa sair da sessão, nunca do formulário, como em mostrar o nome de quem está logado.

E há o problema da leitura: carregar a linha inteira com SELECT * e jogar no template expõe colunas que a pessoa não deveria ver, como observações internas ou o hash da senha. O vizinho SELECT * para mostrar na tela detalha esse lado.

Como montar o perfil do jeito certo

  1. Separe o que é editável. Na Revista Quintal: nome, como prefere ser chamado, telefone e preferência de newsletter.
  2. Separe o que é só leitura. Data de assinatura, plano atual. Aparece como texto, não como input.
  3. Esconda o que é interno. Papel, id, hash, flags de bloqueio, datas de auditoria.
  4. Leia só as colunas que a tela usa.
  5. Grave só os campos da lista branca, validados um a um.
  6. Use o id da sessão no WHERE.
  7. Trate e-mail, senha e foto em fluxos à parte.

Exemplo em PHP com PDO

<?php
session_start();
$usuarioId = $_SESSION['usuario_id'] ?? null;
if ($usuarioId === null) {
    http_response_code(401);
    exit;
}

if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
    http_response_code(403);
    exit;
}

$permitidos = ['nome', 'apelido', 'telefone', 'newsletter'];
$entrada = array_intersect_key($_POST, array_flip($permitidos));

$erros = [];
$nome = trim($entrada['nome'] ?? '');
if ($nome === '' || mb_strlen($nome) > 120) {
    $erros['nome'] = 'Informe um nome com até 120 caracteres.';
}
$apelido = trim($entrada['apelido'] ?? '');
if (mb_strlen($apelido) > 40) {
    $erros['apelido'] = 'Use até 40 caracteres.';
}
$telefone = preg_replace('/\D+/', '', $entrada['telefone'] ?? '');
if ($telefone !== '' && (strlen($telefone) < 10 || strlen($telefone) > 11)) {
    $erros['telefone'] = 'Telefone com DDD, 10 ou 11 dígitos.';
}
$newsletter = isset($entrada['newsletter']) ? 1 : 0;

if ($erros) {
    http_response_code(422);
    // devolve o formulário com $erros
    exit;
}

$stmt = $pdo->prepare(
    'UPDATE assinantes SET nome = ?, apelido = ?, telefone = ?, newsletter = ? WHERE id = ?'
);
$stmt->execute([$nome, $apelido ?: null, $telefone ?: null, $newsletter, $usuarioId]);

O array_intersect_key descarta qualquer campo fora da lista, inclusive o papel injetado pelo auditor. O UPDATE tem as colunas escritas à mão, então mesmo que alguém altere a lista por engano, o SQL não grava o que não está nele. O WHERE id = ? usa a sessão. E o token CSRF impede que outro site envie esse formulário em nome da pessoa logada.

Em frameworks, o equivalente é declarar quais atributos podem ser preenchidos em massa e gravar apenas os dados já validados. No Laravel, por exemplo, isso é o $fillable do model somado a $request->validated(), e não $request->all().

A leitura com colunas explícitas

$stmt = $pdo->prepare(
    'SELECT nome, apelido, telefone, newsletter, plano, assinante_desde
       FROM assinantes WHERE id = ?'
);
$stmt->execute([$usuarioId]);
$perfil = $stmt->fetch(PDO::FETCH_ASSOC);

No template, plano e assinante_desde aparecem como texto. E todo valor impresso passa por htmlspecialchars, porque o apelido que a pessoa digitou é exibido de volta, às vezes também para outras pessoas.

E-mail, senha e foto: fluxos separados

E-mail de login não é um input solto no formulário de perfil. Se a sessão foi sequestrada, trocar o e-mail é o passo que tira a conta do dono de vez. O fluxo seguro: pedir a senha atual, enviar um link de confirmação para o endereço novo, só trocar quando o link for clicado e avisar o endereço antigo. O token do link é gerado com random_bytes, guardado como hash e expira.

Senha pede a senha atual, grava com password_hash e encerra as outras sessões abertas. O porquê está em a senha que não pode ser lida nem por você.

Foto de perfil segue a regra de upload: tipo conferido pelo conteúdo, tamanho limitado, nome gerado pelo servidor, pasta onde nada executa. Detalhes em upload de arquivo em PHP.

Como testar se o perfil confia no navegador

Repita o teste do auditor na sua aplicação, em ambiente de teste. Abra as ferramentas do navegador, acrescente um campo com o nome de uma coluna sensível (papel, is_admin, plano, id) e salve. Depois confira direto no banco se algo mudou. Faça o mesmo alterando qualquer campo oculto que já exista. Se o formulário tem um user_id escondido, troque pelo de outra conta de teste.

Esse teste vale para toda tela que grava, não só o perfil. Cadastro, edição de endereço, preferências: qualquer lugar onde o servidor recebe um conjunto de campos e grava.

Erros comuns

  • Gravar tudo que veio no POST. Um campo injetado promove a conta, muda o plano ou desbloqueia o usuário.
  • Usar id do formulário no WHERE. Troca-se o número e se edita o perfil de outra pessoa.
  • Esconder o campo com CSS ou type="hidden" e achar que está protegido. Ele continua sendo enviado e editável.
  • Trocar o e-mail de login sem confirmação. Uma sessão roubada vira conta roubada para sempre.
  • Exibir o apelido sem escape. O perfil vira vetor de script para quem vê o nome da pessoa.
  • Carregar a linha inteira para a tela. Colunas internas aparecem no HTML ou na resposta JSON.

Checklist

  • O servidor filtra os campos recebidos por uma lista branca.
  • O UPDATE tem as colunas escritas explicitamente.
  • O id da conta vem da sessão, não do formulário.
  • A leitura seleciona só as colunas que a tela usa.
  • Troca de e-mail exige senha atual e confirmação no endereço novo.
  • Troca de senha exige a senha atual e encerra outras sessões.
  • O formulário tem token CSRF.
  • O teste de injetar papel=admin foi feito e não alterou o banco.

Abra o perfil da sua aplicação, acrescente pelas ferramentas do navegador um campo com o nome da coluna de permissão e salve: o valor no banco mudou?

Avalie este artigo

Referência: https://bootsnipp.com/snippets/nPvnk