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>
This commit is contained in:
1 parent
5e300fc341
commit
0e8b53f620
65 files changed
+5852
-455
No files matched your search
@@ -0,0 +1,839 @@
|
||||
<?php
|
||||
// ─── Captcha partilhado: uma pergunta escrita, para qualquer formulário ─────
|
||||
//
|
||||
// Um desafio que a pessoa lê e responde, para os sítios onde as defesas
|
||||
// invisíveis não chegam. Serve qualquer formulário de qualquer site da frota:
|
||||
// registo, recuperação de password, contacto, comentários, inscrições.
|
||||
//
|
||||
// ─── Porque não é o reCAPTCHA (nem o hCaptcha, nem o Turnstile) ─────────────
|
||||
//
|
||||
// Três razões, e nenhuma é gosto por fazer à mão:
|
||||
//
|
||||
// 1. O ASSETS.md fixou a 2026-09-01 que nada de front-end sai para a rede em
|
||||
// produção. Um captcha de terceiros é exactamente isso, e ainda por cima
|
||||
// num formulário que não abre sem ele: o serviço em baixo é o formulário
|
||||
// em baixo.
|
||||
// 2. Corre JavaScript de terceiros na página — incluindo, quando o captcha
|
||||
// está no back-office, na sessão de quem está autenticado como admin.
|
||||
// 3. Manda para fora o IP e o comportamento de cada visitante, o que num site
|
||||
// europeu é uma decisão de RGPD que ninguém tomou, e não uma dependência
|
||||
// técnica.
|
||||
//
|
||||
// O preço é assumido: isto trava scripts, não trava um humano pago para
|
||||
// resolver desafios. Era igualmente verdade com o reCAPTCHA.
|
||||
//
|
||||
// ─── A relação com os dois módulos de anti-spam ─────────────────────────────
|
||||
//
|
||||
// São três coisas diferentes e complementares:
|
||||
//
|
||||
// functions_antispam.php honeypot + blocklist + limite de 24 h
|
||||
// functions_antispam_forms.php pontuação do conteúdo de um formulário
|
||||
// este ficheiro uma pergunta visível, com resposta certa
|
||||
//
|
||||
// Os dois primeiros são invisíveis e recusam em silêncio — de propósito, porque
|
||||
// um bot não deve ficar a saber o que o apanhou e uma lead recusada por engano
|
||||
// tem de poder ser recuperada do registo. Isso só funciona quando há conteúdo
|
||||
// para pontuar e quando a recusa pode ser silenciosa.
|
||||
//
|
||||
// O captcha é para o resto: um formulário de registo ou de recuperação de
|
||||
// password não tem texto livre para pontuar, e recusar em silêncio um pedido de
|
||||
// password nova deixa a pessoa a carregar no botão para sempre. Aqui a pessoa é
|
||||
// informada, e pode tentar outra vez.
|
||||
//
|
||||
// Usar os dois juntos é o normal e não é redundante: as armadilhas apanham o
|
||||
// script que nem olha para a página, o captcha apanha o que olha.
|
||||
//
|
||||
// ─── O desenho ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// **Sem estado no servidor.** O desafio viaja num token assinado por HMAC: o
|
||||
// carimbo de tempo, um nonce aleatório e a assinatura. A pergunta *e a resposta*
|
||||
// são derivadas do nonce com a chave do site, portanto a resposta nunca sai
|
||||
// daqui e não há nada para guardar entre pedidos. Nenhuma tabela, nenhuma sessão
|
||||
// obrigatória, e funciona atrás de qualquer cache.
|
||||
//
|
||||
// **Uma pergunta, uma tentativa.** Havendo sessão, o nonce fica marcado como
|
||||
// usado assim que é verificado — certo ou errado. É o que impede que alguém peça
|
||||
// um desafio e experimente as vinte respostas possíveis com o mesmo token. Sem
|
||||
// sessão activa degrada para "reutilizável dentro da janela", e é por isso que o
|
||||
// ttl é curto por omissão.
|
||||
//
|
||||
// **Funciona sem JavaScript**, porque a regra 10 do PHILOSOPHY.md diz que a
|
||||
// página funciona sem ele, e porque um leitor de ecrã lê uma pergunta escrita e
|
||||
// não lê uma imagem distorcida. Pela mesma razão não há imagens: a resposta é
|
||||
// aceite em algarismos ou por extenso, com ou sem acentos.
|
||||
//
|
||||
// ─── Ligar num site ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// require_once __DIR__ . '/../shared/platform-tools/functions_captcha.php';
|
||||
//
|
||||
// // no formulário, dentro do <form>:
|
||||
// echo platform_captcha_field('registo', $pdo);
|
||||
//
|
||||
// // no handler, depois do CSRF:
|
||||
// if (($erro = platform_captcha_verify('registo', $pdo)) !== null) {
|
||||
// $error = $erro; // já em português, pronto a mostrar
|
||||
// }
|
||||
//
|
||||
// O primeiro argumento é o âmbito: entra na assinatura, para que um desafio
|
||||
// colhido no formulário de contacto não sirva no de registo. Dois formulários na
|
||||
// mesma página com âmbitos diferentes são independentes.
|
||||
//
|
||||
// API pública:
|
||||
// platform_captcha_config(?array) — configuração do módulo
|
||||
// platform_captcha_field(string, ?PDO) — o bloco HTML pronto a imprimir
|
||||
// platform_captcha_issue(string, ?PDO) — pergunta + token, para marcação própria
|
||||
// platform_captcha_verify(string, ?PDO) — null passa, ou a mensagem de erro
|
||||
// platform_captcha_ok(string, ?PDO) — o mesmo em booleano
|
||||
//
|
||||
// E o recurso — a razão pela qual este módulo existe. Ver a secção no fim:
|
||||
// platform_captcha_appeal_offer(PDO, form, reason) — guarda a submissão recusada
|
||||
// platform_captcha_appeal_field(token, form, ?PDO) — o bloco do segundo ecrã
|
||||
// platform_captcha_appeal_claim(PDO, form, &$error) — devolve a submissão libertada
|
||||
|
||||
require_once __DIR__ . '/functions_settings.php';
|
||||
require_once __DIR__ . '/functions_client_ip.php';
|
||||
|
||||
/**
|
||||
* Configuração. Com um array define; sem argumentos, lê.
|
||||
*/
|
||||
function platform_captcha_config(?array $set = null): array
|
||||
{
|
||||
static $cfg = [
|
||||
// 'math' (contas), 'letter' (letra de uma palavra) ou 'mixed' (sorteia
|
||||
// uma das duas por desafio). Misturar é de graça e obriga quem
|
||||
// automatiza a resolver dois formatos em vez de um.
|
||||
'type' => 'mixed',
|
||||
|
||||
// Validade do desafio. Curta de propósito: sem sessão activa é ela que
|
||||
// limita quantas respostas cabem num token. 15 minutos chegam para
|
||||
// preencher um formulário e não chegam para um ataque com paciência.
|
||||
'ttl' => 900,
|
||||
|
||||
// Nomes dos campos. Configuráveis porque um site pode já ter um campo
|
||||
// chamado 'captcha' vindo de outra coisa.
|
||||
'field_answer' => 'captcha_answer',
|
||||
'field_token' => 'captcha_token',
|
||||
|
||||
// Classes do Bootstrap 5, que é o que toda a frota carrega. Um sítio sem
|
||||
// Bootstrap passa strings vazias e estiliza por fora.
|
||||
'class_wrap' => 'mb-3',
|
||||
'class_label' => 'form-label',
|
||||
'class_input' => 'form-control',
|
||||
'class_help' => 'form-text',
|
||||
'help' => 'Uma pergunta simples, para travar envios automáticos.',
|
||||
'autocomplete' => 'off',
|
||||
|
||||
// Uma tentativa por pergunta, quando há sessão. Ver o cabeçalho.
|
||||
'single_use' => true,
|
||||
|
||||
// Um token forjado ou em falta é sinal de bot e conta para a escalada do
|
||||
// functions_ip_ban.php. Uma resposta errada NÃO conta, e a diferença é
|
||||
// deliberada: a escada bane aos 3 registos em 24 h, e quem se engana
|
||||
// duas vezes a escrever "catorze" não é um bot — seria banir gente por
|
||||
// errar uma pergunta que nós próprios lhe fizemos.
|
||||
'offence_forged' => true,
|
||||
'offence_wrong' => false,
|
||||
|
||||
// ─── O recurso ──────────────────────────────────────────────────────
|
||||
// Tabela própria, criada à cabeça como as do functions_ip_ban.php: a
|
||||
// `blocked_submissions` tem forma diferente em cada site e é um registo,
|
||||
// não uma fila de trabalho.
|
||||
'appeal_table' => 'captcha_appeals',
|
||||
|
||||
// Duas horas para responder. É uma pessoa a olhar para o ecrã, não um
|
||||
// formulário deixado aberto — e quanto mais curto, menos tempo a
|
||||
// submissão recusada fica guardada.
|
||||
'appeal_ttl' => 7200,
|
||||
|
||||
// Tentativas por recurso, e recursos por IP à hora. O primeiro impede
|
||||
// que se esgotem as respostas possíveis numa só submissão retida; o
|
||||
// segundo impede que a fila de recursos vire um depósito de spam.
|
||||
'appeal_tries' => 6,
|
||||
'appeal_max_ip' => 5,
|
||||
|
||||
// Motivos que NÃO dão direito a recurso. A recusa silenciosa existe
|
||||
// para o bot não ficar a saber o que o apanhou, e estes dois não têm
|
||||
// leitura inocente: o honeypot é um campo que ninguém vê, e o carimbo só
|
||||
// é produzido pelo nosso próprio formulário. Oferecer recurso a quem
|
||||
// falha ali é ensiná-lo a contornar. Todos os outros — país, limite por
|
||||
// IP, pontuação de conteúdo, formulário expirado — têm falsos positivos
|
||||
// reais e é para eles que isto serve.
|
||||
'appeal_never' => ['honeypot', 'carimbo inválido'],
|
||||
|
||||
// Palavras do desafio da letra. Sem acentos, de propósito: a resposta é
|
||||
// uma letra e não vale a pena pôr ninguém a decidir se escreve "a" ou
|
||||
// "á".
|
||||
'words' => [
|
||||
'CADEIRA', 'JANELA', 'SAPATO', 'CAMINHO', 'MERCADO', 'PALAVRA',
|
||||
'LARANJA', 'MONTANHA', 'TELEFONE', 'BICICLETA', 'ESCADA',
|
||||
'FLORESTA', 'CAMISOLA', 'GUITARRA', 'PADEIRO', 'CADERNO',
|
||||
],
|
||||
];
|
||||
|
||||
if ($set !== null) {
|
||||
$cfg = array_merge($cfg, $set);
|
||||
}
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chave que assina os desafios.
|
||||
*
|
||||
* Domínio próprio: um carimbo do anti-spam não valida aqui, nem ao contrário. A
|
||||
* derivação e os seus três degraus estão em functions_settings.php, que é onde
|
||||
* vive a única implementação.
|
||||
*/
|
||||
function platform_captcha_key(?PDO $pdo = null): string
|
||||
{
|
||||
return platform_derived_key('captcha', $pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pergunta e a resposta, derivadas do nonce.
|
||||
*
|
||||
* Determinista: o mesmo nonce, com a mesma chave e o mesmo âmbito, dá sempre o
|
||||
* mesmo par. É isso que dispensa guardar seja o que for — o servidor volta a
|
||||
* calcular a resposta no pedido seguinte em vez de se lembrar dela.
|
||||
*
|
||||
* Note-se que a resposta nunca vai para o cliente, nem sequer como hash: um hash
|
||||
* de "14" quebra-se com vinte tentativas numa consola.
|
||||
*
|
||||
* @return array{question:string, answer:string}
|
||||
*/
|
||||
function platform_captcha_challenge(string $form, string $nonce, ?PDO $pdo = null): array
|
||||
{
|
||||
$cfg = platform_captcha_config();
|
||||
$b = array_values(unpack(
|
||||
'C*',
|
||||
hash_hmac('sha256', 'q|' . $form . '|' . $nonce, platform_captcha_key($pdo), true)
|
||||
));
|
||||
|
||||
$type = $cfg['type'];
|
||||
if ($type === 'mixed') {
|
||||
$type = ($b[0] % 2 === 0) ? 'math' : 'letter';
|
||||
}
|
||||
|
||||
if ($type === 'letter') {
|
||||
$words = array_values($cfg['words']);
|
||||
$word = $words ? $words[$b[1] % count($words)] : 'CADEIRA';
|
||||
// Até à quinta letra, e nunca além do tamanho da palavra.
|
||||
$max = min(5, mb_strlen($word, 'UTF-8'));
|
||||
$pos = 1 + ($b[2] % $max);
|
||||
$ord = [1 => 'primeira', 2 => 'segunda', 3 => 'terceira', 4 => 'quarta', 5 => 'quinta'][$pos];
|
||||
|
||||
return [
|
||||
'question' => 'Escreva a ' . $ord . ' letra da palavra ' . $word . '.',
|
||||
'answer' => mb_substr($word, $pos - 1, 1, 'UTF-8'),
|
||||
];
|
||||
}
|
||||
|
||||
// Contas. Os limites dos operandos mantêm o resultado entre 0 e 20, que é o
|
||||
// intervalo que platform_captcha_number_words() sabe escrever por extenso.
|
||||
switch ($b[1] % 3) {
|
||||
case 0:
|
||||
$x = 1 + ($b[2] % 9);
|
||||
$y = 1 + ($b[3] % 9);
|
||||
$op = 'mais';
|
||||
$r = $x + $y;
|
||||
break;
|
||||
case 1:
|
||||
// O maior primeiro: um resultado negativo é uma pergunta injusta.
|
||||
$x = 2 + ($b[2] % 8);
|
||||
$y = 1 + ($b[3] % ($x - 1));
|
||||
$op = 'menos';
|
||||
$r = $x - $y;
|
||||
break;
|
||||
default:
|
||||
$x = 2 + ($b[2] % 4);
|
||||
$y = 2 + ($b[3] % 3);
|
||||
$op = 'vezes';
|
||||
$r = $x * $y;
|
||||
}
|
||||
|
||||
// Um operando por extenso e o outro em algarismos, sorteados. Custa nada e
|
||||
// parte o regex /(\d+)\s*\+\s*(\d+)/ com que se resolve um captcha destes em
|
||||
// três linhas.
|
||||
$words = platform_captcha_number_words();
|
||||
$left = ($b[4] % 2 === 0) ? (string) $x : $words[$x];
|
||||
$right = ($b[4] % 2 === 0) ? $words[$y] : (string) $y;
|
||||
|
||||
// "duas vezes três", não "dois vezes três": quem conta ali são as vezes, e
|
||||
// "vez" é feminino. Só acontece com o operando da esquerda de uma
|
||||
// multiplicação, e só quando calha ser escrito por extenso. A resposta não
|
||||
// muda com isto — platform_captcha_canonical_answer() já lê "duas" como 2.
|
||||
if ($op === 'vezes' && $left === 'dois') {
|
||||
$left = 'duas';
|
||||
}
|
||||
|
||||
return [
|
||||
'question' => 'Quanto é ' . $left . ' ' . $op . ' ' . $right . '?',
|
||||
'answer' => (string) $r,
|
||||
];
|
||||
}
|
||||
|
||||
/** 0–20 por extenso, já na forma normalizada (minúsculas, sem acentos). */
|
||||
function platform_captcha_number_words(): array
|
||||
{
|
||||
return [
|
||||
0 => 'zero', 'um', 'dois', 'tres', 'quatro', 'cinco', 'seis', 'sete',
|
||||
'oito', 'nove', 'dez', 'onze', 'doze', 'treze', 'catorze', 'quinze',
|
||||
'dezasseis', 'dezassete', 'dezoito', 'dezanove', 'vinte',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza uma resposta: minúsculas, sem acentos, sem nada que não seja letra
|
||||
* ou algarismo. "Catorze", "catorze " e "CATORZE" são a mesma resposta.
|
||||
*/
|
||||
function platform_captcha_normalize(string $s): string
|
||||
{
|
||||
$s = mb_strtolower(trim($s), 'UTF-8');
|
||||
$s = strtr($s, [
|
||||
'á' => 'a', 'à' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a',
|
||||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'í' => 'i', 'ì' => 'i', 'î' => 'i',
|
||||
'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
|
||||
'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ç' => 'c', 'ñ' => 'n',
|
||||
]);
|
||||
return (string) preg_replace('/[^a-z0-9]+/', '', $s);
|
||||
}
|
||||
|
||||
/**
|
||||
* A resposta de quem preencheu, posta na mesma forma que a nossa: por extenso
|
||||
* vira algarismos, e as variantes do Brasil são aceites porque quem as escreve
|
||||
* respondeu certo.
|
||||
*/
|
||||
function platform_captcha_canonical_answer(string $raw): string
|
||||
{
|
||||
$n = platform_captcha_normalize($raw);
|
||||
|
||||
$alias = [
|
||||
'uma' => 'um', 'duas' => 'dois', 'quatorze' => 'catorze',
|
||||
'dezesseis' => 'dezasseis', 'dezessete' => 'dezassete', 'dezenove' => 'dezanove',
|
||||
];
|
||||
$n = $alias[$n] ?? $n;
|
||||
|
||||
$words = array_flip(platform_captcha_number_words());
|
||||
if (isset($words[$n])) {
|
||||
return (string) $words[$n];
|
||||
}
|
||||
// "07" e "7" são a mesma resposta.
|
||||
if ($n !== '' && ctype_digit($n)) {
|
||||
return (string) (int) $n;
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emite um desafio: a pergunta, o token e os nomes dos campos.
|
||||
*
|
||||
* Para quem quer a sua própria marcação — um formulário em grelha, um modal, um
|
||||
* passo de um assistente. Quem só quer um campo normal usa
|
||||
* platform_captcha_field(), que é isto com HTML à volta.
|
||||
*
|
||||
* @return array{question:string, token:string, field_answer:string, field_token:string, id:string}
|
||||
*/
|
||||
function platform_captcha_issue(string $form = 'form', ?PDO $pdo = null): array
|
||||
{
|
||||
$cfg = platform_captcha_config();
|
||||
$ts = time();
|
||||
$nonce = bin2hex(random_bytes(9));
|
||||
$sig = platform_captcha_sign($form, $ts, $nonce, $pdo);
|
||||
$ch = platform_captcha_challenge($form, $nonce, $pdo);
|
||||
|
||||
return [
|
||||
'question' => $ch['question'],
|
||||
'token' => $ts . '.' . $nonce . '.' . $sig,
|
||||
'field_answer' => $cfg['field_answer'],
|
||||
'field_token' => $cfg['field_token'],
|
||||
// Único por instância: a mesma página pode ter dois captchas, e dois
|
||||
// <label for="captcha"> mandam o clique sempre para o primeiro.
|
||||
'id' => 'captcha-' . substr($nonce, 0, 8),
|
||||
];
|
||||
}
|
||||
|
||||
function platform_captcha_sign(string $form, int $ts, string $nonce, ?PDO $pdo = null): string
|
||||
{
|
||||
return hash_hmac('sha256', 't|' . $form . '|' . $ts . '|' . $nonce, platform_captcha_key($pdo));
|
||||
}
|
||||
|
||||
/**
|
||||
* O bloco pronto a imprimir dentro de um <form>.
|
||||
*
|
||||
* Sem JavaScript, sem imagens e sem dependências: um <label>, um <input> de
|
||||
* texto e um campo escondido. `inputmode="numeric"` fica de fora de propósito —
|
||||
* metade dos desafios pede uma letra, e um teclado numérico num campo onde se
|
||||
* escreve "d" é pior do que nenhum.
|
||||
*/
|
||||
function platform_captcha_field(string $form = 'form', ?PDO $pdo = null): string
|
||||
{
|
||||
$cfg = platform_captcha_config();
|
||||
$c = platform_captcha_issue($form, $pdo);
|
||||
$esc = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
||||
$cls = static fn(string $k): string => $cfg[$k] !== '' ? ' class="' . $esc($cfg[$k]) . '"' : '';
|
||||
|
||||
$html = '<div' . $cls('class_wrap') . ' data-captcha="' . $esc($form) . '">'
|
||||
. '<label for="' . $esc($c['id']) . '"' . $cls('class_label') . '>' . $esc($c['question']) . '</label>'
|
||||
. '<input type="text" id="' . $esc($c['id']) . '" name="' . $esc($c['field_answer']) . '"'
|
||||
. ' value="" required autocomplete="' . $esc($cfg['autocomplete']) . '"'
|
||||
. ' autocapitalize="off" spellcheck="false"' . $cls('class_input') . '>'
|
||||
. '<input type="hidden" name="' . $esc($c['field_token']) . '" value="' . $esc($c['token']) . '">';
|
||||
|
||||
if ($cfg['help'] !== '') {
|
||||
$html .= '<div' . $cls('class_help') . '>' . $esc($cfg['help']) . '</div>';
|
||||
}
|
||||
|
||||
return $html . '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica a resposta. Devolve null quando passa, ou a mensagem a mostrar.
|
||||
*
|
||||
* **Falha fechada**, ao contrário do portão de país: um token que não chegou é
|
||||
* um token inválido. O captcha só existe se a ausência dele for uma recusa —
|
||||
* caso contrário bastava não enviar o campo. É por isso que um site que se
|
||||
* esqueça de imprimir o campo vê o formulário recusado em vez de aberto: o erro
|
||||
* aparece ao primeiro teste, em vez de ficar uma defesa desligada em silêncio.
|
||||
*
|
||||
* A mensagem devolvida é para mostrar a quem preencheu, já em português e já
|
||||
* genérica: nunca diz qual das verificações falhou.
|
||||
*/
|
||||
function platform_captcha_verify(string $form = 'form', ?PDO $pdo = null): ?string
|
||||
{
|
||||
$cfg = platform_captcha_config();
|
||||
$expired = 'A verificação expirou. Responda à nova pergunta e envie outra vez.';
|
||||
$wrong = 'A resposta à pergunta de verificação não está certa. Tente de novo.';
|
||||
|
||||
$token = (string) ($_POST[$cfg['field_token']] ?? '');
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
platform_captcha_offence($pdo, $form, 'token em falta ou malformado', (bool) $cfg['offence_forged']);
|
||||
return $expired;
|
||||
}
|
||||
|
||||
[$ts, $nonce, $sig] = $parts;
|
||||
if (!ctype_digit($ts) || !ctype_xdigit($nonce) || !ctype_xdigit($sig)
|
||||
|| !hash_equals(platform_captcha_sign($form, (int) $ts, $nonce, $pdo), $sig)) {
|
||||
platform_captcha_offence($pdo, $form, 'token forjado', (bool) $cfg['offence_forged']);
|
||||
return $expired;
|
||||
}
|
||||
|
||||
// 60 s de folga para o futuro: o carimbo é feito no servidor, mas um relógio
|
||||
// que ande atrasado depois de um restauro não há-de recusar toda a gente até
|
||||
// alguém reparar.
|
||||
$age = time() - (int) $ts;
|
||||
if ($age > (int) $cfg['ttl'] || $age < -60) {
|
||||
return $expired;
|
||||
}
|
||||
|
||||
if (platform_captcha_used($nonce, false)) {
|
||||
return $expired;
|
||||
}
|
||||
// Marcado antes de comparar, e não depois: é o que torna cada pergunta uma
|
||||
// única tentativa. Marcar só em caso de sucesso deixava o token vivo para as
|
||||
// vinte tentativas seguintes, que é precisamente o ataque.
|
||||
platform_captcha_used($nonce, true, (int) $ts + (int) $cfg['ttl']);
|
||||
|
||||
$given = (string) ($_POST[$cfg['field_answer']] ?? '');
|
||||
if (trim($given) === '') {
|
||||
return 'Responda à pergunta de verificação.';
|
||||
}
|
||||
|
||||
$ch = platform_captcha_challenge($form, $nonce, $pdo);
|
||||
if (!hash_equals(
|
||||
platform_captcha_canonical_answer($ch['answer']),
|
||||
platform_captcha_canonical_answer($given)
|
||||
)) {
|
||||
platform_captcha_offence($pdo, $form, 'resposta errada', (bool) $cfg['offence_wrong']);
|
||||
return $wrong;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** O mesmo, em booleano, para quem já tem a sua própria mensagem. */
|
||||
function platform_captcha_ok(string $form = 'form', ?PDO $pdo = null): bool
|
||||
{
|
||||
return platform_captcha_verify($form, $pdo) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nonces já verificados, guardados na sessão.
|
||||
*
|
||||
* Usa apenas a sessão que já estiver aberta — **não** chama session_start(). Numa
|
||||
* página pública isso punha um cookie a toda a gente que passasse por um
|
||||
* formulário, o que muda a política de cookies do site e estraga qualquer cache
|
||||
* à frente. Sem sessão esta defesa não corre, e fica o ttl curto a limitar.
|
||||
*/
|
||||
function platform_captcha_used(string $nonce, bool $mark, int $expiresAt = 0): bool
|
||||
{
|
||||
$cfg = platform_captcha_config();
|
||||
if (!$cfg['single_use'] || session_status() !== PHP_SESSION_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$used = $_SESSION['platform_captcha_used'] ?? [];
|
||||
if (!is_array($used)) {
|
||||
$used = [];
|
||||
}
|
||||
// Limpeza a cada passagem: sem isto a sessão cresce para sempre num site com
|
||||
// um captcha no rodapé.
|
||||
$used = array_filter($used, static fn($exp): bool => (int) $exp > $now);
|
||||
|
||||
$seen = isset($used[$nonce]);
|
||||
if ($mark) {
|
||||
$used[$nonce] = $expiresAt > 0 ? $expiresAt : $now + 900;
|
||||
// Tecto: um bot com cookies pediria mil desafios e enchia a sessão.
|
||||
if (count($used) > 60) {
|
||||
$used = array_slice($used, -60, null, true);
|
||||
}
|
||||
}
|
||||
$_SESSION['platform_captcha_used'] = $used;
|
||||
|
||||
return $seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dá memória às falhas, quando há base de dados e o functions_ip_ban.php está
|
||||
* carregado. Silencioso e sem consequências quando não há — um captcha não há-de
|
||||
* exigir tabelas para poder fazer uma pergunta.
|
||||
*/
|
||||
function platform_captcha_offence(?PDO $pdo, string $form, string $detail, bool $enabled): void
|
||||
{
|
||||
if (!$enabled || !($pdo instanceof PDO) || !function_exists('platform_ip_record_offence')) {
|
||||
return;
|
||||
}
|
||||
platform_ip_record_offence($pdo, 'captcha:' . mb_substr($form, 0, 28), $detail, platform_client_ip());
|
||||
}
|
||||
|
||||
|
||||
// ─── O recurso: uma segunda oportunidade a quem foi recusado ────────────────
|
||||
//
|
||||
// O problema concreto, e é o que fez nascer este módulo: o portão de país
|
||||
// recusa quem chega de fora de Portugal, e isso apanha duas pessoas que não são
|
||||
// spam nenhum — quem usa o iCloud Private Relay (a Apple faz sair o tráfego por
|
||||
// endereços que as listas da ipdeny atribuem a outro país) e os emigrantes.
|
||||
// Ambos recebiam a página de obrigado e ficavam à espera de uma resposta que
|
||||
// nunca vinha, porque a submissão tinha sido deitada fora em silêncio.
|
||||
//
|
||||
// A recusa silenciosa continua a ser a coisa certa para um bot. O que estava
|
||||
// errado era não haver caminho nenhum de volta para uma pessoa. Então:
|
||||
//
|
||||
// 1. a submissão recusada é guardada inteira, com a razão;
|
||||
// 2. em vez da página de obrigado, a pessoa vê uma pergunta;
|
||||
// 3. respondendo certo, a submissão original é libertada e o site trata-a
|
||||
// como se tivesse passado à primeira.
|
||||
//
|
||||
// Três decisões que vale a pena não desfazer sem pensar:
|
||||
//
|
||||
// **Nem todos os motivos dão recurso.** O honeypot e o carimbo forjado ficam de
|
||||
// fora (ver `appeal_never`): não têm falso positivo possível, e oferecer ali uma
|
||||
// segunda tentativa é explicar a um bot o que o apanhou.
|
||||
//
|
||||
// **A submissão fica no servidor, não no browser.** O segundo ecrã só devolve a
|
||||
// resposta ao desafio. Reenviar o conteúdo num campo escondido deixava editá-lo
|
||||
// depois de ter sido pontuado — passava-se uma mensagem limpa na primeira volta
|
||||
// e o texto verdadeiro na segunda.
|
||||
//
|
||||
// **O recurso não é preso ao IP.** Seria o reflexo natural e estragava
|
||||
// exactamente o caso que isto veio resolver: o Private Relay muda o endereço de
|
||||
// saída entre pedidos, e a pessoa voltaria com um IP diferente daquele com que
|
||||
// foi recusada. O que prende o recurso é a assinatura do token.
|
||||
|
||||
/**
|
||||
* A fila de recursos. Criada à cabeça, como as tabelas do functions_ip_ban.php:
|
||||
* um site não há-de precisar de uma migração para poder desfazer um engano.
|
||||
*/
|
||||
function platform_captcha_appeal_ensure_table(PDO $pdo): bool
|
||||
{
|
||||
static $done = null;
|
||||
if ($done !== null) {
|
||||
return $done;
|
||||
}
|
||||
$cfg = platform_captcha_config();
|
||||
$table = (string) $cfg['appeal_table'];
|
||||
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
|
||||
error_log('[captcha] nome de tabela inválido, recurso desligado: ' . $table);
|
||||
return $done = false;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS `{$table}` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`form` VARCHAR(40) NOT NULL,
|
||||
`reason` VARCHAR(190) NOT NULL,
|
||||
`payload` MEDIUMTEXT NOT NULL,
|
||||
`ip` VARCHAR(45) NULL,
|
||||
`attempts` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`claimed_at` DATETIME NULL,
|
||||
KEY `idx_captcha_appeals_ip` (`ip`, `created_at`),
|
||||
KEY `idx_captcha_appeals_age` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
");
|
||||
return $done = true;
|
||||
} catch (Throwable $e) {
|
||||
// Sem tabela não há recurso, e o site comporta-se como antes: recusa
|
||||
// em silêncio. Uma segunda oportunidade que falha não pode derrubar a
|
||||
// primeira defesa.
|
||||
error_log('[captcha] tabela de recursos indisponível: ' . $e->getMessage());
|
||||
return $done = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Este motivo admite recurso? Ver `appeal_never` na configuração. */
|
||||
function platform_captcha_appeal_allowed(string $reason): bool
|
||||
{
|
||||
$lower = mb_strtolower($reason, 'UTF-8');
|
||||
foreach (platform_captcha_config()['appeal_never'] as $needle) {
|
||||
if ($needle !== '' && str_contains($lower, mb_strtolower((string) $needle, 'UTF-8'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda a submissão recusada e devolve o token do recurso — ou null quando não
|
||||
* há recurso a oferecer, e aí o site faz o que já fazia.
|
||||
*
|
||||
* Chamar logo a seguir a uma recusa, com a razão que a recusa devolveu:
|
||||
*
|
||||
* if (($reason = platform_antispam_check($pdo, 'contacto', ...)) !== null) {
|
||||
* $appeal = platform_captcha_appeal_offer($pdo, 'contacto', $reason);
|
||||
* // $appeal !== null -> mostrar o desafio (ver platform_captcha_appeal_field)
|
||||
* // $appeal === null -> página de obrigado, como sempre
|
||||
* }
|
||||
*
|
||||
* O payload por omissão é o $_POST inteiro menos os campos de mecanismo. Um site
|
||||
* que prefira guardar só o que lhe interessa passa o array que quiser.
|
||||
*/
|
||||
function platform_captcha_appeal_offer(
|
||||
PDO $pdo,
|
||||
string $form,
|
||||
string $reason,
|
||||
?array $payload = null
|
||||
): ?string {
|
||||
$cfg = platform_captcha_config();
|
||||
|
||||
if (!platform_captcha_appeal_allowed($reason) || !platform_captcha_appeal_ensure_table($pdo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = (string) $cfg['appeal_table'];
|
||||
$ip = platform_client_ip();
|
||||
|
||||
$payload ??= platform_captcha_appeal_payload();
|
||||
|
||||
try {
|
||||
// Limpeza oportunista. Os recursos são raros, portanto sai barato fazê-la
|
||||
// aqui e não há mais um cron para alguém se esquecer de instalar.
|
||||
$pdo->prepare("DELETE FROM `{$table}` WHERE created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)")
|
||||
->execute([max(3600, (int) $cfg['appeal_ttl'] * 2)]);
|
||||
|
||||
if ($ip !== null) {
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM `{$table}` WHERE ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)");
|
||||
$st->execute([$ip]);
|
||||
if ((int) $st->fetchColumn() >= (int) $cfg['appeal_max_ip']) {
|
||||
// Já teve as oportunidades que havia para dar. Sem recurso, e
|
||||
// sem dizer porquê.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->prepare("INSERT INTO `{$table}` (form, reason, payload, ip) VALUES (?, ?, ?, ?)")
|
||||
->execute([
|
||||
mb_substr($form, 0, 40),
|
||||
mb_substr($reason, 0, 190),
|
||||
(string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
$ip,
|
||||
]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[captcha] recurso não registado: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
return $id . '.' . platform_captcha_appeal_sign($id, $form, $pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* O que fica guardado de uma submissão retida: o $_POST sem os campos de
|
||||
* mecanismo — o token CSRF, as armadilhas do anti-spam e o próprio captcha.
|
||||
*
|
||||
* Nenhum deles tem sentido depois de a submissão ser libertada (o carimbo vai
|
||||
* estar velho, o honeypot vazio), e guardá-los era guardar um token válido
|
||||
* numa tabela durante duas horas sem precisar.
|
||||
*/
|
||||
function platform_captcha_appeal_payload(?array $post = null): array
|
||||
{
|
||||
$cfg = platform_captcha_config();
|
||||
$payload = $post ?? $_POST;
|
||||
|
||||
unset(
|
||||
$payload['csrf_token'], $payload['_pt_csrf'], $payload['antispam_token'],
|
||||
$payload['form_sig'], $payload['form_js'], $payload['form_ts'], $payload['website'],
|
||||
$payload[$cfg['field_answer']], $payload[$cfg['field_token']], $payload['captcha_appeal']
|
||||
);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
function platform_captcha_appeal_sign(int $id, string $form, ?PDO $pdo = null): string
|
||||
{
|
||||
return hash_hmac('sha256', 'a|' . $form . '|' . $id, platform_derived_key('captcha-appeal', $pdo));
|
||||
}
|
||||
|
||||
/**
|
||||
* O bloco do segundo ecrã: o desafio mais o token do recurso.
|
||||
*
|
||||
* O token tem de sobreviver a uma resposta errada, por isso vai num campo
|
||||
* escondido e é reimpresso a cada tentativa — com um desafio novo, porque o
|
||||
* anterior ficou gasto.
|
||||
*/
|
||||
function platform_captcha_appeal_field(string $token, string $form = 'form', ?PDO $pdo = null): string
|
||||
{
|
||||
return '<input type="hidden" name="captcha_appeal" value="'
|
||||
. htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">'
|
||||
. platform_captcha_field($form, $pdo);
|
||||
}
|
||||
|
||||
/** O token do recurso que veio no POST, para reimprimir o ecrã. */
|
||||
function platform_captcha_appeal_token(): string
|
||||
{
|
||||
return (string) ($_POST['captcha_appeal'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenta libertar a submissão retida. Três respostas possíveis:
|
||||
*
|
||||
* null e $error null — não havia recurso em curso; seguir o fluxo normal
|
||||
* null e $error com texto — havia, e falhou: reimprimir o ecrã com a mensagem
|
||||
* array — libertada. É o $_POST original, para processar
|
||||
* exactamente como se tivesse passado à primeira.
|
||||
*
|
||||
* O site continua a decidir o que fazer com ela — criar a lead, abrir o ticket,
|
||||
* enviar o email. Este módulo não sabe o que aquele formulário significa, e não
|
||||
* é aqui que isso se decide.
|
||||
*/
|
||||
function platform_captcha_appeal_claim(PDO $pdo, string $form = 'form', ?string &$error = null): ?array
|
||||
{
|
||||
$error = null;
|
||||
$cfg = platform_captcha_config();
|
||||
$token = platform_captcha_appeal_token();
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expired = 'Este pedido já não está disponível. Volte a preencher o formulário, por favor.';
|
||||
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 2 || !ctype_digit($parts[0]) || !ctype_xdigit($parts[1])
|
||||
|| !hash_equals(platform_captcha_appeal_sign((int) $parts[0], $form, $pdo), $parts[1])) {
|
||||
platform_captcha_offence($pdo, $form, 'recurso forjado', (bool) $cfg['offence_forged']);
|
||||
$error = $expired;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!platform_captcha_appeal_ensure_table($pdo)) {
|
||||
$error = $expired;
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = (int) $parts[0];
|
||||
$table = (string) $cfg['appeal_table'];
|
||||
|
||||
try {
|
||||
$st = $pdo->prepare(
|
||||
"SELECT payload, attempts, ip, created_at FROM `{$table}`
|
||||
WHERE id = ? AND form = ? AND claimed_at IS NULL
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)"
|
||||
);
|
||||
$st->execute([$id, mb_substr($form, 0, 40), (int) $cfg['appeal_ttl']]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[captcha] recurso não lido: ' . $e->getMessage());
|
||||
$error = $expired;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Já libertado, já expirado, ou nunca existiu: a mesma resposta para os três.
|
||||
if (!$row) {
|
||||
$error = $expired;
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $row['attempts'] >= (int) $cfg['appeal_tries']) {
|
||||
// Gastou as tentativas. Fecha-se o recurso em vez de o deixar aberto a
|
||||
// ser martelado até uma resposta calhar certa.
|
||||
try {
|
||||
$pdo->prepare("UPDATE `{$table}` SET claimed_at = NOW() WHERE id = ?")->execute([$id]);
|
||||
} catch (Throwable) { /* a janela fecha-a de qualquer maneira */ }
|
||||
$error = $expired;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->prepare("UPDATE `{$table}` SET attempts = attempts + 1 WHERE id = ?")->execute([$id]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[captcha] tentativa não contada: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if (($why = platform_captcha_verify($form, $pdo)) !== null) {
|
||||
$error = $why; // resposta errada: o recurso continua de pé
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $row['payload'], true);
|
||||
if (!is_array($payload)) {
|
||||
error_log('[captcha] payload do recurso ' . $id . ' ilegível');
|
||||
$error = $expired;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->prepare("UPDATE `{$table}` SET claimed_at = NOW() WHERE id = ?")->execute([$id]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[captcha] recurso ' . $id . ' libertado mas não marcado: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
platform_captcha_appeal_forgive($pdo, $form, (string) ($row['ip'] ?? ''), (string) $row['created_at']);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apaga as infracções que a recusa desta pessoa gerou.
|
||||
*
|
||||
* Sem isto o recurso era uma armadilha: cada tentativa de contacto somava uma
|
||||
* infracção, e a escada do functions_ip_ban.php bane aos três registos em 24 h —
|
||||
* ou seja, a terceira vez que um utilizador do Private Relay tentasse falar
|
||||
* connosco ficava sem acesso ao site, depois de nós próprios lhe termos
|
||||
* confirmado que era humano.
|
||||
*
|
||||
* **Não levanta banimentos**, e é a única parte disto que é uma escolha e não
|
||||
* uma consequência. Um captcha que desfizesse um banimento seria um oráculo:
|
||||
* resolve-se um desafio e recupera-se o acesso, o que é um alvo bem mais
|
||||
* apetecível do que um formulário de contacto. Quem já está banido fala com uma
|
||||
* pessoa, pelo email do ecrã de 403.
|
||||
*/
|
||||
function platform_captcha_appeal_forgive(PDO $pdo, string $form, string $ip, string $since): void
|
||||
{
|
||||
if ($ip === '' || $since === '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Só as infracções daquele formulário, daquele endereço, desde o momento
|
||||
// em que a submissão foi retida. O historial anterior fica de pé.
|
||||
$pdo->prepare(
|
||||
"DELETE FROM ip_offences
|
||||
WHERE ip = ? AND kind IN (?, ?) AND created_at >= DATE_SUB(?, INTERVAL 5 SECOND)"
|
||||
)->execute([$ip, 'antispam:' . $form, 'captcha:' . $form, $since]);
|
||||
} catch (Throwable $e) {
|
||||
// Tabela por criar (o site não usa o ip_ban) ou apagada: não é motivo
|
||||
// para não libertar a submissão, que é o que a pessoa veio buscar.
|
||||
error_log('[captcha] infracções do recurso não limpas: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user