Files
fitme3070.pt/shared/platform-tools/functions_captcha.php
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

840 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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());
}
}