O artigo Segurança em Formulários cobriu os vetores mais comuns — XSS, CSRF e SQL Injection. Segurança avançada vai além: envolve criptografia correta, proteção de dados sensíveis, headers HTTP defensivos, auditoria de dependências, e a mentalidade de tratar segurança não como checklist, mas como camada contínua do processo de desenvolvimento.
Um sistema seguro não é aquele que nunca foi atacado. É aquele que, quando atacado, limita o dano ao mínimo possível.
Criptografia simétrica com OpenSSL
Para criptografar dados sensíveis no banco (números de cartão, documentos, dados médicos), use criptografia simétrica com AES-256-GCM — o modo mais seguro disponível:
<?php
declare(strict_types=1);
final class Criptografia
{
private const ALGORITMO = 'aes-256-gcm';
private const TAG_LENGTH = 16;
public function __construct(
private readonly string $chave // 32 bytes — gerado com random_bytes(32)
) {
if (strlen($this->chave) !== 32) {
throw new \InvalidArgumentException('Chave deve ter exatamente 32 bytes.');
}
}
public function criptografar(string $texto): string
{
$iv = random_bytes(openssl_cipher_iv_length(self::ALGORITMO));
$tag = '';
$cifrado = openssl_encrypt(
data: $texto,
cipher_algo: self::ALGORITMO,
passphrase: $this->chave,
options: OPENSSL_RAW_DATA,
iv: $iv,
tag: $tag,
tag_length: self::TAG_LENGTH,
);
if ($cifrado === false) {
throw new \RuntimeException('Falha na criptografia.');
}
// Empacota: iv + tag + cifrado, tudo em base64
return base64_encode($iv . $tag . $cifrado);
}
public function descriptografar(string $pacote): string
{
$dados = base64_decode($pacote, strict: true);
if ($dados === false) {
throw new \RuntimeException('Pacote inválido.');
}
$ivLen = openssl_cipher_iv_length(self::ALGORITMO);
$iv = substr($dados, 0, $ivLen);
$tag = substr($dados, $ivLen, self::TAG_LENGTH);
$cifrado = substr($dados, $ivLen + self::TAG_LENGTH);
$texto = openssl_decrypt(
data: $cifrado,
cipher_algo: self::ALGORITMO,
passphrase: $this->chave,
options: OPENSSL_RAW_DATA,
iv: $iv,
tag: $tag,
);
if ($texto === false) {
throw new \RuntimeException('Falha na descriptografia — dados corrompidos ou chave incorreta.');
}
return $texto;
}
}
// Uso:
$chave = base64_decode($_ENV['ENCRYPTION_KEY']); // 32 bytes armazenados em base64 no .env
$cripto = new Criptografia($chave);
$cifrado = $cripto->criptografar('4111111111111111'); // número de cartão
$descriptografado = $cripto->descriptografar($cifrado);
Gerar e armazenar a chave com segurança:
# Gera uma chave de 32 bytes em base64 — armazene no .env, nunca no código
php -r "echo base64_encode(random_bytes(32)) . PHP_EOL;"
Criptografia assimétrica para dados entre sistemas
Quando dois sistemas precisam trocar dados sensíveis, use RSA ou Ed25519:
<?php
// Gerar par de chaves
$config = [
'private_key_type' => OPENSSL_KEYTYPE_RSA,
'private_key_bits' => 4096,
'digest_alg' => 'sha256',
];
$recurso = openssl_pkey_new($config);
$chavePriv = '';
openssl_pkey_export($recurso, $chavePriv);
$detalhes = openssl_pkey_get_details($recurso);
$chavePub = $detalhes['key'];
// Criptografar com chave pública (apenas quem tem a privada pode
// descriptografar). DUAS coisas que o exemplo de manual costuma omitir:
//
// 1. O padding PADRÃO é OPENSSL_PKCS1_PADDING, o esquema antigo do
// PKCS#1 v1.5, alvo do ataque de Bleichenbacher. Para dado novo, o
// certo é OAEP — e ele precisa ser pedido nos DOIS lados.
// 2. Em caso de falha a função devolve false e NÃO toca na variável de
// saída. Se ela já tinha valor de uma chamada anterior, o
// `base64_encode()` publica o CIFRADO ANTIGO como se fosse o novo.
// Medido: o segredo anterior sai inteiro, e sem nenhum aviso.
//
// RSA também tem teto de tamanho: numa chave de 4096 bits (512 bytes)
// cabem 501 bytes com PKCS#1 v1.5 e 470 com OAEP. Payload maior que isso
// não é caso de aumentar a chave — é caso de cifrar os dados com AES
// e usar o RSA só para transportar a chave AES (envelope digital).
if (!openssl_public_encrypt('dados sensíveis', $cifrado, $chavePub, OPENSSL_PKCS1_OAEP_PADDING)) {
throw new \RuntimeException('Falha ao criptografar: ' . openssl_error_string());
}
$cifradoB64 = base64_encode($cifrado);
// Descriptografar com chave privada — mesmo padding, obrigatoriamente
if (!openssl_private_decrypt(base64_decode($cifradoB64), $texto, $chavePriv, OPENSSL_PKCS1_OAEP_PADDING)) {
throw new \RuntimeException('Falha ao descriptografar: ' . openssl_error_string());
}
// Assinar com chave privada (prova de autoria)
openssl_sign('mensagem', $assinatura, $chavePriv, OPENSSL_ALGO_SHA256);
// Verificar assinatura com chave pública
$valido = openssl_verify('mensagem', $assinatura, $chavePub, OPENSSL_ALGO_SHA256) === 1;
Proteção de dados pessoais — LGPD na prática
A Lei Geral de Proteção de Dados exige que dados pessoais sejam coletados minimamente, protegidos adequadamente, e que usuários possam solicitar exclusão:
<?php
declare(strict_types=1);
class RepositorioDeUsuario
{
// Criptografa dados sensíveis antes de salvar
public function salvar(DadosDeUsuario $dados, Criptografia $cripto): int
{
$stmt = $this->pdo->prepare("
INSERT INTO usuarios (nome, email, cpf_criptografado, telefone_criptografado)
VALUES (?, ?, ?, ?)
");
$stmt->execute([
$dados->nome,
$dados->email,
$cripto->criptografar($dados->cpf), // PII criptografado em repouso
$cripto->criptografar($dados->telefone),
]);
return (int) $this->pdo->lastInsertId();
}
// Direito ao esquecimento — anonimização
public function anonimizar(int $usuarioId): void
{
$this->pdo->prepare("
UPDATE usuarios SET
nome = 'Usuário Removido',
email = CONCAT('removido_', id, '@anonimo.local'),
cpf_criptografado = NULL,
telefone_criptografado = NULL,
anonimizado_em = NOW()
WHERE id = ?
")->execute([$usuarioId]);
}
// Log de acesso a dados pessoais (trilha de auditoria)
public function registrarAcesso(int $usuarioId, int $acessadoPor, string $motivo): void
{
$this->pdo->prepare("
INSERT INTO log_acesso_pii (usuario_id, acessado_por, motivo, ip, criado_em)
VALUES (?, ?, ?, ?, NOW())
")->execute([$usuarioId, $acessadoPor, $motivo, $_SERVER['REMOTE_ADDR'] ?? '']);
}
}
Headers HTTP de segurança — configuração completa
<?php
declare(strict_types=1);
final class HeadersDeSeguranca
{
public static function aplicar(): void
{
// Impede MIME sniffing — o browser respeita o Content-Type declarado
header('X-Content-Type-Options: nosniff');
// Impede clickjacking — a página não pode ser carregada em iframe de outro domínio
header('X-Frame-Options: DENY');
// Força HTTPS por 1 ano (incluindo subdomínios)
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
// Controla informações enviadas no Referer
header('Referrer-Policy: strict-origin-when-cross-origin');
// Desativa recursos do browser que a aplicação não usa
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
// Content Security Policy — política granular de fontes permitidas
$csp = implode('; ', [
"default-src 'self'",
"script-src 'self' https://cdn.jsdelivr.net",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]);
header("Content-Security-Policy: {$csp}");
}
}
No Laravel, centralize isso em um middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HeadersDeSeguranca
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
return $response;
}
}
Auditoria de dependências
Dependências desatualizadas são um vetor frequente de ataque. Automatize a verificação:
# Verifica vulnerabilidades conhecidas nas dependências instaladas
composer audit
# Saída de exemplo:
# Found 1 security vulnerability advisory affecting 1 package:
# Package: vendor/pacote
# CVE: CVE-2024-XXXX
# Title: Remote Code Execution via unsanitized input
# URL: https://github.com/...
# Atualiza para versões seguras
composer update vendor/pacote
# Verificar dependências desatualizadas
composer outdated
No CI/CD, adicione composer audit como etapa obrigatória — o build falha se houver vulnerabilidades:
# .github/workflows/security.yml
- name: Auditoria de segurança
run: composer audit --no-dev
Timing attacks e comparações seguras
<?php
// VULNERÁVEL a timing attack — a comparação para no primeiro caractere diferente
if ($tokenEnviado === $tokenArmazenado) { }
// CORRETO — hash_equals tem tempo constante independente de onde diverge
if (!hash_equals($tokenArmazenado, $tokenEnviado)) {
throw new \RuntimeException('Token inválido.');
}
// O mesmo princípio se aplica a qualquer comparação de segredo:
// API keys, tokens de webhook, assinaturas HMAC
$assinatura = hash_hmac('sha256', $payload, $_ENV['WEBHOOK_SECRET']);
$assinaturaOk = hash_equals($assinatura, $assinaturaRecebida);
Proteção contra enumeração de usuários
Um erro clássico é dar respostas diferentes para "email não existe" e "senha incorreta" — isso permite que atacantes descubram quais emails estão cadastrados:
<?php
// RUIM — revela se o email existe
public function login(string $email, string $senha): bool
{
$usuario = $this->repositorio->buscarPorEmail($email);
if (!$usuario) {
throw new \Exception('Email não cadastrado.'); // vaza informação
}
if (!password_verify($senha, $usuario->senhaHash)) {
throw new \Exception('Senha incorreta.'); // também vaza
}
return true;
}
// BOM — mesma mensagem para ambos os casos
public function login(string $email, string $senha): bool
{
$usuario = $this->repositorio->buscarPorEmail($email);
// Executa password_verify mesmo quando o usuário não existe, para
// manter o tempo de resposta constante.
//
// O detalhe que decide se isso funciona: o hash descartável precisa
// ser BEM FORMADO e ter o MESMO custo dos hashes reais. Um literal
// como '$argon2id$v=19$m=65536,t=4,p=1$...' PARECE um hash e não é:
// sem salt e sem digest válidos, o password_verify() rejeita o
// formato e volta na hora, sem derivar nada. Medido em PHP 8.5 com
// Argon2id de custo padrão: 0,001 ms contra 160 ms do hash de
// verdade — ou seja, a defesa escrita assim ENTREGA de bandeja a
// informação que ela promete esconder, com margem de 160 mil vezes.
//
// Gere um hash descartável de verdade UMA vez, com os mesmos
// parâmetros do cadastro, e guarde em configuração:
//
// php -r "echo password_hash(bin2hex(random_bytes(32)), PASSWORD_ARGON2ID);"
//
$hashDescartavel = $_ENV['HASH_DESCARTAVEL'];
$hashVerif = $usuario?->senhaHash ?? $hashDescartavel;
if (!$usuario || !password_verify($senha, $hashVerif)) {
throw new \RuntimeException('Credenciais inválidas.'); // mensagem genérica
}
return true;
}
O que atravessa todas as técnicas deste artigo é um padrão desconfortável: a defesa que parece estar lá e não está. O password_verify contra um hash malformado tem a forma exata da proteção contra enumeração e entrega a informação em um milésimo do tempo. O openssl_public_encrypt sem checagem de retorno tem a forma exata de uma cifragem e pode publicar o segredo anterior. O header de CSP tem a forma exata de uma política e, com um 'unsafe-inline' esquecido, autoriza justamente o que veio bloquear. Nenhum desses casos produz erro, log ou teste vermelho — é código que passa em revisão porque a intenção está escrita nele.
Daí a única prática que sustenta as outras: medir a defesa, não inspecioná-la. Cronometrar as duas respostas do login e comparar os números. Adulterar um byte do pacote cifrado e confirmar que a exceção vem. Abrir o navegador com o console aberto e ver a CSP recusando o script que deveria recusar. Rodar composer audit no CI, onde ele falha o build, e não na máquina de quem lembrar. Segurança avançada em PHP tem menos a ver com conhecer a função certa — quase todas estão na biblioteca padrão, prontas — e mais com a disciplina de não acreditar que ela está fazendo o que o nome promete até vê-la fazendo.
Fontes e leituras recomendadas
- OWASP Cryptographic Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
- PHP Manual — OpenSSL: https://www.php.net/manual/pt_BR/book.openssl.php
- LGPD — Lei 13.709/2018: https://www.planalto.gov.br/ccivil_03/_ato2015-2018/2018/lei/l13709.htm
- Security Headers: https://securityheaders.com/
- PHP Manual —
password_hashe oPASSWORD_DEFAULT, que muda entre versoes: https://www.php.net/manual/pt_BR/function.password-hash.php - OWASP Top 10 — as dez classes de falha mais comuns em aplicacao web: https://owasp.org/www-project-top-ten/
Exercícios
Exercício 1
Implemente a classe Criptografia com AES-256-GCM. Criptografe CPF e telefone de usuários no banco. Garanta que a descriptografia funciona e que dados corrompidos lançam exceção.
Ver resposta
✓ Resposta: Duas escolhas carregam o resto. GCM, não CBC: o GCM autentica, então dado adulterado no banco falha ao decifrar em vez de devolver lixo silencioso. E o IV novo a cada gravação — reutilizar IV no GCM permite recuperar a chave de autenticação, então jamais o derive de um id. O efeito colateral do IV aleatório é que WHERE cpf = ? deixa de funcionar; daí a coluna de hash HMAC indexada (HMAC, não sha256 puro — o espaço de CPFs cabe numa rainbow table).
<?php
// ---------------------------------------- app/Seguranca/FalhaDeCriptografia.php
declare(strict_types=1);
namespace App\Seguranca;
use RuntimeException;
final class FalhaDeCriptografia extends RuntimeException {}
// ---------------------------------------- app/Seguranca/Criptografia.php
declare(strict_types=1);
namespace App\Seguranca;
use SensitiveParameter;
/**
* AES-256-GCM: cifra E autentica. A diferença para o CBC não é acadêmica —
* no CBC, quem tem acesso ao banco consegue alterar o ciphertext e o
* decrypt aceita o resultado adulterado. No GCM, a tag de autenticação faz
* a descriptografia falhar.
*/
final class Criptografia
{
private const ALGORITMO = 'aes-256-gcm';
private const TAMANHO_IV = 12; // 96 bits: o recomendado para GCM
private const TAMANHO_TAG = 16; // 128 bits
public function __construct(
#[SensitiveParameter]
private readonly string $chave,
) {
// A chave tem de ter 32 BYTES. Uma senha digitada não serve:
// derive com hash('sha256', ..., binary: true) ou gere com
// random_bytes(32) e guarde em base64 no ambiente.
if (strlen($this->chave) !== 32) {
throw new FalhaDeCriptografia(
'A chave precisa ter exatamente 32 bytes (256 bits).'
);
}
}
public static function daVariavelDeAmbiente(string $nome = 'CRYPTO_KEY'): self
{
$base64 = getenv($nome);
if ($base64 === false || $base64 === '') {
throw new FalhaDeCriptografia("Variável {$nome} não definida.");
}
$chave = base64_decode($base64, strict: true);
if ($chave === false) {
throw new FalhaDeCriptografia("O valor de {$nome} não é base64 válido.");
}
return new self($chave);
}
/**
* O AAD (dados autenticados adicionais) amarra o ciphertext ao seu
* contexto: passando "usuarios:42:cpf", o valor cifrado do usuário 42
* não pode ser copiado para a linha do usuário 7 — a autenticação
* falha, mesmo com a chave certa.
*/
public function cifrar(
#[SensitiveParameter] string $texto,
string $contexto = '',
): string {
// IV NOVO a cada operação. Reutilizar IV no GCM é catastrófico:
// com dois textos sob o mesmo IV, dá para recuperar a chave de
// autenticação. Nunca derive o IV de um id.
$iv = random_bytes(self::TAMANHO_IV);
$tag = '';
$cifrado = openssl_encrypt(
$texto,
self::ALGORITMO,
$this->chave,
OPENSSL_RAW_DATA,
$iv,
$tag,
$contexto,
self::TAMANHO_TAG,
);
if ($cifrado === false) {
throw new FalhaDeCriptografia('Falha ao cifrar: ' . openssl_error_string());
}
// Formato: v1.base64(iv + tag + ciphertext). O prefixo de versão é
// o que permitirá trocar de algoritmo daqui a três anos sem
// precisar adivinhar como cada linha antiga foi gravada.
return 'v1.' . base64_encode($iv . $tag . $cifrado);
}
public function decifrar(string $armazenado, string $contexto = ''): string
{
if (!str_starts_with($armazenado, 'v1.')) {
throw new FalhaDeCriptografia('Formato desconhecido ou dado corrompido.');
}
$bruto = base64_decode(substr($armazenado, 3), strict: true);
if ($bruto === false || strlen($bruto) <= self::TAMANHO_IV + self::TAMANHO_TAG) {
throw new FalhaDeCriptografia('Dado corrompido: tamanho inválido.');
}
$iv = substr($bruto, 0, self::TAMANHO_IV);
$tag = substr($bruto, self::TAMANHO_IV, self::TAMANHO_TAG);
$texto = substr($bruto, self::TAMANHO_IV + self::TAMANHO_TAG);
$decifrado = openssl_decrypt(
$texto,
self::ALGORITMO,
$this->chave,
OPENSSL_RAW_DATA,
$iv,
$tag,
$contexto,
);
// false aqui significa uma de três coisas: chave errada, dado
// adulterado ou contexto diferente. Nos três casos, o certo é
// explodir — devolver null convidaria o código chamador a seguir
// com um valor vazio como se nada tivesse acontecido.
if ($decifrado === false) {
throw new FalhaDeCriptografia(
'Falha na autenticação: dado corrompido, adulterado ou chave incorreta.'
);
}
return $decifrado;
}
/**
* Hash determinístico para BUSCA. O ciphertext muda a cada gravação
* (IV aleatório), então `WHERE cpf = ?` nunca acharia nada. Guarda-se
* também este hash, numa coluna indexada.
*/
public function hashDeBusca(#[SensitiveParameter] string $valor): string
{
// HMAC com a mesma chave — não sha256 puro: o espaço de CPFs é
// pequeno o bastante para uma rainbow table completa.
return hash_hmac('sha256', $valor, $this->chave);
}
}
// ---------------------------------------- app/Casts/Cifrado.php
declare(strict_types=1);
namespace App\Casts;
use App\Seguranca\Criptografia;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/**
* Cast do Eloquent: o model lê e escreve texto claro, o banco guarda
* ciphertext. Nenhum controller precisa saber que existe criptografia.
*
* @implements CastsAttributes<string|null, string|null>
*/
class Cifrado implements CastsAttributes
{
public function get(Model $model, string $chave, mixed $valor, array $atributos): ?string
{
if ($valor === null || $valor === '') {
return null;
}
return app(Criptografia::class)->decifrar(
$valor,
// O contexto amarra o valor à linha: ciphertext copiado de
// outra linha não decifra.
contexto: $model->getTable() . ':' . $model->getKey() . ':' . $chave,
);
}
public function set(Model $model, string $chave, mixed $valor, array $atributos): array
{
if ($valor === null || $valor === '') {
return [$chave => null];
}
$cripto = app(Criptografia::class);
return [
$chave => $cripto->cifrar(
(string) $valor,
contexto: $model->getTable() . ':' . $model->getKey() . ':' . $chave,
),
// A coluna de busca é gravada junto, no mesmo set.
$chave . '_hash' => $cripto->hashDeBusca((string) $valor),
];
}
}
// ---------------------------------------- app/Models/Usuario.php (trecho)
declare(strict_types=1);
namespace App\Models;
use App\Casts\Cifrado;
use App\Seguranca\Criptografia;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\User as Authenticatable;
class Usuario extends Authenticatable
{
protected $fillable = ['nome', 'email', 'senha', 'cpf', 'telefone'];
protected $hidden = ['senha', 'cpf', 'cpf_hash', 'telefone'];
protected function casts(): array
{
return [
'senha' => 'hashed',
'cpf' => Cifrado::class,
'telefone' => Cifrado::class,
];
}
/** Busca pelo hash determinístico, não pelo ciphertext. */
public function scopePorCpf(Builder $consulta, string $cpf): Builder
{
$digitos = preg_replace('/\D/', '', $cpf) ?? '';
return $consulta->where('cpf_hash', app(Criptografia::class)->hashDeBusca($digitos));
}
}
// ------------------- database/migrations/..._cifrar_dados_pessoais.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('usuarios', function (Blueprint $tabela): void {
// text, não string(14): o ciphertext em base64 é bem maior que
// o CPF original.
$tabela->text('cpf')->nullable()->change();
$tabela->text('telefone')->nullable()->change();
// char(64) fixo: saída do sha256 em hexadecimal.
$tabela->char('cpf_hash', 64)->nullable()->index();
});
}
public function down(): void
{
Schema::table('usuarios', function (Blueprint $tabela): void {
$tabela->dropColumn('cpf_hash');
});
}
};
// ---------------------------------------- tests/Unit/CriptografiaTest.php
declare(strict_types=1);
namespace Tests\Unit;
use App\Seguranca\Criptografia;
use App\Seguranca\FalhaDeCriptografia;
use PHPUnit\Framework\TestCase;
class CriptografiaTest extends TestCase
{
private function cripto(): Criptografia
{
return new Criptografia(str_repeat('k', 32));
}
public function test_ida_e_volta(): void
{
$cripto = $this->cripto();
$this->assertSame('529.982.247-25', $cripto->decifrar($cripto->cifrar('529.982.247-25')));
}
public function test_o_mesmo_texto_gera_ciphertexts_diferentes(): void
{
$cripto = $this->cripto();
// IV aleatório: sem isso, dois usuários com o mesmo CPF teriam o
// mesmo valor no banco — e o "cifrado" viraria um identificador.
$this->assertNotSame($cripto->cifrar('12345678900'), $cripto->cifrar('12345678900'));
}
public function test_dado_adulterado_lanca_excecao(): void
{
$cripto = $this->cripto();
$cifrado = $cripto->cifrar('12345678900');
// Vira um byte do meio do ciphertext.
$bruto = base64_decode(substr($cifrado, 3), true);
$bruto[20] = $bruto[20] === 'A' ? 'B' : 'A';
$this->expectException(FalhaDeCriptografia::class);
$cripto->decifrar('v1.' . base64_encode($bruto));
}
public function test_chave_errada_lanca_excecao(): void
{
$cifrado = $this->cripto()->cifrar('12345678900');
$this->expectException(FalhaDeCriptografia::class);
(new Criptografia(str_repeat('x', 32)))->decifrar($cifrado);
}
public function test_contexto_diferente_nao_decifra(): void
{
$cripto = $this->cripto();
$cifrado = $cripto->cifrar('12345678900', contexto: 'usuarios:42:cpf');
$this->expectException(FalhaDeCriptografia::class);
// O valor do usuário 42 copiado para a linha do 7 não abre.
$cripto->decifrar($cifrado, contexto: 'usuarios:7:cpf');
}
public function test_hash_de_busca_e_deterministico(): void
{
$cripto = $this->cripto();
$this->assertSame(
$cripto->hashDeBusca('12345678900'),
$cripto->hashDeBusca('12345678900'),
);
}
public function test_chave_de_tamanho_errado_e_recusada(): void
{
$this->expectException(FalhaDeCriptografia::class);
new Criptografia('curta');
}
}
Exercício 2
Adicione o middleware de headers de segurança ao Laravel. Valide os headers com a ferramenta https://securityheaders.com/ (use ngrok ou similar para expor localmente).
Ver resposta
✓ Resposta: O nonce é o que permite uma CSP de verdade: sem ele, o primeiro <script> inline força alguém a acrescentar 'unsafe-inline' — e aí a política vira enfeite, porque é exatamente script inline que o XSS injeta. Daí o teste que proíbe a string. Dois cuidados de ambiente: HSTS só em produção (ligado em localhost, tranca o navegador do time por dois anos) e, para adotar CSP em site legado, Content-Security-Policy-Report-Only, que relata sem bloquear.
<?php
// ---------------------------------------- app/Http/Middleware/HeadersDeSeguranca.php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class HeadersDeSeguranca
{
public function handle(Request $request, Closure $proximo): Response
{
// O nonce precisa existir ANTES da view renderizar — é ele que
// autoriza cada <script> inline individualmente, dispensando o
// 'unsafe-inline' que anula metade da CSP.
$nonce = Str::random(24);
$request->attributes->set('csp_nonce', $nonce);
$resposta = $proximo($request);
foreach ($this->headers($nonce) as $nome => $valor) {
// headers()->set() sobrescreve; o ->has() evita atropelar
// header que uma rota tenha definido de propósito.
if (!$resposta->headers->has($nome)) {
$resposta->headers->set($nome, $valor);
}
}
// O Laravel anuncia a si mesmo por padrão. Não é vulnerabilidade,
// mas é informação grátis para quem procura alvo por versão.
$resposta->headers->remove('X-Powered-By');
return $resposta;
}
/** @return array<string, string> */
private function headers(string $nonce): array
{
$csp = [
"default-src 'self'",
// 'strict-dynamic' faz o navegador confiar no que o script com
// nonce carregar — é o que permite Vite e bundlers modernos
// sem liberar tudo.
"script-src 'self' 'nonce-{$nonce}' 'strict-dynamic'",
"style-src 'self' 'nonce-{$nonce}'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
];
$headers = [
'Content-Security-Policy' => implode('; ', $csp),
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'DENY',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=(), payment=()',
'Cross-Origin-Opener-Policy' => 'same-origin',
'Cross-Origin-Resource-Policy' => 'same-origin',
];
if (app()->environment('production')) {
// HSTS só em produção e só sob HTTPS. Ligado em ambiente local,
// ele tranca o navegador de todo o time em https://localhost —
// e o efeito dura os dois anos do max-age.
$headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains; preload';
}
return $headers;
}
}
// ---------------------------------------- bootstrap/app.php (trecho)
declare(strict_types=1);
use App\Http\Middleware\HeadersDeSeguranca;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
// append: roda por último no caminho de entrada, portanto é o
// primeiro a tocar a resposta na volta — carimba TODA rota,
// inclusive as páginas de erro.
$middleware->append(HeadersDeSeguranca::class);
// Confie no proxy só se ele for seu: TrustProxies mal configurado
// deixa o cliente forjar o próprio IP pelo X-Forwarded-For, e o
// rate limit por IP deixa de valer.
$middleware->trustProxies(at: [
'10.0.0.0/8',
'172.16.0.0/12',
]);
})
->create();
// ---------------------------------------- app/Providers/AppServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// @nonce nas views: <script @nonce>...</script>
Blade::directive('nonce', function (): string {
return "<?php echo 'nonce=\"' . e(request()->attributes->get('csp_nonce')) . '\"'; ?>";
});
}
}
// ---------------------------------------- tests/Feature/HeadersDeSegurancaTest.php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\TestCase;
class HeadersDeSegurancaTest extends TestCase
{
public function test_headers_essenciais_estao_presentes(): void
{
$resposta = $this->get('/');
foreach ([
'Content-Security-Policy',
'X-Content-Type-Options',
'X-Frame-Options',
'Referrer-Policy',
'Permissions-Policy',
] as $header) {
$resposta->assertHeader($header);
}
}
public function test_a_csp_nao_libera_unsafe_inline(): void
{
// O teste que impede a regressão mais comum: alguém acrescenta
// 'unsafe-inline' para "fazer o script funcionar" e a CSP vira
// enfeite.
$csp = $this->get('/')->headers->get('Content-Security-Policy');
$this->assertStringNotContainsString("'unsafe-inline'", $csp);
$this->assertStringNotContainsString("'unsafe-eval'", $csp);
$this->assertStringContainsString("frame-ancestors 'none'", $csp);
}
public function test_hsts_nao_vaza_para_ambiente_local(): void
{
$this->get('/')->assertHeaderMissing('Strict-Transport-Security');
}
public function test_ate_a_pagina_de_erro_e_carimbada(): void
{
// Página de erro é justamente onde mais se testa injeção.
$this->get('/rota-que-nao-existe')
->assertNotFound()
->assertHeader('X-Content-Type-Options', 'nosniff');
}
}
// ============ validando de fora ============
//
// Localmente o securityheaders.com não alcança. Exponha com um túnel:
//
// ngrok http 8000
// # ou, sem instalar nada além do Cloudflare:
// cloudflared tunnel --url http://localhost:8000
//
// Cole a URL pública em https://securityheaders.com/ — a nota A+ exige
// HSTS, que só sai em produção, então localmente espere um A.
//
// Sem túnel, o mesmo diagnóstico pelo terminal:
// curl -sI http://localhost:8000 | grep -iE 'content-security|x-frame|x-content|referrer|permissions'
//
// E a CSP tem um modo de ensaio: trocar o header por
// Content-Security-Policy-Report-Only aplica NADA, só relata violações.
// É como se implanta CSP em site legado sem quebrar a produção no deploy.
Exercício 3
Configure composer audit no pipeline de CI. Simule uma vulnerabilidade instalando uma versão antiga de um pacote e confirme que o build falha.
Ver resposta
✓ Resposta: O --locked é o detalhe que faz a auditoria caber na CI: ele lê o composer.lock sem instalar nada, rodando em segundos. E o agendamento semanal importa mais que o gatilho por push — a CVE não é publicada quando você faz commit, e um projeto parado há três meses precisa descobri-la mesmo assim. Quando o patch ainda não existe, ignore com data e justificativa versionadas: lista de ignorados sem prazo vira cemitério que ninguém revisa.
# ============ o comando, no terminal ============
#
# composer audit # tabela legível
# composer audit --format=json # para processar
# composer audit --locked # audita o composer.lock, não o vendor/
# composer audit --no-dev # só o que vai para produção
#
# `--locked` é o que se usa em CI: audita o que ESTÁ TRAVADO no lock, sem
# precisar instalar nada. Roda em segundos e não depende do vendor/.
#
# Saída típica quando há problema:
#
# Found 2 security vulnerability advisories affecting 1 package:
# +-------------------+----------------------------------------------------+
# | Package | guzzlehttp/guzzle |
# | CVE | CVE-2022-31090 |
# | Title | Cookie headers on requests to a different host |
# | Reported at | 2022-06-27 |
# | Affected versions | >=7.0.0,<7.4.5 |
# +-------------------+----------------------------------------------------+
#
# Código de saída 1 quando encontra algo — é o que faz o job da CI falhar.
# ============ .github/workflows/seguranca.yml ============
name: Segurança
on:
push:
branches: [main]
pull_request:
schedule:
# Toda segunda às 6h. Este é o ponto que a maioria esquece: a
# vulnerabilidade não é publicada quando você faz commit. Um projeto
# sem deploy há três meses precisa descobrir a CVE mesmo assim.
- cron: '0 6 * * 1'
permissions:
contents: read
jobs:
auditoria:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
# Sem `composer install`: --locked lê o composer.lock direto.
- name: Auditar dependências de produção
run: composer audit --locked --no-dev --format=table
# As de desenvolvimento entram num passo separado que NÃO derruba o
# build: uma CVE no PHPUnit não chega ao usuário final, mas você quer
# saber dela.
- name: Auditar dependências de desenvolvimento
continue-on-error: true
run: composer audit --locked --format=table
# Confere que o lock corresponde ao composer.json — o "funciona na
# minha máquina" clássico é lock desatualizado.
- name: Validar composer.json e lock
run: composer validate --strict --no-check-publish
- name: Verificar dependências abandonadas
continue-on-error: true
run: |
composer show --direct --outdated --format=json > outdated.json || true
echo "Pacotes desatualizados:"
cat outdated.json
# ---- Dependabot faz o outro lado do trabalho: abre o PR da correção ----
# .github/dependabot.yml
#
# version: 2
# updates:
# - package-ecosystem: composer
# directory: "/"
# schedule: { interval: weekly }
# open-pull-requests-limit: 5
# groups:
# # Agrupa as atualizações de patch num PR só — 40 PRs por semana
# # é a forma mais rápida de ninguém mais olhar para nenhum.
# patches:
# update-types: ["patch"]
# ============ simulando uma vulnerabilidade (o exercício) ============
#
# 1. Instale uma versão sabidamente vulnerável, ignorando as travas:
#
# composer require guzzlehttp/guzzle:7.4.0 \
# --with-all-dependencies --ignore-platform-reqs
#
# 2. Rode a auditoria:
#
# composer audit --locked
# echo "código de saída: $?" # → 1
#
# 3. Empurre para um branch e veja o job ficar vermelho.
#
# 4. Corrija e confirme o verde:
#
# composer require guzzlehttp/guzzle:^7.9
# composer audit --locked # → No security vulnerability advisories found
#
# ============ quando a correção não existe ainda ============
#
# Às vezes a CVE é publicada e o mantenedor ainda não lançou o patch.
# Bloquear o deploy do time inteiro por dias não é opção — mas ignorar
# em silêncio, muito menos. O acordo é ignorar COM PRAZO:
#
# composer.json:
# "extra": {
# "audit": {
# "ignore": {
# "CVE-2024-XXXXX": "Sem correção até 2026-09-30. Reavaliar nessa data. Ticket SEC-412."
# }
# }
# }
#
# A justificativa e a data ficam versionadas. Sem elas, a lista de
# ignorados vira um cemitério que ninguém mais revisa.
Exercício 4
Refatore o sistema de login do artigo 35 para usar mensagem genérica de erro. Implemente o hash falso para garantir tempo de resposta constante.
Ver resposta
✓ Resposta: Corrigir só a mensagem resolve metade do problema. O outro canal é o relógio: sem o hash falso, o caminho "usuário não existe" retorna sem chamar o bcrypt e responde em ~5 ms contra ~80 ms do caminho normal — a diferença é medível pela rede e entrega a mesma informação. Duas extensões que quase todo mundo esquece: conta desativada e e-mail não verificado precisam cair na mesma mensagem, e a tela de "esqueci minha senha" é o segundo lugar por onde a lista de usuários vaza.
<?php
// ---------- ANTES: dois vazamentos numa tela só ------------------------
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Models\Usuario;
class LoginAntes
{
public function entrar(array $dados): array
{
$usuario = Usuario::where('email', $dados['email'])->first();
// (1) VAZAMENTO POR MENSAGEM — a resposta diz se o e-mail existe.
// É assim que se monta a lista de clientes de um serviço:
// roda 10 mil e-mails e separa os que respondem isto.
if ($usuario === null) {
return ['erro' => 'E-mail não cadastrado.'];
}
// (2) VAZAMENTO POR TEMPO — se o usuário não existe, o método
// retorna sem chamar password_verify. O bcrypt leva ~80 ms;
// a resposta "rápida" (~5 ms) entrega o mesmo segredo, agora
// por cronômetro, mesmo com a mensagem corrigida.
if (!password_verify($dados['senha'], $usuario->senha)) {
return ['erro' => 'Senha incorreta.'];
}
return ['ok' => true, 'usuario' => $usuario];
}
}
// ---------- DEPOIS: uma mensagem, um tempo ------------------------------
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Usuario;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
/**
* Hash de uma senha que ninguém usa, gerado uma vez e fixado aqui.
* Serve de alvo para o password_verify quando o e-mail não existe:
* o custo de CPU passa a ser o mesmo dos dois lados.
*
* Gere o seu com: php -r "echo password_hash(bin2hex(random_bytes(32)), PASSWORD_BCRYPT);"
*/
private const HASH_FALSO = '$2y$12$K3Jq8vZ9wYxL2mN4pQ6r.uT7sV1wX3yZ5aB7cD9eF0gH2iJ4kL6mO';
public function entrar(Request $request): RedirectResponse
{
$dados = $request->validate([
'email' => ['required', 'email'],
'senha' => ['required', 'string'],
]);
$chave = 'login:' . mb_strtolower($dados['email']) . '|' . $request->ip();
if (RateLimiter::tooManyAttempts($chave, maxAttempts: 5)) {
throw ValidationException::withMessages([
'email' => 'Muitas tentativas. Aguarde ' . RateLimiter::availableIn($chave) . 's.',
]);
}
$usuario = Usuario::where('email', $dados['email'])->first();
// A CORREÇÃO DO TEMPO: com usuário inexistente, verifica contra o
// hash falso. O bcrypt roda igual nos dois caminhos, e o
// cronômetro deixa de distinguir "não existe" de "senha errada".
$senhaConfere = $usuario !== null
? Hash::check($dados['senha'], $usuario->senha)
: Hash::check($dados['senha'], self::HASH_FALSO);
// Contas desativadas e sem e-mail confirmado também caem na
// MESMA mensagem. Dizer "conta suspensa" confirma que a conta
// existe — a informação vai por outro canal (e-mail ao titular).
$podeEntrar = $usuario !== null
&& $senhaConfere
&& $usuario->ativo
&& $usuario->email_verificado_em !== null;
if (!$podeEntrar) {
RateLimiter::hit($chave, decaySeconds: 60);
// O detalhe REAL vai para o log, onde só a equipe vê.
Log::warning('Tentativa de login recusada', [
'email' => $dados['email'],
'motivo' => match (true) {
$usuario === null => 'usuario_inexistente',
!$senhaConfere => 'senha_incorreta',
!$usuario->ativo => 'conta_desativada',
$usuario->email_verificado_em === null => 'email_nao_verificado',
default => 'desconhecido',
},
'ip' => $request->ip(),
]);
// UMA mensagem para todos os casos.
throw ValidationException::withMessages([
'email' => 'E-mail ou senha inválidos.',
]);
}
// Rehash oportunista: se o custo do bcrypt subiu desde o cadastro,
// aproveita que a senha em claro está em mãos e regrava.
if (Hash::needsRehash($usuario->senha)) {
$usuario->forceFill(['senha' => Hash::make($dados['senha'])])->save();
}
RateLimiter::clear($chave);
Auth::login($usuario, $request->boolean('lembrar'));
$request->session()->regenerate();
return redirect()->intended(route('painel'));
}
}
// ---------------------------------------- app/Http/Controllers/Auth/SenhaController.php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Usuario;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
class SenhaController extends Controller
{
public function enviarLink(Request $request): RedirectResponse
{
$request->validate(['email' => ['required', 'email']]);
// A recuperação de senha é o outro lugar por onde a lista de
// usuários vaza — e o mais esquecido.
$usuario = Usuario::where('email', $request->input('email'))->first();
if ($usuario !== null) {
Password::sendResetLink($request->only('email'));
}
// A MESMA resposta nos dois casos. O usuário legítimo não perde
// nada: se tem conta, o e-mail chega.
return back()->with(
'sucesso',
'Se este e-mail estiver cadastrado, você receberá as instruções em instantes.'
);
}
}
// ---------------------------------------- tests/Feature/LoginSeguroTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class LoginSeguroTest extends TestCase
{
use RefreshDatabase;
public function test_a_mensagem_e_a_mesma_nos_tres_casos(): void
{
Usuario::factory()->create([
'email' => 'existe@teste.com',
'senha' => Hash::make('correta'),
'ativo' => false,
]);
$mensagens = [];
foreach ([
['email' => 'nao-existe@teste.com', 'senha' => 'qualquer'],
['email' => 'existe@teste.com', 'senha' => 'errada'],
['email' => 'existe@teste.com', 'senha' => 'correta'], // conta desativada
] as $tentativa) {
$this->post('/login', $tentativa);
$mensagens[] = session('errors')->first('email');
$this->flushSession();
}
$this->assertCount(1, array_unique($mensagens),
'As mensagens divergem e permitem enumerar contas.');
}
public function test_o_tempo_de_resposta_nao_denuncia_a_existencia(): void
{
Usuario::factory()->create([
'email' => 'existe@teste.com',
'senha' => Hash::make('correta'),
]);
$medir = function (array $dados): float {
$inicio = microtime(true);
$this->post('/login', $dados);
$this->flushSession();
return microtime(true) - $inicio;
};
$comUsuario = $medir(['email' => 'existe@teste.com', 'senha' => 'errada']);
$semUsuario = $medir(['email' => 'nao-existe@teste.com', 'senha' => 'errada']);
// Margem generosa de propósito: máquina de CI oscila. O que este
// teste pega é a diferença GROSSEIRA — os ~80 ms do bcrypt que
// simplesmente não aconteceriam sem o hash falso.
$this->assertLessThan(
0.05,
abs($comUsuario - $semUsuario),
'A diferença de tempo permite descobrir quais e-mails têm conta.',
);
}
public function test_recuperacao_de_senha_nao_denuncia_e_mail_inexistente(): void
{
$resposta = $this->post('/esqueci-senha', ['email' => 'nao-existe@teste.com']);
$resposta->assertSessionHas('sucesso');
$resposta->assertSessionHasNoErrors();
}
}
Exercício 5
Desafio: implemente assinatura de webhook: ao receber um POST de um sistema externo, valide a assinatura HMAC-SHA256 no header X-Signature. Rejeite requisições com assinatura inválida ou mais antigas que 5 minutos (use timestamp no payload).
Ver resposta
✓ Resposta: Três defesas, e nenhuma delas é opcional. A assinatura prova a origem; o timestamp dentro do texto assinado barra o replay do POST capturado (fora dele, o atacante só trocaria o campo); e a idempotência por unique(provedor, id_externo) — porque todo provedor reenvia o evento quando não recebe 2xx rápido, e sem essa trava um pagamento é creditado duas vezes. O detalhe de implementação que quebra tudo em silêncio: assine $request->getContent(), nunca json_encode($request->all()) — reserializar reordena chaves e a assinatura nunca mais bate.
<?php
// ---------------------------------------- app/Webhooks/AssinaturaInvalida.php
declare(strict_types=1);
namespace App\Webhooks;
use RuntimeException;
final class AssinaturaInvalida extends RuntimeException {}
// ---------------------------------------- app/Webhooks/VerificadorDeAssinatura.php
declare(strict_types=1);
namespace App\Webhooks;
use SensitiveParameter;
/**
* Valida webhooks assinados com HMAC-SHA256, no formato que Stripe e
* GitHub popularizaram:
*
* X-Signature: t=1771171200,v1=5257a869e7ecebeda32affa62cdca3fa...
*
* O timestamp entra DENTRO do texto assinado — não é um campo separado que
* o atacante possa trocar à vontade.
*/
final class VerificadorDeAssinatura
{
public function __construct(
#[SensitiveParameter]
private readonly string $segredo,
private readonly int $toleranciaSegundos = 300, // 5 minutos
) {}
/**
* @param string $corpoBruto o corpo EXATO da requisição, byte a byte
*/
public function verificar(string $corpoBruto, string $cabecalho): void
{
[$timestamp, $assinaturas] = $this->separar($cabecalho);
$agora = time();
// Janela para trás: barra o replay. Sem ela, quem capturou um POST
// válido pode reenviá-lo amanhã — a assinatura continua correta.
if ($agora - $timestamp > $this->toleranciaSegundos) {
throw new AssinaturaInvalida(sprintf(
'Requisição velha demais: %d segundos.', $agora - $timestamp
));
}
// Janela para a frente: timestamp futuro é relógio desajustado ou
// tentativa de fabricar uma requisição válida por muito tempo.
if ($timestamp - $agora > 60) {
throw new AssinaturaInvalida('Timestamp no futuro.');
}
$esperada = hash_hmac('sha256', "{$timestamp}.{$corpoBruto}", $this->segredo);
foreach ($assinaturas as $recebida) {
// hash_equals, nunca ===. O provedor manda mais de uma
// assinatura durante rotação de segredo: qualquer uma que
// bata é válida.
if (hash_equals($esperada, $recebida)) {
return;
}
}
throw new AssinaturaInvalida('Assinatura não confere.');
}
/** @return array{0: int, 1: list<string>} */
private function separar(string $cabecalho): array
{
$timestamp = null;
$assinaturas = [];
foreach (explode(',', $cabecalho) as $parte) {
$par = explode('=', trim($parte), 2);
if (count($par) !== 2) {
continue;
}
[$chave, $valor] = $par;
match ($chave) {
't' => $timestamp = ctype_digit($valor) ? (int) $valor : null,
'v1' => $assinaturas[] = $valor,
default => null,
};
}
if ($timestamp === null || $assinaturas === []) {
throw new AssinaturaInvalida('Cabeçalho de assinatura malformado.');
}
return [$timestamp, $assinaturas];
}
/** Do outro lado: assinar o que você mesmo envia. */
public function assinar(string $corpo, ?int $timestamp = null): string
{
$timestamp ??= time();
return sprintf(
't=%d,v1=%s',
$timestamp,
hash_hmac('sha256', "{$timestamp}.{$corpo}", $this->segredo),
);
}
}
// ---------------------------------------- app/Http/Middleware/VerificarWebhook.php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Webhooks\AssinaturaInvalida;
use App\Webhooks\VerificadorDeAssinatura;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class VerificarWebhook
{
public function handle(Request $request, Closure $proximo, string $provedor): Response
{
$segredo = config("webhooks.{$provedor}.segredo");
if (blank($segredo)) {
Log::critical("Webhook {$provedor} sem segredo configurado.");
return response()->json(['mensagem' => 'Webhook não configurado.'], 500);
}
$verificador = new VerificadorDeAssinatura($segredo);
try {
$verificador->verificar(
// getContent(), NÃO json_encode($request->all()). O HMAC é
// sobre os bytes originais: reserializar reordena chaves,
// muda espaços e escapes, e a assinatura nunca mais bate.
$request->getContent(),
(string) $request->header('X-Signature', ''),
);
} catch (AssinaturaInvalida $e) {
Log::warning('Webhook recusado', [
'provedor' => $provedor,
'motivo' => $e->getMessage(),
'ip' => $request->ip(),
]);
return response()->json(['mensagem' => 'Assinatura inválida.'], 401);
}
return $proximo($request);
}
}
// ---------------------------------------- app/Http/Controllers/WebhookController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Jobs\ProcessarEventoDoGateway;
use App\Models\EventoDeWebhook;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function gateway(Request $request): JsonResponse
{
$evento = $request->json()->all();
$idExterno = (string) ($evento['id'] ?? '');
if ($idExterno === '') {
return response()->json(['mensagem' => 'Evento sem id.'], 422);
}
// IDEMPOTÊNCIA. Todo provedor sério REENVIA o evento quando não
// recebe 2xx rápido o suficiente — e às vezes reenvia mesmo tendo
// recebido. Sem esta trava, um pagamento é creditado duas vezes.
// O unique na coluna é o que torna a checagem à prova de corrida.
$registro = EventoDeWebhook::firstOrCreate(
['provedor' => 'gateway', 'id_externo' => $idExterno],
['tipo' => $evento['type'] ?? 'desconhecido', 'payload' => $evento],
);
if (!$registro->wasRecentlyCreated) {
// 200, não erro: para o provedor, já foi processado.
return response()->json(['mensagem' => 'Evento já processado.'], 200);
}
// Responda RÁPIDO e processe depois. A maioria dos provedores
// desiste em 5–10 segundos e marca a entrega como falha — se o
// processamento demorar, você recebe o mesmo evento de novo.
ProcessarEventoDoGateway::dispatch($registro);
return response()->json(['recebido' => true], 202);
}
}
// ---------------------------------------- routes/api.php
declare(strict_types=1);
use App\Http\Controllers\WebhookController;
use Illuminate\Support\Facades\Route;
// Webhook NÃO tem sessão nem token CSRF: a autenticação é a assinatura.
// Em rotas de API o CSRF já não se aplica; em routes/web.php, seria
// preciso excluir a rota explicitamente.
Route::post('/webhooks/gateway', [WebhookController::class, 'gateway'])
->middleware('webhook:gateway')
->name('webhooks.gateway');
// ------------------- database/migrations/..._create_eventos_de_webhook.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('eventos_de_webhook', function (Blueprint $tabela): void {
$tabela->id();
$tabela->string('provedor', 50);
$tabela->string('id_externo', 100);
$tabela->string('tipo', 100);
$tabela->json('payload');
$tabela->timestamp('processado_em')->nullable();
$tabela->timestamps();
// A trava de idempotência mora AQUI: mesmo com duas entregas
// simultâneas, o banco só aceita uma.
$tabela->unique(['provedor', 'id_externo']);
});
}
public function down(): void
{
Schema::dropIfExists('eventos_de_webhook');
}
};
// ---------------------------------------- tests/Feature/WebhookTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Jobs\ProcessarEventoDoGateway;
use App\Webhooks\VerificadorDeAssinatura;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class WebhookTest extends TestCase
{
use RefreshDatabase;
private const SEGREDO = 'segredo-de-teste';
protected function setUp(): void
{
parent::setUp();
config(['webhooks.gateway.segredo' => self::SEGREDO]);
Queue::fake();
}
private function enviar(array $evento, ?string $assinatura = null, ?int $timestamp = null)
{
// O corpo é montado UMA vez e reaproveitado: assinar um JSON e
// enviar outro (mesmo equivalente) é o erro nº 1 de quem escreve
// o teste.
$corpo = json_encode($evento, JSON_THROW_ON_ERROR);
$verificador = new VerificadorDeAssinatura(self::SEGREDO);
return $this->call(
'POST',
'/api/webhooks/gateway',
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => $assinatura ?? $verificador->assinar($corpo, $timestamp),
],
content: $corpo,
);
}
public function test_assinatura_valida_e_aceita(): void
{
$this->enviar(['id' => 'evt_1', 'type' => 'pagamento.aprovado'])
->assertStatus(202);
$this->assertDatabaseHas('eventos_de_webhook', ['id_externo' => 'evt_1']);
Queue::assertPushed(ProcessarEventoDoGateway::class);
}
public function test_assinatura_invalida_e_recusada(): void
{
$this->enviar(['id' => 'evt_2'], assinatura: 't=' . time() . ',v1=abcdef')
->assertUnauthorized();
$this->assertDatabaseCount('eventos_de_webhook', 0);
}
public function test_sem_cabecalho_e_recusado(): void
{
$this->enviar(['id' => 'evt_3'], assinatura: '')->assertUnauthorized();
}
public function test_requisicao_antiga_e_recusada(): void
{
// Assinatura perfeitamente válida, só que de 10 minutos atrás:
// é exatamente o cenário de replay.
$this->enviar(['id' => 'evt_4'], timestamp: time() - 600)
->assertUnauthorized();
}
public function test_timestamp_no_futuro_e_recusado(): void
{
$this->enviar(['id' => 'evt_5'], timestamp: time() + 3600)
->assertUnauthorized();
}
public function test_corpo_alterado_invalida_a_assinatura(): void
{
$verificador = new VerificadorDeAssinatura(self::SEGREDO);
$original = json_encode(['id' => 'evt_6', 'valor' => 100], JSON_THROW_ON_ERROR);
$adulterado = json_encode(['id' => 'evt_6', 'valor' => 999999], JSON_THROW_ON_ERROR);
$this->call(
'POST',
'/api/webhooks/gateway',
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => $verificador->assinar($original),
],
content: $adulterado,
)->assertUnauthorized();
}
public function test_evento_repetido_e_processado_uma_vez_so(): void
{
$evento = ['id' => 'evt_7', 'type' => 'pagamento.aprovado'];
$this->enviar($evento)->assertStatus(202);
$this->enviar($evento)->assertStatus(200); // reentrega
$this->assertDatabaseCount('eventos_de_webhook', 1);
Queue::assertPushed(ProcessarEventoDoGateway::class, times: 1);
}
}