Segurança
As ameaças mais comuns em sistemas web e como se defender de cada uma — com exemplos práticos em PHP. Regra mestre: nunca confie no que vem do cliente (formulário, URL, header, cookie, arquivo).
XSS & HTML injection
XSS (Cross-Site Scripting) é quando um atacante injeta HTML/JS numa página que outros vão ver. Ex.: um comentário com <script>roubaCookie()</script>. A defesa é escapar toda saída que vem de usuário/banco.
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES, "UTF-8");
}
// ERRADO: imprime o que o usuário mandou, do jeito que veio.
echo "<p>" . $comentario . "</p>";
// CERTO: escapa < > & " ' antes de imprimir.
echo "<p>" . h($comentario) . "</p>";
Uma segunda camada é o header CSP (Content-Security-Policy), que diz ao navegador de onde scripts podem rodar:
header("Content-Security-Policy: default-src 'self'"); // só scripts do próprio domínio
header("X-Content-Type-Options: nosniff");
htmlspecialchars serve pra HTML. Dentro de um atributo, de uma URL (href) ou de <script> as regras mudam — nunca jogue dado de usuário cru dentro de onclick, href="javascript:..." ou de um bloco <script>.
SQL injection
Acontece quando você concatena input dentro da query. Um email com ' OR '1'='1 vira um login que sempre passa. A defesa é prepared statement: a query vai com ? e os valores separados — o banco trata como dado, nunca como comando.
// ERRADO: input direto na string -> injeção.
$pdo->query("SELECT * FROM users WHERE email = '" . $email . "'");
// CERTO: ? no lugar do valor; valor vai no execute().
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
prepare() + execute().
CSRF (Cross-Site Request Forgery)
Um site malicioso faz o navegador do usuário logado enviar uma requisição pro seu site (ex.: um <form> escondido que transfere dinheiro). Como o cookie de sessão vai junto, o servidor acha que foi o usuário. A defesa é um token secreto em cada formulário, que o site atacante não tem como adivinhar.
session_start();
// Gera o token uma vez por sessão (??= só cria se ainda não existe).
$_SESSION["csrf"] ??= bin2hex(random_bytes(32));
// No formulário:
// <input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
// Ao receber o POST, confere ANTES de tudo:
if (!hash_equals($_SESSION["csrf"], $_POST["csrf"] ?? "")) {
http_response_code(419);
exit("Requisição inválida (CSRF).");
}
hash_equals (compara em tempo constante, contra timing attack), nunca ==. Reforce com cookie de sessão SameSite=Lax. E lembre: nunca altere dado por GET — só POST (foi a correção que fizemos no pacientes.php).
Hash de senhas
Senha nunca é guardada em texto, nem com md5/sha1 (quebráveis). Use password_hash(), que aplica bcrypt/argon com salt automático. Pra conferir, password_verify() — você nunca "descriptografa", só compara.
// --- No CADASTRO: guarda o hash, não a senha. ---
$hash = password_hash($senha, PASSWORD_DEFAULT);
// $hash -> algo como "$2y$10$abc..." (60 chars). Salve ISSO no banco.
// --- No LOGIN: compara a digitada com o hash salvo. ---
if (password_verify($senhaDigitada, $hashDoBanco)) {
// senha confere -> cria a sessão
$_SESSION["user_id"] = $user["id"];
} else {
echo "E-mail ou senha incorretos."; // msg genérica (não diga qual errou)
}
Upload malicioso
O perigo: alguém sobe shell.php disfarçado e depois o executa pela URL. Regras: confira o MIME real (não o enviado), regere o nome, force a extensão pelo tipo, limite o tamanho e salve onde o PHP não roda.
$f = $_FILES["arquivo"];
// 1) Tamanho máximo (ex.: 2 MB).
if ($f["size"] > 2 * 1024 * 1024) exit("Arquivo grande demais.");
// 2) MIME REAL (lê o conteúdo; não confie em $f["type"], que o cliente forja).
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f["tmp_name"]);
$permitidos = ["image/jpeg" => "jpg", "image/png" => "png"];
if (!isset($permitidos[$mime])) exit("Tipo não permitido.");
// 3) Nome aleatório + extensão definida pelo SERVIDOR (não pelo upload).
$nome = bin2hex(random_bytes(16)) . "." . $permitidos[$mime];
// 4) Salva numa pasta de uploads (idealmente fora do web root).
move_uploaded_file($f["tmp_name"], __DIR__ . "/../uploads/" . $nome);
$_FILES["arquivo"]["type"] ou na extensão do nome enviado — os dois são controlados pelo cliente. Sempre cheque o MIME real com finfo e regere o nome. Na pasta de uploads, desligue execução de PHP (via .htaccess).
APIs & JWT
APIs não têm sessão por cookie como o site. O padrão é o JWT (JSON Web Token): o servidor entrega um token assinado no login, e o cliente o manda em cada requisição no header Authorization. O servidor confere a assinatura — sem precisar guardar nada.
HEADER . PAYLOAD . SIGNATURE
{"alg":"HS256"} . {"sub":123,"exp":1700} . HMAC(header.payload, segredo)
base64url base64url prova de autenticidade
Authorization: Bearer eyJhbGci... <- o cliente manda assim em cada request
Na prática se usa uma lib (firebase/php-jwt via Composer), mas o conceito de validar é:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Pega o token do header Authorization.
$token = str_replace("Bearer ", "", $_SERVER["HTTP_AUTHORIZATION"] ?? "");
try {
// decode confere a assinatura E a expiração; lança exceção se inválido.
$dados = JWT::decode($token, new Key($segredo, "HS256"));
$userId = $dados->sub;
} catch (\Exception $e) {
http_response_code(401);
exit(json_encode(["erro" => "Token inválido"]));
}
exp (expiração curta). Valide a assinatura SEMPRE. Use um $segredo forte e fora do código (variável de ambiente).
CORS (Cross-Origin Resource Sharing)
Por padrão, o navegador bloqueia uma página em siteA.com de chamar uma API em siteB.com. O CORS é o servidor da API autorizando origens específicas, via headers de resposta.
// Libera SÓ o seu front (específico, não "*").
header("Access-Control-Allow-Origin: https://meuapp.com");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
// O navegador manda um "preflight" OPTIONS antes do POST real:
if ($_SERVER["REQUEST_METHOD"] === "OPTIONS") {
http_response_code(204);
exit;
}
curl ignora CORS totalmente. Por isso a API ainda precisa de autenticação (JWT) e validação. E Allow-Origin: * não combina com credenciais/cookies.
Rate limit
Limitar quantas requisições alguém faz num intervalo. Essencial no login (contra força bruta) e em APIs. Devolve HTTP 429 (Too Many Requests) quando estoura.
session_start();
// Conta tentativas de login nesta sessão (exemplo didático).
$_SESSION["tentativas"] = ($_SESSION["tentativas"] ?? 0) + 1;
if ($_SESSION["tentativas"] > 5) {
http_response_code(429);
exit("Muitas tentativas. Tente de novo em alguns minutos.");
}
// ...verifica a senha; se acertar, zere: $_SESSION["tentativas"] = 0;
Cloudflare & WAF
Cloudflare (e WAFs em geral) ficam na frente do seu servidor, como um porteiro. Toda requisição passa por ele antes de chegar no seu PHP — e ele filtra ataques, distribui carga e esconde o IP de origem.
| Função | O que faz |
|---|---|
| CDN | serve estáticos (css/img) de servidores perto do usuário → mais rápido |
| Anti-DDoS | absorve enxurradas de tráfego malicioso antes de derrubar seu servidor |
| WAF | regras que bloqueiam padrões de ataque (SQLi, XSS) na borda |
| Rate limit / Bot | desafia visitantes suspeitos (o "verificando seu navegador...") |
| SSL | HTTPS automático entre o visitante e o Cloudflare |
curl batia numa tela "One moment, please..." — era exatamente um desafio anti-bot do servidor (estilo Cloudflare). Um navegador real passa; scripts automatizados não.
Resumo: ameaça → defesa
| Ameaça | Defesa principal | Função/header chave |
|---|---|---|
| XSS | escapar saída + CSP | htmlspecialchars |
| SQL injection | prepared statements | prepare + execute |
| CSRF | token na sessão | hash_equals |
| Senha vazada | hash forte | password_hash |
| Upload malicioso | MIME real + nome regerado | finfo |
| Token forjado (API) | JWT assinado + exp | JWT::decode |
| Origem não autorizada | CORS específico | Access-Control-Allow-Origin |
| Força bruta | rate limit | HTTP 429 |
| DDoS / bots | WAF na borda | Cloudflare |