- 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>
281 lines
11 KiB
PHP
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 [];
|
|
}
|
|
}
|