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:
andreandClaude Opus 5.5 committed 2026-09-25 07:28:13 +01:00
1 parent 5e300fc341
commit 0e8b53f620
65 files changed
+5852 -455

No files matched your search

+839
View File
@@ -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());
}
}