Prepared statements e prevenção de SQL injection

1. O Perigo do SQL Injection

SQL Injection é uma das vulnerabilidades mais críticas e persistentes em aplicações web. Ela ocorre quando um atacante consegue inserir código SQL malicioso através de campos de entrada do usuário, como formulários, URLs ou cookies. O ataque explora a falta de sanitização adequada dos dados antes de serem incluídos em consultas SQL.

Exemplo clássico de vulnerabilidade:

// Código VULNERÁVEL - NUNCA faça isso!
$email = $_POST['email'];
$senha = $_POST['senha'];
$sql = "SELECT * FROM usuarios WHERE email = '$email' AND senha = '$senha'";
$resultado = mysqli_query($conexao, $sql);

Se um atacante enviar ' OR '1'='1 como email e ' OR '1'='1 como senha, a consulta se torna:

SELECT * FROM usuarios WHERE email = '' OR '1'='1' AND senha = '' OR '1'='1'

Isso retornaria todos os usuários do banco, permitindo acesso não autorizado.

Consequências reais:
- Roubo de dados sensíveis (senhas, dados financeiros)
- Alteração ou exclusão de registros
- Comprometimento total do servidor
- Instalação de malware

O PHP é um alvo comum por causa de:
- Grande quantidade de código legado sem práticas modernas
- Facilidade de aprendizado que leva a más práticas
- Tutoriais antigos que ainda ensinam concatenação direta

2. Entendendo Prepared Statements

Prepared statements (declarações preparadas) resolvem o problema do SQL Injection separando completamente a estrutura SQL dos dados fornecidos pelo usuário.

Ciclo de funcionamento:
1. Preparar: O banco de dados analisa e compila a estrutura SQL
2. Vincular parâmetros: Os placeholders são substituídos pelos valores reais
3. Executar: A consulta completa é executada

-- Estrutura SQL com placeholder
INSERT INTO usuarios (nome, email) VALUES (?, ?)

-- O banco entende que ? serão dados, nunca código

Diferença fundamental:
- Consulta estática: SQL fixo, sem variáveis do usuário
- Consulta dinâmica: Precisa de dados do usuário → use prepared statements

3. Prepared Statements com MySQLi

MySQLi oferece suporte nativo a prepared statements através de funções específicas.

Exemplo de INSERT:

$conexao = new mysqli("localhost", "usuario", "senha", "banco");

// 1. Preparar
$stmt = $conexao->prepare("INSERT INTO usuarios (nome, email, idade) VALUES (?, ?, ?)");

// 2. Vincular parâmetros (s=string, s=string, i=integer)
$stmt->bind_param("ssi", $nome, $email, $idade);

// 3. Atribuir valores
$nome = "Maria Silva";
$email = "maria@email.com";
$idade = 28;

// 4. Executar
$stmt->execute();

echo "Usuário inserido com ID: " . $stmt->insert_id;
$stmt->close();

Tipos de parâmetros:
- s: string
- i: integer
- d: double/float
- b: blob (dados binários)

Exemplo de SELECT com condição:

$stmt = $conexao->prepare("SELECT * FROM produtos WHERE preco < ? AND categoria = ?");
$stmt->bind_param("ds", $precoMaximo, $categoria);

$precoMaximo = 50.00;
$categoria = "eletrônicos";
$stmt->execute();

$resultado = $stmt->get_result();
while ($produto = $resultado->fetch_assoc()) {
    echo $produto['nome'] . " - R$ " . $produto['preco'] . "<br>";
}
$stmt->close();

Exemplo de UPDATE:

$stmt = $conexao->prepare("UPDATE usuarios SET ultimo_login = NOW() WHERE id = ?");
$stmt->bind_param("i", $usuarioId);

$usuarioId = 42;
$stmt->execute();
echo "Linhas afetadas: " . $stmt->affected_rows;
$stmt->close();

4. Prepared Statements com PDO

PDO (PHP Data Objects) oferece uma interface mais moderna e flexível, suportando múltiplos bancos de dados.

Configuração inicial com tratamento de erros:

try {
    $pdo = new PDO("mysql:host=localhost;dbname=banco;charset=utf8mb4", "usuario", "senha");
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
    die("Erro de conexão: " . $e->getMessage());
}

Placeholders nomeados vs posicionais:

// Placeholders nomeados (:nome)
$stmt = $pdo->prepare("INSERT INTO usuarios (nome, email, idade) VALUES (:nome, :email, :idade)");
$stmt->execute([
    ':nome' => 'João',
    ':email' => 'joao@email.com',
    ':idade' => 35
]);

// Placeholders posicionais (?)
$stmt = $pdo->prepare("INSERT INTO usuarios (nome, email, idade) VALUES (?, ?, ?)");
$stmt->execute(['Maria', 'maria@email.com', 28]);

bindParam() vs bindValue() vs array na execução:

// bindParam() - vincula por referência
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE idade > ?");
$stmt->bindParam(1, $idadeMinima, PDO::PARAM_INT);
$idadeMinima = 18;
$stmt->execute(); // Usa o valor atual de $idadeMinima

// bindValue() - vincula por valor
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE idade > ?");
$stmt->bindValue(1, 18, PDO::PARAM_INT);
// Mesmo que $idadeMinima mude depois, a consulta usa 18

// Array na execução (mais conciso)
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE idade > ? AND ativo = ?");
$stmt->execute([18, 1]);

5. Prevenção Avançada: Além do Básico

Cuidados com LIKE: Prepared statements protegem contra injeção, mas caracteres curinga ainda funcionam.

// Perigoso: usuário pode enviar % para buscar tudo
$busca = "%"; // Isso retornaria todos os registros
$stmt = $pdo->prepare("SELECT * FROM produtos WHERE nome LIKE ?");
$stmt->execute(["%$busca%"]);

// Correto: escapar caracteres curinga
$busca = "%";
$buscaSegura = addcslashes($busca, '%_');
$stmt = $pdo->prepare("SELECT * FROM produtos WHERE nome LIKE ?");
$stmt->execute(["%$buscaSegura%"]);

ORDER BY e nomes de colunas: Prepared statements não podem ser usados para nomes de colunas ou tabelas. Use whitelist:

function orderBySeguro($campo, $direcao = 'ASC') {
    $camposPermitidos = ['nome', 'preco', 'data_criacao'];
    $direcoesPermitidas = ['ASC', 'DESC'];

    if (!in_array($campo, $camposPermitidos)) {
        $campo = 'nome'; // valor padrão seguro
    }
    if (!in_array(strtoupper($direcao), $direcoesPermitidas)) {
        $direcao = 'ASC';
    }

    return "ORDER BY $campo $direcao";
}

$sql = "SELECT * FROM produtos " . orderBySeguro($_GET['ordenar'], $_GET['dir']);
$stmt = $pdo->query($sql);

6. Mitos e Erros Comuns

Mito 1: mysqli_real_escape_string() substitui prepared statements

// AINDA é vulnerável em alguns casos
$nome = mysqli_real_escape_string($conexao, $_POST['nome']);
$sql = "SELECT * FROM usuarios WHERE nome = '$nome'"; // Inseguro!

// Prepared statement é SEMPRE superior
$stmt = $conexao->prepare("SELECT * FROM usuarios WHERE nome = ?");
$stmt->bind_param("s", $_POST['nome']);
$stmt->execute();

Mito 2: Prepared statements são apenas para consultas em cache

Prepared statements são sobre segurança, não performance. Embora alguns bancos armazenem em cache, o principal benefício é a prevenção de injeção.

Erro comum: Concatenar mesmo com prepared statements

// ERRADO: anula a proteção
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE email = '" . $email . "'");
$stmt->execute();

// CORRETO
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE email = ?");
$stmt->execute([$email]);

7. Boas Práticas Integradas

Validação de dados no PHP + prepared statements:

function cadastrarUsuario($dados) {
    // Validação no PHP
    if (!filter_var($dados['email'], FILTER_VALIDATE_EMAIL)) {
        throw new InvalidArgumentException("Email inválido");
    }
    if (strlen($dados['senha']) < 8) {
        throw new InvalidArgumentException("Senha muito curta");
    }

    // Prepared statement para inserção segura
    $stmt = $pdo->prepare("INSERT INTO usuarios (email, senha_hash) VALUES (?, ?)");
    $stmt->execute([
        $dados['email'],
        password_hash($dados['senha'], PASSWORD_BCRYPT)
    ]);
}

Uso de transações com PDO:

try {
    $pdo->beginTransaction();

    $stmt = $pdo->prepare("INSERT INTO pedidos (usuario_id, total) VALUES (?, ?)");
    $stmt->execute([$usuarioId, $total]);
    $pedidoId = $pdo->lastInsertId();

    $stmt = $pdo->prepare("INSERT INTO itens_pedido (pedido_id, produto_id, quantidade) VALUES (?, ?, ?)");
    foreach ($itens as $item) {
        $stmt->execute([$pedidoId, $item['produto_id'], $item['quantidade']]);
    }

    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
    error_log("Erro no pedido: " . $e->getMessage());
    throw $e;
}

Logging de tentativas suspeitas:

function detectarTentativaInjecao($entrada) {
    $padroesSuspeitos = ['SELECT.*FROM', 'DROP\s+TABLE', 'UNION\s+SELECT', '--', ';\s*DROP'];
    foreach ($padroesSuspeitos as $padrao) {
        if (preg_match("/$padrao/i", $entrada)) {
            error_log("Tentativa de SQL Injection detectada: " . $_SERVER['REMOTE_ADDR']);
            return true;
        }
    }
    return false;
}

O uso consistente de prepared statements combinado com validação adequada, transações e monitoramento forma uma defesa robusta contra SQL Injection. Lembre-se: a segurança nunca deve ser uma reflexão tardia no desenvolvimento PHP.

Referências