Programação Por Equipe DevBras

Capa do artigo: INSERT em PHP com PDO: o cadastro que aceita D'Ávila sem abrir brecha de SQL

INSERT em PHP com PDO: o cadastro que aceita D'Ávila sem abrir brecha de SQL

Troque o INSERT montado com variável por uma consulta preparada com PDO ou mysqli, valide os campos, trate e-mail duplicado e rode com um usuário de banco sem poder de apagar.

O cadastro de clientes da loja funcionou com a Maria, com o João e com mais trezentas pessoas. Aí a Joana D'Ávila tentou se cadastrar e recebeu "You have an error in your SQL syntax". Ninguém tinha mexido no código. Ele sempre esteve quebrado: o apóstrofo do sobrenome só foi a primeira entrada que mostrou isso. A próxima pode não vir de alguém com sobrenome, e sim de alguém procurando a brecha.

A resposta curta: nunca coloque o que veio do formulário dentro do texto do SQL. Escreva o INSERT com marcadores (:nome ou ?), prepare a consulta e envie os valores separados, com PDO ou mysqli. O banco recebe a estrutura da consulta de um lado e os dados do outro, e um apóstrofo volta a ser só uma letra.

Por que concatenar quebra e abre brecha

O código que o tutorial antigo ensinava era mais ou menos este:

$nome  = $_POST['nome'];
$email = $_POST['email'];
$sql   = "INSERT INTO clientes (nome, email) VALUES ('$nome', '$email')";
mysqli_query($conexao, $sql);

Com "Joana D'Ávila", o texto que chega ao MySQL tem uma aspa a mais. A string do nome termina em "Joana D", e o resto, "Ávila', '...", vira SQL que não faz sentido. Por isso o erro de sintaxe.

Agora imagine que, em vez de um sobrenome, alguém digite uma aspa seguida de uma subconsulta que lê a tabela de usuários e um comentário que anula o resto da linha. O INSERT continua válido e grava, no campo de e-mail do cadastro do atacante, o hash da senha do administrador. Depois basta ele abrir a própria página de perfil. Não precisa de DROP TABLE nem de nada espetacular: injeção em INSERT costuma ser silenciosa e funcionar por meses.

Escapar à mão, com addslashes ou funções parecidas, é tentar adivinhar todos os caracteres perigosos para cada encoding e cada contexto. Consulta preparada elimina a adivinhação, porque o valor nunca é interpretado como SQL.

Como escrever o INSERT com PDO

O exemplo é o cadastro da loja: nome, e-mail e data de nascimento, numa tabela clientes com UNIQUE no e-mail.

  1. Abra a conexão com charset e modo de erro definidos. charset=utf8mb4 no DSN evita que o "Á" da Joana vire lixo no caminho. Desde o PHP 8, o PDO já lança exceção por padrão, mas deixar explícito protege quem roda versão antiga.
  2. Desligue a emulação de preparo. Com PDO::ATTR_EMULATE_PREPARES em false, o driver do MySQL usa o preparo nativo do servidor em vez de montar a string no PHP.
  3. Valide antes de gravar. Parâmetro protege o SQL, não a regra de negócio. E-mail precisa parecer e-mail, nome precisa ter tamanho máximo, data precisa ser data.
  4. Liste as colunas no INSERT. INSERT INTO clientes VALUES (...) sem colunas quebra no dia em que alguém acrescenta um campo na tabela.
  5. Trate a duplicidade como resposta, não como erro 500. O código 1062 do MySQL indica chave única repetida.
<?php
$pdo = new PDO(
    'mysql:host=localhost;dbname=loja;charset=utf8mb4',
    getenv('DB_USER'),
    getenv('DB_PASS'),
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_EMULATE_PREPARES   => false,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]
);

$nome       = trim($_POST['nome'] ?? '');
$email      = trim($_POST['email'] ?? '');
$nascimento = $_POST['nascimento'] ?? '';

$erros = [];
if ($nome === '' || mb_strlen($nome) > 120) {
    $erros[] = 'Informe um nome com até 120 caracteres.';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
    $erros[] = 'Informe um e-mail válido.';
}
$data = DateTime::createFromFormat('!Y-m-d', $nascimento);
if ($data === false || $data->format('Y-m-d') !== $nascimento) {
    $erros[] = 'Informe a data de nascimento no formato do campo.';
}

if ($erros) {
    http_response_code(422);
    exit(implode(' ', $erros));
}

$stmt = $pdo->prepare(
    'INSERT INTO clientes (nome, email, nascimento) VALUES (:nome, :email, :nascimento)'
);

try {
    $stmt->execute([
        'nome'       => $nome,
        'email'      => $email,
        'nascimento' => $nascimento,
    ]);
    $id = (int) $pdo->lastInsertId();
} catch (PDOException $e) {
    if (($e->errorInfo[1] ?? null) === 1062) {
        http_response_code(409);
        exit('Já existe um cadastro com este e-mail.');
    }
    throw $e;
}

A comparação $data->format('Y-m-d') !== $nascimento existe porque createFromFormat aceita "2026-02-31" e transforma em 3 de março. Comparar a volta com a ida pega a data impossível.

A mesma coisa com mysqli

Se o projeto já usa mysqli, não precisa migrar tudo para PDO para corrigir. O preparo existe lá também, com ? no lugar dos nomes e uma letra de tipo por parâmetro:

$db = new mysqli('localhost', getenv('DB_USER'), getenv('DB_PASS'), 'loja');
$db->set_charset('utf8mb4');

$stmt = $db->prepare('INSERT INTO clientes (nome, email, nascimento) VALUES (?, ?, ?)');
$stmt->bind_param('sss', $nome, $email, $nascimento);
$stmt->execute();
$id = $db->insert_id;

Do PHP 8.2 em diante, $db->execute_query($sql, [$nome, $email, $nascimento]) faz preparo e execução numa chamada só.

O que parâmetro não resolve

Marcador substitui valor. Nome de tabela, nome de coluna e direção de ordenação não podem ser parâmetros. Se a tela deixa escolher a ordenação, compare com uma lista fechada:

$colunas = ['nome' => 'nome', 'cadastro' => 'criado_em'];
$ordem   = $colunas[$_GET['ordem'] ?? ''] ?? 'criado_em';
$stmt    = $pdo->query("SELECT id, nome FROM clientes ORDER BY $ordem DESC LIMIT 50");

Aqui a variável entra no SQL, mas só pode valer um dos textos que você escreveu.

Parâmetro também não protege a tela. O nome gravado volta para o HTML e precisa de htmlspecialchars na saída, senão um "nome" com <script> vira XSS no painel do atendente. E se o cadastro tiver senha, ela vai com password_hash, como em login com senha protegida. Quem trabalha com Node.js encontra a mesma ideia em Node.js e MySQL com parâmetro.

O usuário do banco também é uma trava

O site não precisa apagar tabelas. Crie um usuário só para ele, com o mínimo:

CREATE USER 'loja_site'@'localhost' IDENTIFIED BY 'troque-por-uma-senha-gerada';
GRANT SELECT, INSERT, UPDATE ON loja.* TO 'loja_site'@'localhost';

Se uma brecha escapar em algum canto antigo do código, ela herda só esses poderes.

Como testar antes de dar por resolvido

Monte uma pequena lista de cadastros de teste e passe todos pelo formulário real, não só pelo código isolado. Na loja do exemplo, a lista tem "Joana D'Ávila", um nome com aspas duplas, um nome com 121 caracteres, um e-mail sem arroba, a data "2026-02-31" e o mesmo e-mail cadastrado duas vezes seguidas.

O resultado esperado é previsível: os dois primeiros gravam e aparecem idênticos na listagem; os três seguintes voltam com a mensagem de validação; o último responde que o e-mail já existe. Se qualquer um deles gerar erro de sintaxe, tela branca ou registro com o nome cortado, ainda há uma consulta montada com variável em algum lugar do caminho. Guarde essa lista num teste automatizado para que a próxima mudança no cadastro passe por ela também.

Erros comuns

  • Preparar a consulta já com a variável dentro. $pdo->prepare("... VALUES ('$nome')") tem a mesma brecha de antes, só com um nome mais bonito.
  • Escapar com addslashes e achar que resolveu. Funciona até o encoding ou o contexto mudar, e você não fica sabendo quando muda.
  • Mostrar a mensagem do PDOException para o visitante. Ela revela nome de tabela, de coluna e às vezes o usuário do banco.
  • Tratar e-mail duplicado com um SELECT antes do INSERT. Dois cadastros simultâneos passam pela checagem; só o UNIQUE no banco garante.
  • Rodar o site com o usuário root. Qualquer brecha vira poder de apagar o banco inteiro.
  • Achar que parâmetro dispensa escapar na saída. O SQL fica seguro e o HTML continua aberto a XSS.

Checklist

  • Nenhuma consulta do projeto tem $_POST, $_GET ou variável de formulário dentro do texto do SQL.
  • A conexão declara charset=utf8mb4 e lança exceção em erro.
  • Todo INSERT lista as colunas.
  • O e-mail tem índice UNIQUE e o código 1062 vira uma mensagem amigável.
  • Ordenação e filtros por coluna usam lista fechada.
  • O usuário do banco do site não tem DROP, DELETE em massa nem GRANT.
  • O cadastro "Joana D'Ávila" grava e aparece igual na tela.

Rode agora uma busca no projeto por VALUES ('$: quantos INSERT ainda colam o formulário direto no SQL?

Avalie este artigo

Referência: http://phpsqlbr.blogspot.com.br/2009/05/inserindo-registros-pelo-php.html