Files
fitme3070.pt/shared/platform-tools/functions_antispam_forms.php
T
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

503 lines
20 KiB
PHP

<?php
// ─── Anti-spam de formulários públicos: o motor de pontuação (partilhado) ────
//
// Nasceu no dance, a defender os formulários de contacto e de aula
// experimental — que são o funil de inscrições da academia — e vive agora aqui.
// A auditoria de 2026-09-07 encontrou CINCO implementações de anti-spam na
// frota, e a do dance era a única com pontuação de conteúdo. Também era, das
// que existiam, a que corria sem a validação de identificadores SQL que o
// functions_antispam.php ao lado já tinha: a cópia mais rica era a cópia sem a
// guarda. É o argumento inteiro contra implementações paralelas.
//
// ─── A relação com functions_antispam.php ───────────────────────────────────
//
// São dois módulos e não um por decisão, não por esquecimento. O outro
// (honeypot + blocklist + limite deslizante de 24 h por email e IP) está em
// produção no hub e tem outra API; fundi-los às cegas mudava o comportamento de
// um site a que ninguém pediu alterações. Este trata do que aquele não faz:
// **pontuar o conteúdo** de um formulário de angariação, onde recusar de mais
// custa inscrições e recusar de menos custa uma caixa de correio.
//
// Convergir os dois é trabalho para quando houver quem teste o hub. Até lá,
// quem escolhe é o site: este para formulários de angariação com texto livre,
// aquele para formulários de suporte com identidade conhecida.
//
// ─── O desenho, que se mantém do dance ──────────────────────────────────────
//
// Nada aqui devolve um erro a quem escreve. Uma submissão recusada recebe a
// mesma página de obrigado que uma válida — um bot não fica a saber o que o
// apanhou — e fica gravada, para que um falso positivo seja recuperável em vez
// de desaparecer. Um falso positivo é uma lead verdadeira.
//
// As camadas, da mais barata para a mais cara:
//
// 1. honeypot — campo escondido; preenchido é um bot.
// 2. carimbo assinado — `form_ts` + HMAC. Sem assinatura, o carimbo era um
// número que qualquer script punha à mão.
// 3. prova de JavaScript — um campo que só o browser preenche. NÃO bloqueia
// sozinho: quem desliga o JS continua a enviar.
// 4. limite por IP — inclui as tentativas já bloqueadas, senão bastava
// insistir para o contador nunca subir.
// 5. conteúdo — pontuação. Uma mensagem legítima pode trazer um
// link ("vi no vosso instagram"), por isso um link
// sozinho não chega ao limite e dois chegam.
//
// ─── Ligar num site ─────────────────────────────────────────────────────────
//
// require_once __DIR__ . '/../shared/platform-tools/functions_antispam_forms.php';
// platform_antispam_config([
// 'key_domain' => 'dsa-antispam|', // NÃO mudar num site a correr: ver abaixo
// 'log_table' => 'blocked_submissions',
// 'keywords' => [...], // vocabulário que não aparece no vosso negócio
// 'native' => '/\b(ola|gostaria|aulas?)\b/u',
// ]);
//
// `key_domain` entra no HMAC do carimbo. Mudá-lo num site a correr invalida os
// formulários que estiverem abertos nesse momento, e essas submissões são
// recusadas em silêncio — leads perdidas por uma questão de arrumação. Por isso
// é configurável e não fixo: cada site mantém o domínio com que já assina.
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php';
/**
* Configuração do módulo. Chamar com um array para definir; sem argumentos, lê.
* Os valores por omissão são conservadores de propósito — sem `keywords`, a
* camada de vocabulário simplesmente não pontua.
*/
function platform_antispam_config(?array $set = null): array
{
static $cfg = [
'key_domain' => 'antispam|',
'log_table' => 'blocked_submissions',
'min_seconds' => 4, // ninguém preenche um formulário em menos
'max_seconds' => 21600, // 6 h — separador aberto de manhã ainda serve
'score_block' => 5,
'ip_limit' => 5,
'ip_window' => 60, // minutos
'keywords' => [],
'native' => '', // regex das marcas da língua local (ver looks_foreign)
'setting_key' => 'antispam_key',
];
if ($set !== null) {
$cfg = array_merge($cfg, $set);
}
return $cfg;
}
/**
* Um identificador SQL que podemos interpolar?
*
* Nomes de tabela não podem ir por placeholder, e estas funções recebem-nos de
* quem chama. **Falha aberto**: um identificador inválido é um erro de
* programação do site, e não é o anti-spam que há-de derrubar o formulário por
* causa dele — regista-se e deixa-se passar.
*/
function platform_antispam_ident_ok(string $ident): bool
{
if (preg_match('/^[A-Za-z0-9_]+$/', $ident)) {
return true;
}
error_log('[antispam] identificador SQL inválido, ignorado: ' . $ident);
return false;
}
/**
* Chave dos HMAC do carimbo.
*
* Tem de ser **a mesma entre pedidos**: o formulário é assinado num pedido e
* verificado noutro, e uma chave que mudasse a cada pedido recusava toda a
* gente com "carimbo inválido". Daí os três degraus, todos estáveis:
*
* 1. APP_SECRET_KEY / BACKUP_TOKEN do ambiente (o caso normal);
* 2. uma chave aleatória gravada em system_settings, quando não há a primeira;
* 3. derivada da configuração do install, para instalações sem chave e sem a
* tabela de settings. Não é um segredo tão bom como os outros, mas é
* constante — e uma defesa que tranca todos os formulários é pior do que
* uma assinatura mais fraca.
*
* A mesma escada vive agora em platform_derived_key() (functions_settings.php),
* que é o que os módulos novos usam — o captcha, por exemplo. Esta cópia fica
* porque separa os domínios de outra maneira (ver o comentário lá dentro sobre
* a chave do ambiente) e porque trocá-la num site a correr invalida os
* formulários abertos nesse instante. Quem a converter faz uma janela que
* aceite as duas assinaturas antes de tirar a antiga.
*/
function platform_antispam_key(?PDO $pdo = null): string
{
static $key = null;
if ($key !== null) {
return $key;
}
$cfg = platform_antispam_config();
// Em bruto, e NÃO derivada com o domínio, quando vem do ambiente.
//
// Separar domínios seria melhor prática — a mesma chave assina carimbos e
// cifra backups. Mas a chave do HMAC não se muda num site a correr: os
// formulários abertos nesse instante passam a ter carimbo inválido e são
// recusados em silêncio, ou seja, leads perdidas por uma questão de
// arrumação. Fica como está, de propósito, e quem quiser trocar faz uma
// janela que aceite as duas assinaturas antes de tirar a antiga.
$fromEnv = platform_secret_key();
if ($fromEnv !== null) {
return $key = $fromEnv;
}
$stored = '';
if ($pdo instanceof PDO) {
$stored = (string) platform_settings_get($pdo, $cfg['setting_key'], '');
if ($stored === '') {
$fresh = bin2hex(random_bytes(32));
try {
// Nunca cifrada: platform_settings_is_secret('antispam_key') é
// falso de propósito. Cifrá-la aqui e lê-la noutro sítio sem
// decifrar dava uma chave diferente a cada leitura, ou seja
// todos os formulários recusados.
platform_settings_set($pdo, $cfg['setting_key'], $fresh, null, false);
$stored = $fresh;
} catch (Throwable $e) {
error_log('[antispam] chave não persistida (' . $e->getMessage() . '); a derivar da configuração');
}
}
}
if ($stored === '') {
$env = function (string $k): string {
return function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: '');
};
$stored = 'derived|' . $env('DB_NAME') . '|' . $env('DB_USER') . '|' . $env('DB_PASS')
. '|' . (defined('APP_BASE_URL') ? APP_BASE_URL : '');
}
return $key = hash('sha256', $cfg['key_domain'] . $stored, true);
}
function platform_antispam_sign(string $form, int $ts, ?PDO $pdo = null): string
{
return hash_hmac('sha256', $form . '|' . $ts, platform_antispam_key($pdo));
}
/**
* Campos-armadilha de um formulário público. `$form` entra na assinatura, para
* que um carimbo colhido no formulário de contacto não sirva no de inscrição.
*
* O <script> sai uma vez por página (dois formulários na mesma página partilham
* o mesmo) e preenche `form_js` com o início da assinatura — um cliente que só
* leia o HTML e reenvie os campos manda-o vazio, porque no HTML está vazio.
*/
function platform_antispam_trap_field(string $form = 'form', ?PDO $pdo = null): string
{
static $scriptPrinted = false;
$esc = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$ts = time();
$sig = platform_antispam_sign($form, $ts, $pdo);
$html = '<input type="text" name="website" value="" tabindex="-1" autocomplete="off"'
. ' style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;" aria-hidden="true">'
. '<input type="hidden" name="form_ts" value="' . $ts . '">'
. '<input type="hidden" name="form_sig" value="' . $esc($sig) . '">'
. '<input type="hidden" name="form_js" value="" data-js-check="' . $esc(substr($sig, 0, 12)) . '">';
if (!$scriptPrinted) {
$scriptPrinted = true;
// Corre já (o formulário que o imprime está acima) e outra vez quando a
// página acaba de carregar: o script sai uma vez só, junto ao PRIMEIRO
// formulário, e um segundo mais abaixo na mesma página ainda não existia
// quando ele correu — ficava sem a prova de JS (silver, 2026-09-23).
$html .= '<script>(function(){function f(){document.querySelectorAll(\'input[data-js-check]\').forEach('
. 'function(i){i.value=i.dataset.jsCheck;});}f();'
. 'document.addEventListener(\'DOMContentLoaded\',f);})();</script>';
}
return $html;
}
/**
* Recusa directa: honeypot, carimbo em falta/forjado, ou depressa/velho de mais.
* Devolve o motivo (para o registo) ou null quando passa.
*/
function platform_antispam_trap_reason(string $form, ?PDO $pdo = null): ?string
{
$cfg = platform_antispam_config();
if (trim((string) ($_POST['website'] ?? '')) !== '') {
return 'honeypot';
}
$ts = (int) ($_POST['form_ts'] ?? 0);
$sig = (string) ($_POST['form_sig'] ?? '');
if ($ts <= 0 || $sig === '' || !hash_equals(platform_antispam_sign($form, $ts, $pdo), $sig)) {
return 'carimbo inválido';
}
$elapsed = time() - $ts;
if ($elapsed < $cfg['min_seconds']) {
return 'submetido em ' . max(0, $elapsed) . 's';
}
if ($elapsed > $cfg['max_seconds']) {
return 'formulário expirado';
}
return null;
}
/**
* Texto comprido, em inglês corrente, sem uma única marca da língua do site.
*
* Vale pontos e não uma recusa: um estrangeiro a perguntar por aulas escreve
* exactamente assim, e esse tem de passar. A regex da língua local vem da
* configuração — sem ela, esta camada não pontua, porque um módulo partilhado
* não pode adivinhar em que língua escrevem os clientes de cada site.
*/
function platform_antispam_looks_foreign(string $text): bool
{
$cfg = platform_antispam_config();
if ($cfg['native'] === '' || mb_strlen($text) < 60) {
return false;
}
$lower = mb_strtolower($text, 'UTF-8');
if (preg_match($cfg['native'], $lower)) {
return false;
}
$en = preg_match_all(
'/\b(the|your|and|are|with|for|our|we|you|this|that|they|before|across|using|each|from|have|will|would|can|about|more|their)\b/u',
$lower
);
return $en >= 4;
}
/** O campo que só o browser preenche foi preenchido? */
function platform_antispam_has_js_proof(): bool
{
$sig = (string) ($_POST['form_sig'] ?? '');
$js = (string) ($_POST['form_js'] ?? '');
return $sig !== '' && $js !== '' && hash_equals(substr($sig, 0, 12), $js);
}
/**
* Pontuação do conteúdo. `$fields['name']` é tratado com mais severidade do que
* o texto livre: um link no nome não tem leitura inocente.
*
* @param array{name?:string, text?:string, modality?:string} $fields
* @param string[] $allowedChoices Lista fechada de um <select>; vazia = não verifica
* @return array{0:int, 1:string[]}
*/
function platform_antispam_content_score(array $fields, array $allowedChoices = []): array
{
$cfg = platform_antispam_config();
$name = trim((string) ($fields['name'] ?? ''));
$text = trim((string) ($fields['text'] ?? ''));
$all = $name . "\n" . $text;
$lower = mb_strtolower($all, 'UTF-8');
$score = 0;
$reasons = [];
$urlPattern = '~(https?://|www\.|\[url|<a\s)~i';
if (preg_match($urlPattern, $name)) {
$score += 5;
$reasons[] = 'link no nome';
}
$urlCount = preg_match_all($urlPattern, $text);
if ($urlCount >= 2) {
$score += 5;
$reasons[] = $urlCount . ' links na mensagem';
} elseif ($urlCount === 1) {
$score += 3;
$reasons[] = 'link na mensagem';
}
// Domínio escrito de propósito ao lado do filtro — "brnd .li/delist",
// "exemplo (dot) com". Quem escreve assim sabe que há um filtro a evitar, o
// que por si só diz o que a mensagem é. Vale 3 e não 5 porque apanha por
// engano um ponto mal espaçado ("a minha filha .Com sete anos").
if (preg_match('~\b[a-z0-9][a-z0-9-]+\s+\.\s?(com|net|org|io|co|li|ru|xyz|info|shop|link|site|online|biz)\b~i', $all)
|| preg_match('~\(\s*dot\s*\)|\bdot\s+(com|net|org)\b|\bhxxps?\b~i', $all)) {
$score += 3;
$reasons[] = 'domínio disfarçado';
}
if (preg_match('/[\p{Cyrillic}\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}\p{Arabic}\p{Hebrew}\p{Thai}]/u', $all)) {
$score += 5;
$reasons[] = 'escrita não-latina';
}
$hits = [];
foreach ($cfg['keywords'] as $word) {
if (str_contains($lower, $word)) {
$hits[] = $word;
}
}
if ($hits) {
$score += min(6, 3 * count($hits));
$reasons[] = 'vocabulário: ' . implode(', ', array_slice($hits, 0, 3));
}
// Dois pontos, e não três, por causa de um caso concreto: um estrangeiro a
// perguntar por aulas, com o JavaScript desligado, somava logo o limite.
if (platform_antispam_looks_foreign($text)) {
$score += 2;
$reasons[] = 'texto em língua estrangeira';
}
if (preg_match('~<\s*(a|script|iframe|img|div)\b~i', $all) || str_contains($lower, '[/url]')) {
$score += 3;
$reasons[] = 'html na mensagem';
}
$choice = trim((string) ($fields['modality'] ?? $fields['choice'] ?? ''));
if ($choice !== '' && $allowedChoices && !in_array($choice, $allowedChoices, true)) {
$score += 3;
$reasons[] = 'opção inexistente';
}
if (!platform_antispam_has_js_proof()) {
$score += 2;
$reasons[] = 'sem javascript';
}
return [$score, $reasons];
}
/**
* Quantas submissões deste IP na última janela, contando as bloqueadas — senão
* quem fosse recusado voltava a começar do zero a cada tentativa.
*/
function platform_antispam_ip_count(PDO $pdo, string $form, string $table, string $ip): int
{
if ($ip === '') {
return 0;
}
$cfg = platform_antispam_config();
$count = 0;
if (platform_antispam_ident_ok($table)) {
try {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)"
);
$stmt->execute([$ip, $cfg['ip_window']]);
$count += (int) $stmt->fetchColumn();
} catch (PDOException) { /* tabela ainda não migrada — não trancar por isso */ }
}
$log = $cfg['log_table'];
if (platform_antispam_ident_ok($log)) {
try {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM `{$log}`
WHERE ip = ? AND form = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)"
);
$stmt->execute([$ip, $form, $cfg['ip_window']]);
$count += (int) $stmt->fetchColumn();
} catch (PDOException) { /* idem */ }
}
return $count;
}
/**
* A verificação completa de um formulário público. Devolve null quando pode
* seguir, ou o motivo quando não — já registado.
*
* Chamar depois da verificação de CSRF. Quem chama responde sempre da mesma
* maneira (a página de obrigado), passe ou não.
*/
function platform_antispam_check(
PDO $pdo,
string $form,
string $table,
array $fields,
array $allowedChoices = []
): ?string {
// O IP verdadeiro, não o REMOTE_ADDR: atrás de um proxy o REMOTE_ADDR é
// sempre o mesmo, e o limite por IP passava a contar toda a gente no mesmo
// balde — cinco visitantes diferentes trancavam o formulário a todos, e um
// bot sozinho nunca chegava ao limite dele.
// platform_client_ip(), o nome canónico de functions_client_ip.php — e não
// auth_client_ip(), que é apenas um alias criado dentro do
// functions_auth.php. Um módulo partilhado não pode contar com que o site
// tenha carregado a autenticação: no dance tinha, e por isso o alias
// funcionava aqui sem ninguém reparar na dependência que faltava.
$ip = platform_client_ip() ?? '';
// O portão de país corre primeiro: é mais barato que as outras verificações
// e não depende de token nenhum. Desligado, devolve null.
if (function_exists('geo_form_block_reason')) {
$geoReason = geo_form_block_reason($ip ?: null);
if ($geoReason !== null) {
platform_antispam_log($pdo, $form, $geoReason, 0, $ip);
return $geoReason;
}
}
$cfg = platform_antispam_config();
$reason = platform_antispam_trap_reason($form, $pdo);
$score = 0;
if ($reason === null && platform_antispam_ip_count($pdo, $form, $table, $ip) >= $cfg['ip_limit']) {
$reason = 'limite de ' . $cfg['ip_limit'] . ' envios/hora deste IP';
}
if ($reason === null) {
[$score, $reasons] = platform_antispam_content_score($fields, $allowedChoices);
if ($score >= $cfg['score_block']) {
$reason = implode('; ', $reasons);
}
}
if ($reason === null) {
return null;
}
platform_antispam_log($pdo, $form, $reason, $score, $ip);
return $reason;
}
/**
* Grava a tentativa recusada. O payload é o $_POST inteiro menos o token CSRF —
* é o que permite ler um falso positivo e responder à pessoa à mesma.
*/
function platform_antispam_log(PDO $pdo, string $form, string $reason, int $score, string $ip): void
{
$cfg = platform_antispam_config();
$log = $cfg['log_table'];
if (!platform_antispam_ident_ok($log)) {
return;
}
$payload = $_POST;
unset($payload['csrf_token'], $payload['form_sig'], $payload['form_js'], $payload['form_ts']);
try {
$pdo->prepare(
"INSERT INTO `{$log}` (form, reason, score, ip, user_agent, payload)
VALUES (?, ?, ?, ?, ?, ?)"
)->execute([
$form,
mb_substr($reason, 0, 190),
$score,
$ip,
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 250),
mb_substr((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 0, 4000),
]);
// Cada recusa conta para o bloqueio de IP. É isto que dá memória às
// defesas: sozinho, este contador esquece tudo à hora seguinte, e um
// bot que volte todos os dias é sempre um visitante novo.
if (function_exists('platform_ip_record_offence')) {
platform_ip_record_offence($pdo, 'antispam:' . $form, mb_substr($reason, 0, 120), $ip ?: null);
}
} catch (PDOException $e) {
// Tabela por migrar, ou disco cheio: o bloqueio mantém-se, só não fica
// registado. Recusar por não se conseguir escrever o registo seria
// trocar spam por leads perdidas.
error_log('[antispam] registo falhou: ' . $e->getMessage());
}
}