- 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>
399 lines
17 KiB
PHP
399 lines
17 KiB
PHP
<?php
|
|
// ─── Eventos de segurança e banimentos da frota, via hub ────────────────────
|
|
//
|
|
// Cada plataforma regista localmente o que as defesas recusam (banimentos,
|
|
// infracções, logins falhados, CSRF, tecto de envio) numa caixa de saída, e
|
|
// envia-a em lote ao hub: POST <hub>/api/security_events.php, autenticado com a
|
|
// MESMA chave com que o hub sonda esta plataforma (X-Hub-Key). A resposta traz
|
|
// os banimentos activos da frota, que entram no ip_bans local com
|
|
// source='fleet' — um IP banido na dance passa a ser recusado na fotografia.
|
|
//
|
|
// Porquê uma caixa de saída e não um POST por evento: um ataque gera centenas
|
|
// de recusas por minuto. Um pedido HTTP por recusa punha cada pedido do
|
|
// atacante à espera da rede e transformava o ataque a um site num ataque ao
|
|
// hub. Em lote, mil recusas são um pedido.
|
|
//
|
|
// Quando é que o lote sai:
|
|
// • no fim de um pedido qualquer (platform_ip_guard() regista-o), no máximo a
|
|
// cada HUB_SECURITY_FLUSH_EVERY s se há eventos e HUB_SECURITY_SYNC_EVERY s
|
|
// se não há — sem eventos continua a sincronizar, é assim que os
|
|
// banimentos dos OUTROS sites chegam aqui;
|
|
// • pelo cron: bin/hub-security-sync.php, que esvazia a caixa toda.
|
|
//
|
|
// Nada disto bloqueia nem rebenta. Sem hub emparelhado não se regista nada
|
|
// (senão a caixa crescia para sempre); com o hub em baixo os eventos esperam,
|
|
// com tecto, e os banimentos locais funcionam exactamente como antes.
|
|
//
|
|
// Origem de cada decisão sobre banimentos da frota:
|
|
// • um banimento chega ao hub só a partir do site que o decidiu — as cópias
|
|
// (source='fleet') nunca são reenviadas, senão ecoavam em ciclo;
|
|
// • levantar um banimento em QUALQUER site levanta-o na frota: as cópias
|
|
// desaparecem de todos na sincronização seguinte. Sem isto, um unban local
|
|
// de uma cópia era desfeito cinco minutos depois. O site de origem mantém
|
|
// o seu próprio banimento até expirar ou ser levantado lá.
|
|
|
|
require_once __DIR__ . '/functions_client_ip.php';
|
|
|
|
const HUB_SECURITY_FLUSH_EVERY = 30;
|
|
const HUB_SECURITY_SYNC_EVERY = 300;
|
|
const HUB_SECURITY_BATCH = 200;
|
|
// Tecto da caixa com o hub em baixo. Os banimentos nunca são descartados; o
|
|
// resto sai do mais antigo para o mais recente.
|
|
const HUB_SECURITY_OUTBOX_MAX = 5000;
|
|
|
|
function hub_security_enabled(): bool {
|
|
static $on = null;
|
|
if ($on !== null) return $on;
|
|
try {
|
|
if (!function_exists('hub_url')) require_once __DIR__ . '/functions_hub.php';
|
|
$on = hub_url() !== '' && hub_api_key() !== '';
|
|
} catch (Throwable $e) {
|
|
$on = false;
|
|
}
|
|
return $on;
|
|
}
|
|
|
|
/**
|
|
* Carimbos em ficheiro, para o fim de cada pedido decidir «há sincronização a
|
|
* fazer?» com dois stat() e sem tocar na base de dados.
|
|
*/
|
|
function hub_security_stamp(string $name): string {
|
|
return rtrim(sys_get_temp_dir(), '/\\') . '/hub-security-'
|
|
. substr(md5(dirname(__DIR__, 2)), 0, 12) . '-' . $name;
|
|
}
|
|
|
|
function hub_security_ensure_table(PDO $pdo): void {
|
|
static $done = false;
|
|
if ($done) return;
|
|
$done = true;
|
|
// occurred_at em epoch e não DATETIME: o hub corre noutro contentor, e um
|
|
// DATETIME sem fuso lido do outro lado seria uma hora errada em metade do ano.
|
|
$pdo->exec(
|
|
"CREATE TABLE IF NOT EXISTS `hub_security_outbox` (
|
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
`kind` VARCHAR(40) NOT NULL,
|
|
`ip` VARCHAR(45) NULL,
|
|
`detail` VARCHAR(190) NULL,
|
|
`data` TEXT NULL,
|
|
`occurred_at` INT UNSIGNED NOT NULL,
|
|
`attempts` INT NOT NULL DEFAULT 0,
|
|
KEY `idx_hso_kind` (`kind`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Destino alternativo dos eventos: fn(?PDO, kind, ?detail, ?ip, array data).
|
|
* É o hub que se regista aqui — ele é o registo da frota, e mandar os seus
|
|
* próprios eventos a si mesmo por HTTP exigia emparelhá-lo consigo próprio.
|
|
*/
|
|
function platform_security_event_sink(?callable $sink = null): ?callable {
|
|
static $current = null;
|
|
if ($sink !== null) $current = $sink;
|
|
return $current;
|
|
}
|
|
|
|
/**
|
|
* Regista um evento para o hub. Nunca lança e nunca espera pela rede.
|
|
*
|
|
* $kind curto e estável (ip_ban, ip_unban, offence, login_failed,
|
|
* login_throttled, csrf_failed, mail_rate_limited): é por ele que o hub agrupa.
|
|
*/
|
|
function platform_security_event(?PDO $pdo, string $kind, ?string $detail = null,
|
|
?string $ip = null, array $data = []): void {
|
|
try {
|
|
$ip ??= platform_client_ip();
|
|
$ip = ($ip !== null && $ip !== '') ? $ip : null;
|
|
// Os detalhes das infracções trazem às vezes o email de quem submeteu
|
|
// (o antispam usa-o como assunto). Não tem de sair do site para o hub.
|
|
if ($detail !== null) {
|
|
$detail = preg_replace_callback('/[^\s@<>"\'(),;:]+@[^\s@<>"\'(),;:]+\.[a-z]{2,}/i',
|
|
static fn(array $m): string => hub_security_mask_identifier($m[0]), $detail);
|
|
}
|
|
|
|
$sink = platform_security_event_sink();
|
|
if ($sink !== null) {
|
|
$sink($pdo ?? (($GLOBALS['pdo'] ?? null) instanceof PDO ? $GLOBALS['pdo'] : null), $kind, $detail, $ip, $data);
|
|
return;
|
|
}
|
|
|
|
if (!hub_security_enabled()) return;
|
|
$pdo ??= hub_pdo();
|
|
if (!$pdo instanceof PDO) return;
|
|
hub_security_ensure_table($pdo);
|
|
$pdo->prepare(
|
|
'INSERT INTO hub_security_outbox (kind, ip, detail, data, occurred_at) VALUES (?, ?, ?, ?, ?)'
|
|
)->execute([
|
|
mb_substr($kind, 0, 40),
|
|
$ip,
|
|
$detail === null ? null : mb_substr($detail, 0, 190),
|
|
$data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
|
time(),
|
|
]);
|
|
@touch(hub_security_stamp('dirty'));
|
|
} catch (Throwable $e) {
|
|
error_log('[hub-security] evento não registado: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/** «a***@exemplo.pt» — chega para ver um ataque a uma conta sem mandar o email ao hub. */
|
|
function hub_security_mask_identifier(string $id): string {
|
|
$id = mb_strtolower(trim($id));
|
|
$at = strrpos($id, '@');
|
|
if ($at === false) return mb_substr($id, 0, 1) . '***';
|
|
return mb_substr($id, 0, 1) . '***' . substr($id, $at);
|
|
}
|
|
|
|
/**
|
|
* Chamado cedo em cada pedido (pelo platform_ip_guard). Só agenda: o trabalho
|
|
* corre no fim, depois de a resposta sair quando o SAPI o permite.
|
|
*/
|
|
function hub_security_maybe_sync(PDO $pdo): void {
|
|
static $registered = false;
|
|
if ($registered) return;
|
|
$registered = true;
|
|
|
|
register_shutdown_function(static function () use ($pdo): void {
|
|
try {
|
|
if (!hub_security_due() || !hub_security_enabled()) return;
|
|
if (function_exists('fastcgi_finish_request')) {
|
|
@fastcgi_finish_request();
|
|
}
|
|
hub_security_sync($pdo, 1);
|
|
} catch (Throwable $e) {
|
|
error_log('[hub-security] sincronização falhou: ' . $e->getMessage());
|
|
}
|
|
});
|
|
}
|
|
|
|
function hub_security_due(): bool {
|
|
$attempt = hub_security_stamp('attempt');
|
|
$last = @filemtime($attempt) ?: 0;
|
|
$every = is_file(hub_security_stamp('dirty')) && !is_file(hub_security_stamp('failing'))
|
|
? HUB_SECURITY_FLUSH_EVERY
|
|
: HUB_SECURITY_SYNC_EVERY;
|
|
if (time() - $last < $every) return false;
|
|
// Marcar a tentativa ANTES de a fazer: os pedidos concorrentes desistem, e
|
|
// um hub em baixo não é tentado a cada pedido. Sem pasta temporária
|
|
// gravável não há como saber, e fica tudo para o cron.
|
|
return @touch($attempt);
|
|
}
|
|
|
|
/**
|
|
* Envia a caixa de saída e aplica a lista da frota que vem na resposta.
|
|
* $maxBatches: 1 no fim de um pedido, mais no cron.
|
|
*/
|
|
function hub_security_sync(PDO $pdo, int $maxBatches = 25): array {
|
|
if (!hub_security_enabled()) return ['ok' => false, 'error' => 'plataforma não emparelhada com o hub'];
|
|
if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL indisponível'];
|
|
|
|
hub_security_ensure_table($pdo);
|
|
@touch(hub_security_stamp('attempt'));
|
|
|
|
// Um só sincronizador de cada vez, entre todos os processos PHP do site. O
|
|
// nome leva a base de dados: os locks do MariaDB são do servidor inteiro, e a
|
|
// frota partilha o servidor — sem isto a sincronização de um site fazia os
|
|
// outros desistir.
|
|
if ((int) $pdo->query("SELECT GET_LOCK(CONCAT('hub_security_sync:', DATABASE()), 0)")->fetchColumn() !== 1) {
|
|
return ['ok' => true, 'skipped' => 'já a correr'];
|
|
}
|
|
|
|
$out = ['ok' => true, 'sent' => 0, 'fleet' => null, 'error' => null];
|
|
try {
|
|
hub_security_trim_outbox($pdo);
|
|
|
|
for ($i = 0; $i < max(1, $maxBatches); $i++) {
|
|
$rows = $pdo->query(
|
|
'SELECT * FROM hub_security_outbox ORDER BY id LIMIT ' . HUB_SECURITY_BATCH
|
|
)->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$events = [];
|
|
foreach ($rows as $r) {
|
|
$events[] = [
|
|
'kind' => $r['kind'],
|
|
'ip' => $r['ip'],
|
|
'detail' => $r['detail'],
|
|
'data' => $r['data'] !== null ? json_decode((string) $r['data'], true) : null,
|
|
'ts' => (int) $r['occurred_at'],
|
|
];
|
|
}
|
|
|
|
$res = hub_security_post(['events' => $events]);
|
|
if (!$res['ok']) {
|
|
if ($rows) {
|
|
$ids = implode(',', array_map('intval', array_column($rows, 'id')));
|
|
$pdo->exec("UPDATE hub_security_outbox SET attempts = attempts + 1 WHERE id IN ($ids)");
|
|
}
|
|
@touch(hub_security_stamp('failing'));
|
|
$out['ok'] = false;
|
|
$out['error'] = $res['error'];
|
|
error_log('[hub-security] envio falhou: ' . $res['error']);
|
|
break;
|
|
}
|
|
@unlink(hub_security_stamp('failing'));
|
|
|
|
if ($rows) {
|
|
$ids = implode(',', array_map('intval', array_column($rows, 'id')));
|
|
$pdo->exec("DELETE FROM hub_security_outbox WHERE id IN ($ids)");
|
|
$out['sent'] += count($rows);
|
|
}
|
|
if (is_array($res['data']['fleet'] ?? null)) {
|
|
$out['fleet'] = hub_security_apply_fleet($pdo, $res['data']['fleet']);
|
|
}
|
|
@touch(hub_security_stamp('synced'));
|
|
|
|
if (count($rows) < HUB_SECURITY_BATCH) {
|
|
// Contar de novo: um evento que entrou durante o envio tem de
|
|
// manter a marca, senão esperava pela sincronização lenta.
|
|
if ((int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn() === 0) {
|
|
@unlink(hub_security_stamp('dirty'));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
$pdo->query("SELECT RELEASE_LOCK(CONCAT('hub_security_sync:', DATABASE()))");
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
function hub_security_trim_outbox(PDO $pdo): void {
|
|
$n = (int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn();
|
|
if ($n <= HUB_SECURITY_OUTBOX_MAX) return;
|
|
$excess = $n - HUB_SECURITY_OUTBOX_MAX;
|
|
$pdo->exec(
|
|
"DELETE FROM hub_security_outbox WHERE kind NOT IN ('ip_ban', 'ip_unban')
|
|
ORDER BY id LIMIT " . (int) $excess
|
|
);
|
|
error_log("[hub-security] caixa de saída acima do tecto — $excess eventos antigos descartados");
|
|
}
|
|
|
|
/** POST ao hub. ['ok'=>bool, 'data'=>array|null, 'error'=>string]. */
|
|
function hub_security_post(array $payload): array {
|
|
$ch = curl_init(hub_url() . '/api/security_events.php');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
// Curto: no pior caso isto corre no fim de um pedido de um visitante.
|
|
CURLOPT_CONNECTTIMEOUT => 2,
|
|
CURLOPT_TIMEOUT => 5,
|
|
// X-Hub-Key e não Authorization: o proxy come o Authorization (ver functions_hub.php).
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json',
|
|
'X-Hub-Key: ' . hub_api_key()],
|
|
CURLOPT_USERAGENT => 'andmore-platform-security/1.0',
|
|
]);
|
|
$raw = curl_exec($ch);
|
|
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($raw === false || $err !== '') return ['ok' => false, 'data' => null, 'error' => $err ?: 'sem resposta'];
|
|
$data = json_decode((string) $raw, true);
|
|
if (!is_array($data)) return ['ok' => false, 'data' => null, 'error' => "resposta não-JSON (HTTP $http)"];
|
|
if (empty($data['ok'])) return ['ok' => false, 'data' => $data, 'error' => (string) ($data['error'] ?? "HTTP $http")];
|
|
return ['ok' => true, 'data' => $data, 'error' => ''];
|
|
}
|
|
|
|
/**
|
|
* Aplica a lista da frota ao ip_bans local.
|
|
*
|
|
* Um banimento próprio activo ganha sempre à cópia — foi decidido aqui, com o
|
|
* motivo daqui. As cópias que o hub deixou de mandar (levantadas ou expiradas)
|
|
* saem, mas só quando a lista vem completa: uma lista truncada não diz nada
|
|
* sobre o que ficou de fora.
|
|
*/
|
|
function hub_security_apply_fleet(PDO $pdo, array $fleet): array {
|
|
require_once __DIR__ . '/functions_ip_ban.php';
|
|
platform_ip_ban_ensure_tables($pdo);
|
|
|
|
$local = [];
|
|
foreach ($pdo->query(
|
|
"SELECT ip, source, reason, expires_at,
|
|
(expires_at IS NULL OR expires_at > NOW()) AS active
|
|
FROM ip_bans
|
|
WHERE source = 'fleet' OR expires_at IS NULL OR expires_at > NOW()"
|
|
) as $r) {
|
|
$local[$r['ip']] = $r;
|
|
}
|
|
|
|
$upsert = $pdo->prepare(
|
|
"INSERT INTO ip_bans (ip, reason, source, expires_at, created_by) VALUES (?, ?, 'fleet', ?, NULL)
|
|
ON DUPLICATE KEY UPDATE reason = VALUES(reason), source = 'fleet',
|
|
expires_at = VALUES(expires_at), created_at = NOW(), created_by = NULL"
|
|
);
|
|
|
|
$seen = [];
|
|
$applied = 0;
|
|
foreach ((array) ($fleet['bans'] ?? []) as $b) {
|
|
$ip = trim((string) ($b['ip'] ?? ''));
|
|
if (!filter_var($ip, FILTER_VALIDATE_IP) || platform_ip_ban_exempt($ip)) continue;
|
|
$seen[$ip] = true;
|
|
|
|
$cur = $local[$ip] ?? null;
|
|
if ($cur !== null && $cur['source'] !== 'fleet' && (int) $cur['active'] === 1) continue;
|
|
|
|
$ts = isset($b['expires_ts']) ? (int) $b['expires_ts'] : null;
|
|
$expires = $ts ? date('Y-m-d H:i:s', $ts) : null;
|
|
$reason = mb_substr('frota · ' . (string) ($b['origin'] ?? '?') . ' · ' . (string) ($b['reason'] ?? ''), 0, 190);
|
|
|
|
if ($cur !== null && $cur['source'] === 'fleet'
|
|
&& $cur['reason'] === $reason && $cur['expires_at'] === $expires) {
|
|
continue;
|
|
}
|
|
try {
|
|
$upsert->execute([$ip, $reason, $expires]);
|
|
$applied++;
|
|
} catch (Throwable $e) {
|
|
error_log('[hub-security] cópia de banimento não aplicada: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$removed = 0;
|
|
if (!empty($fleet['complete'])) {
|
|
$del = $pdo->prepare("DELETE FROM ip_bans WHERE ip = ? AND source = 'fleet'");
|
|
foreach ($local as $ip => $r) {
|
|
if ($r['source'] === 'fleet' && !isset($seen[$ip])) {
|
|
$del->execute([$ip]);
|
|
$removed++;
|
|
}
|
|
}
|
|
}
|
|
return ['received' => count($seen), 'applied' => $applied, 'removed' => $removed];
|
|
}
|
|
|
|
/**
|
|
* `checks.security` para o health do hub. A pergunta é «o envio está a
|
|
* funcionar?»: eventos presos há mais de uma hora querem dizer que não.
|
|
*/
|
|
function hub_security_check(PDO $pdo): array {
|
|
if (!hub_security_enabled()) return [];
|
|
hub_security_ensure_table($pdo);
|
|
|
|
$row = $pdo->query('SELECT COUNT(*) n, MIN(occurred_at) oldest FROM hub_security_outbox')
|
|
->fetch(PDO::FETCH_ASSOC);
|
|
$pending = (int) ($row['n'] ?? 0);
|
|
$oldest = $row['oldest'] !== null ? time() - (int) $row['oldest'] : 0;
|
|
|
|
$fleet = 0;
|
|
try {
|
|
$fleet = (int) $pdo->query(
|
|
"SELECT COUNT(*) FROM ip_bans WHERE source = 'fleet' AND (expires_at IS NULL OR expires_at > NOW())"
|
|
)->fetchColumn();
|
|
} catch (Throwable) { /* ip_bans ainda por criar */ }
|
|
|
|
$synced = @filemtime(hub_security_stamp('synced')) ?: null;
|
|
$detail = $pending . ' por enviar'
|
|
. ($synced ? ' · sincronizado há ' . max(0, (int) round((time() - $synced) / 60)) . ' min' : ' · nunca sincronizou')
|
|
. ' · ' . $fleet . ' banimento(s) da frota';
|
|
|
|
return [
|
|
'ok' => $oldest < 3600,
|
|
'detail' => $detail,
|
|
'pending' => $pending,
|
|
'oldest_seconds' => $oldest,
|
|
'fleet_bans' => $fleet,
|
|
'synced_at' => $synced ? date('c', $synced) : null,
|
|
];
|
|
}
|