Files
fitme3070.pt/shared/platform-tools/functions_ip_ban.php
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

281 lines
11 KiB
PHP

<?php
// ─── Bloqueio de IP, com memória ────────────────────────────────────────────
//
// A quinta tinha três defesas por IP e nenhuma bloqueava:
//
// throttle de login temporário, por identificador+IP, esquece em 15 min
// contador do antispam por hora e por formulário, recomeça do zero à hora
// portão de país recusa por país, não por comportamento
//
// Nenhuma delas tem memória. Um IP que insista todos os dias às 3 da manhã é,
// para as três, um visitante novo de cada vez. Isto acrescenta a peça que
// faltava: as infracções ficam registadas, e a partir de um limiar o endereço
// passa a ser recusado à porta, antes de chegar ao formulário.
//
// A escalada é deliberadamente lenta e o primeiro banimento é curto. Um IP é
// muitas vezes partilhado -- uma escola, um escritório, o CGNAT de um operador
// móvel -- e banir alguém para sempre à terceira tentativa apanha pessoas a
// mais. Quem insiste é que sobe:
//
// 3 infracções em 24h → 1 hora
// 5 → 24 horas
// 8 → 7 dias
// 12 → permanente
//
// Nada disto se aplica a endereços internos: atrás do NPM o contentor fala
// consigo próprio para cron e health checks, e auto-banir-se seria uma avaria
// difícil de diagnosticar.
//
// Com o site emparelhado com o hub, os banimentos e as infracções seguem para lá
// e os banimentos dos outros sites chegam cá com source='fleet' — ver
// functions_security_events.php.
require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_security_events.php';
/** [infracções em 24h => minutos de banimento]. null = permanente. */
const IP_BAN_LADDER = [3 => 60, 5 => 1440, 8 => 10080, 12 => null];
function platform_ip_ban_ensure_tables(PDO $pdo): void
{
static $done = false;
if ($done) {
return;
}
$done = true;
$pdo->exec("
CREATE TABLE IF NOT EXISTS `ip_bans` (
`ip` VARCHAR(45) NOT NULL PRIMARY KEY,
`reason` VARCHAR(190) NOT NULL,
`source` VARCHAR(40) NOT NULL DEFAULT 'auto',
`expires_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` INT NULL,
`hits` INT NOT NULL DEFAULT 0,
KEY `idx_ip_bans_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
// As infracções vivem à parte do banimento: é a contagem delas que decide
// o degrau, e apagar um banimento não deve apagar o historico que o
// justificou -- senão um unban devolve a pessoa ao degrau zero e a escalada
// recomeça do princípio.
$pdo->exec("
CREATE TABLE IF NOT EXISTS `ip_offences` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`ip` VARCHAR(45) NOT NULL,
`kind` VARCHAR(40) NOT NULL,
`detail` VARCHAR(190) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_ip_offences` (`ip`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
/** Endereços que nunca são banidos: loopback, privados, link-local. */
function platform_ip_ban_exempt(?string $ip): bool
{
if ($ip === null || $ip === '') {
return true;
}
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
/**
* O banimento em vigor para este endereço, ou null.
*
* Um banimento expirado não é apagado -- fica como histórico, e a consulta
* ignora-o. Apagar linhas aqui perderia a razão pela qual alguém foi banido da
* primeira vez.
*/
function platform_ip_banned(PDO $pdo, ?string $ip = null): ?array
{
$ip ??= platform_client_ip();
if (platform_ip_ban_exempt($ip)) {
return null;
}
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT * FROM ip_bans WHERE ip = ? AND (expires_at IS NULL OR expires_at > NOW()) LIMIT 1'
);
$st->execute([$ip]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
// Falha ABERTO: sem base de dados não se tranca ninguém à porta. Uma
// avaria de infraestrutura não deve transformar-se em site inacessível.
error_log('[ip-ban] consulta falhou, a deixar passar: ' . $e->getMessage());
return null;
}
}
/** Bane um endereço. $minutes null = permanente. */
function platform_ip_ban(PDO $pdo, string $ip, string $reason, ?int $minutes = 60,
string $source = 'auto', ?int $userId = null): bool
{
if (platform_ip_ban_exempt($ip)) {
return false;
}
try {
platform_ip_ban_ensure_tables($pdo);
$expires = $minutes === null ? null : date('Y-m-d H:i:s', time() + $minutes * 60);
$pdo->prepare(
'INSERT INTO ip_bans (ip, reason, source, expires_at, created_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
reason = VALUES(reason), source = VALUES(source),
expires_at = VALUES(expires_at), created_at = NOW(), created_by = VALUES(created_by)'
)->execute([$ip, mb_substr($reason, 0, 190), $source, $expires, $userId]);
error_log("[ip-ban] $ip banido ($source): $reason" . ($expires ? " até $expires" : ' permanentemente'));
// As cópias da frota não voltam ao hub: ecoavam em ciclo.
if ($source !== 'fleet') {
platform_security_event($pdo, 'ip_ban', $reason, $ip, [
'source' => $source,
'expires_ts' => $minutes === null ? null : time() + $minutes * 60,
]);
}
return true;
} catch (Throwable $e) {
error_log('[ip-ban] não conseguiu banir: ' . $e->getMessage());
return false;
}
}
function platform_ip_unban(PDO $pdo, string $ip): bool
{
try {
platform_ip_ban_ensure_tables($pdo);
// Levanta também na frota — incluindo quando isto era uma cópia vinda de
// outro site. Sem isso a sincronização seguinte voltava a pô-la cá.
platform_security_event($pdo, 'ip_unban', null, $ip);
return $pdo->prepare('DELETE FROM ip_bans WHERE ip = ?')->execute([$ip]);
} catch (Throwable $e) {
error_log('[ip-ban] não conseguiu levantar: ' . $e->getMessage());
return false;
}
}
/**
* Regista uma infracção e, se o limiar for atingido, bane.
*
* É este o ponto de ligação para as defesas que já existem: o antispam chama-o
* quando recusa, o throttle de login quando esgota as tentativas, o portão de
* país quando bloqueia. Cada uma continua a fazer o que fazia -- isto só lhes
* acrescenta a memória que nenhuma tinha.
*
* Devolve o banimento aplicado, ou null se ainda não chegou lá.
*/
function platform_ip_record_offence(PDO $pdo, string $kind, ?string $detail = null, ?string $ip = null): ?array
{
$ip ??= platform_client_ip();
if (platform_ip_ban_exempt($ip)) {
return null;
}
try {
platform_ip_ban_ensure_tables($pdo);
$pdo->prepare('INSERT INTO ip_offences (ip, kind, detail) VALUES (?, ?, ?)')
->execute([$ip, mb_substr($kind, 0, 40), $detail === null ? null : mb_substr($detail, 0, 190)]);
platform_security_event($pdo, 'offence', $kind . ($detail !== null && $detail !== '' ? ' · ' . $detail : ''), $ip);
$st = $pdo->prepare('SELECT COUNT(*) FROM ip_offences WHERE ip = ? AND created_at >= NOW() - INTERVAL 1 DAY');
$st->execute([$ip]);
$n = (int) $st->fetchColumn();
// O degrau mais alto que este número atinge.
$minutes = false;
foreach (IP_BAN_LADDER as $threshold => $mins) {
if ($n >= $threshold) {
$minutes = $mins;
}
}
if ($minutes === false) {
return null;
}
platform_ip_ban($pdo, $ip, "$n infracções em 24h (última: $kind)", $minutes, 'auto');
return ['ip' => $ip, 'offences' => $n, 'minutes' => $minutes];
} catch (Throwable $e) {
error_log('[ip-ban] não registou a infracção: ' . $e->getMessage());
return null;
}
}
/**
* Corta o pedido se o endereço estiver banido. Chamar cedo, do bootstrap.
*
* 403 e não 404: um bot não lê nenhum dos dois, e para uma pessoa apanhada por
* engano um 403 com um email de contacto é a diferença entre perceber o que se
* passa e achar que o site está avariado.
*/
function platform_ip_guard(PDO $pdo, ?string $contactEmail = null): void
{
hub_security_maybe_sync($pdo);
$ban = platform_ip_banned($pdo);
if ($ban === null) {
return;
}
try {
$pdo->prepare('UPDATE ip_bans SET hits = hits + 1 WHERE ip = ?')->execute([$ban['ip']]);
} catch (Throwable) { /* contar é acessório */ }
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
header('Retry-After: ' . ($ban['expires_at'] ? max(60, strtotime($ban['expires_at']) - time()) : 86400));
$mail = $contactEmail !== null
? '<p>Se acha que se trata de um engano, escreva para <a href="mailto:'
. htmlspecialchars($contactEmail) . '">' . htmlspecialchars($contactEmail) . '</a>.</p>'
: '';
echo '<!doctype html><meta charset="utf-8"><title>Acesso bloqueado</title>'
. '<div style="font:16px/1.6 system-ui,sans-serif;max-width:34em;margin:18vh auto;padding:0 1.5rem">'
. '<h1 style="font-size:1.3rem">Acesso bloqueado</h1>'
. '<p>O seu endereço foi bloqueado por atividade automatizada repetida.</p>'
. $mail . '</div>';
exit;
}
// ─── Consultas para o backoffice ────────────────────────────────────────────
function platform_ip_ban_list(PDO $pdo, int $limit = 200): array
{
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT b.*,
(SELECT COUNT(*) FROM ip_offences o WHERE o.ip = b.ip) AS offences,
(b.expires_at IS NOT NULL AND b.expires_at <= NOW()) AS expired
FROM ip_bans b
ORDER BY (b.expires_at IS NULL OR b.expires_at > NOW()) DESC, b.created_at DESC
LIMIT ' . max(1, min(500, $limit))
);
$st->execute();
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
}
/** Endereços com infracções mas ainda sem banimento — quem está a subir. */
function platform_ip_offenders(PDO $pdo, int $days = 7, int $limit = 30): array
{
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT o.ip, COUNT(*) AS n, MAX(o.created_at) AS last_seen,
GROUP_CONCAT(DISTINCT o.kind ORDER BY o.kind SEPARATOR ", ") AS kinds
FROM ip_offences o
LEFT JOIN ip_bans b ON b.ip = o.ip AND (b.expires_at IS NULL OR b.expires_at > NOW())
WHERE o.created_at >= NOW() - INTERVAL ? DAY AND b.ip IS NULL
GROUP BY o.ip
ORDER BY n DESC, last_seen DESC
LIMIT ' . max(1, min(200, $limit))
);
$st->execute([max(1, $days)]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
}