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 ? '
Se acha que se trata de um engano, escreva para ' . htmlspecialchars($contactEmail) . '.
' : ''; echo 'O seu endereço foi bloqueado por atividade automatizada repetida.
' . $mail . '