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
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// Página 404 (ErrorDocument no .htaccess). Autónoma de propósito: sem base de
|
||||
// dados nem sessão, para funcionar mesmo quando o resto não funciona.
|
||||
http_response_code(404);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="pt-PT">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Página não encontrada — FitMe 3070</title>
|
||||
<style>
|
||||
:root { --bg:#0b0b0d; --ink:#f5f5f5; --muted:#a3a69e; --accent:#c2d500; }
|
||||
@media (prefers-color-scheme: light) { :root { --bg:#f7f8fa; --ink:#0b0b0d; --muted:#5b5e57; --accent:#6b7a00; } }
|
||||
body { margin:0; min-height:100svh; display:grid; place-items:center; background:var(--bg); color:var(--ink);
|
||||
font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; padding:16px; box-sizing:border-box; }
|
||||
main { max-width:460px; text-align:center; }
|
||||
h1 { font-size:clamp(3rem,12vw,5rem); margin:0; color:var(--accent); line-height:1; }
|
||||
p { color:var(--muted); }
|
||||
a { display:inline-block; margin-top:.5rem; padding:.7rem 1.4rem; border-radius:999px; background:var(--accent);
|
||||
color:#0b0b0d; font-weight:600; text-decoration:none; min-height:44px; box-sizing:border-box; }
|
||||
a:focus-visible { outline:3px solid var(--ink); outline-offset:3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>404</h1>
|
||||
<h2>Página não encontrada</h2>
|
||||
<p>O endereço que procura não existe ou mudou de sítio.</p>
|
||||
<a href="/">Voltar ao início</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+3
-2
@@ -36,8 +36,9 @@ if ($can_view && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
'message' => trim((string) ($_POST['lead_message'] ?? '')),
|
||||
'consent' => !empty($_POST['lead_consent']) ? 1 : 0,
|
||||
'consent_text' => $consent_text,
|
||||
// First hop of X-Forwarded-For (behind the id.andmore.pt proxy), else peer.
|
||||
'ip' => trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]) ?: ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
// IP real atrás do proxy (config/env.php) — o primeiro valor do
|
||||
// X-Forwarded-For é escrito pelo cliente.
|
||||
'ip' => client_ip(),
|
||||
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||
];
|
||||
if ($lead['name'] === '') $lead_errors[] = 'Indica o teu nome.';
|
||||
|
||||
+3
-2
@@ -38,8 +38,9 @@ $lead = [
|
||||
'message' => trim((string) ($_POST['mensagem'] ?? '')),
|
||||
'consent' => !empty($_POST['consent']) ? 1 : 0,
|
||||
'consent_text' => $consent_text,
|
||||
// First hop of X-Forwarded-For (behind the reverse proxy), else peer.
|
||||
'ip' => trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]) ?: ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
// IP real atrás do proxy (config/env.php) — o primeiro valor do
|
||||
// X-Forwarded-For é escrito pelo cliente.
|
||||
'ip' => client_ip(),
|
||||
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user