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:
andreandClaude Opus 5.5 committed 2026-09-25 07:16:10 +01:00
1 parent d70f13aea0
commit 895b3d58d7
41 files changed
+688 -139

No files matched your search

+18 -4
View File
@@ -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