fix(segurança): lixeira de 30 dias, CSRF em todas as ações, IP real, sessão e uploads
Correções da auditoria de 24/09 (audits/fitme3070-auditoria-2026-09-24.html). Lixeira - Tudo o que é importante passa a soft delete (deleted_at + deleted_by): equipa, modalidades, eventos, mensalidades, galerias, imagens, horário, horas de funcionamento, inscrições em eventos e pedidos de inscrição. - config/functions_trash.php: um registo por tipo; apagar, restaurar, listar e eliminar leem todos dele. Eliminar de vez só com 30 dias na lixeira — a condição está no WHERE do DELETE, um POST forjado não apaga nada. - Gestão → Lixeira (views/admin/trash/index.php): lista única com quem e quando, restauro a qualquer momento. - Imagens de galeria: o site lista a pasta, por isso o ficheiro passa para .trash/ (negado pelo .htaccess) e volta no restauro. - As listagens e o site público filtram deleted_at (horário também filtra modalidades apagadas). Migração: database/migration_20260925_trash.sql (só acrescenta colunas; tem de ser aplicada antes deste código). CSRF - csrf_check() recusa qualquer pedido que não seja POST (405). Antes saía sem verificar nada em GET, e dez ações de apagar/restaurar eram GET. - Os dez handlers passaram a POST com token; links viraram formulários. Autenticação e sessão - client_ip(): X-Real-IP / último X-Forwarded-For, só vindo de proxy privado com TRUSTED_PROXY. O throttle contava o IP do proxy (limite global). - Throttle também por conta (hash do email), além do IP. - Sem oráculo de tempo: login verifica contra um hash de referência quando a conta não existe; a recuperação deixa o email na fila. - Cookie de sessão Secure/HttpOnly/SameSite=Lax e use_strict_mode. - Definições de sistema só para sysadmin; autocomplete=username. Servidor e uploads - .htaccess: nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy, CSP (frame-ancestors, base-uri, object-src, form-action); nega parciais _*.php, PHP soltos do platform-tools e *.md; 404 própria. - Uploads validados pelo conteúdo (finfo/getimagesize); SVG só sem scripts, handlers on*=, foreignObject ou hrefs externos. Testado numa cópia da BD de produção (fitme_audit_test) com um contentor temporário: 34/34 verificações HTTP ponta-a-ponta. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d70f13aea0
commit
895b3d58d7
41 files changed
+688
-139
No files matched your search
@@ -27,6 +27,10 @@ function admin_exists(PDO $pdo): bool {
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
|
||||
// Hash bcrypt (custo 12) de uma password aleatória descartada — só serve para
|
||||
// igualar o tempo de resposta quando a conta não existe.
|
||||
const AUTH_DUMMY_HASH = '$2y$12$luPhZpP46FkyufPCM1d8Gu4lPNS3A/I0gSvoBiNEL3lZAauMkuzH2';
|
||||
|
||||
// Verifies credentials and stamps the login trail. Returns the user row on
|
||||
// success, null on bad email / bad password / inactive account / no password
|
||||
// set. Callers must not leak which of the four it was.
|
||||
@@ -37,7 +41,14 @@ function auth_login(PDO $pdo, string $email, string $password): ?array {
|
||||
$stmt->execute([strtolower(trim($email))]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
if (!$user) {
|
||||
// Sem conta, verifica na mesma contra um hash de referência: um bcrypt
|
||||
// de custo 12 demora dezenas de ms, e responder mais depressa quando o
|
||||
// email não existe diria a quem tenta quais emails têm conta.
|
||||
password_verify($password, AUTH_DUMMY_HASH);
|
||||
return null;
|
||||
}
|
||||
if (!password_verify($password, $user['password_hash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,7 +58,7 @@ function auth_login(PDO $pdo, string $email, string $password): ?array {
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$ip = client_ip();
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
|
||||
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
|
||||
->execute([$ip, $ua, $user['id']]);
|
||||
@@ -219,7 +230,10 @@ function auth_consume_reset_token(PDO $pdo, string $token, string $newPassword):
|
||||
// Delivery is centralized at the hub's /notify endpoint — see
|
||||
// config/functions_queue.php (enqueue_email + flush_message).
|
||||
|
||||
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null): bool {
|
||||
// $flush=false deixa a mensagem na fila para o cron (≤5 min). O pedido de
|
||||
// recuperação usa-o: um envio SMTP síncrono só quando a conta existe fazia a
|
||||
// resposta demorar segundos apenas nesse caso — um oráculo de contas.
|
||||
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null, bool $flush = true): bool {
|
||||
$body = load_email_template('password_reset', [
|
||||
'user_name' => $name,
|
||||
'reset_url' => $resetUrl,
|
||||
@@ -235,7 +249,7 @@ function send_password_reset_email(string $name, string $email, string $resetUrl
|
||||
'context_type' => 'password_reset',
|
||||
'context_id' => $userId,
|
||||
]);
|
||||
return flush_message($id);
|
||||
return $flush ? flush_message($id) : $id > 0;
|
||||
}
|
||||
|
||||
// An account created from the backoffice has no password, so the person has no
|
||||
|
||||
Reference in new issue
Block a user