- 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>
840 lines
35 KiB
PHP
840 lines
35 KiB
PHP
<?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());
|
||
}
|
||
}
|