Segurança Avançada em PHP

Segurança Avançada em PHP

Criptografia simétrica e assimétrica, dados pessoais sob a LGPD, headers HTTP, auditoria de dependências e comparação em tempo constante. Com a medição que mostra a defesa contra enumeração de usuários falhando por causa de um hash de mentira no código.
PHP

• • 37 min de leitura

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

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);
    }
}
Comentários

Mais em PHP

Projeto Final Parte 1: Planejamento e Arquitetura
Projeto Final Parte 1: Planejamento e Arquitetura

Escopo, decisões de arquitetura com justificativa, o schema completo em…

PostgreSQL com PHP
PostgreSQL com PHP

Conectar é só trocar o DSN, mas o que vem depois prende: RETURNING, UPSERT com…

Performance e Otimização
Performance e Otimização

OPcache, cache de queries no Redis, o problema N+1, índices, filas e profiling…