.v4 and .v6. It is committed to the // repo so a deploy never depends on that host being up, and a cron refresh // keeps it current — allocations move slowly, monthly is plenty. // // Failure posture is fail-OPEN, deliberately. A missing or corrupt data file // means "allow and log loudly", never "lock everyone out". Getting this // backwards turns a stale file into a total outage; the worst case of failing // open is that the spam gets through the way it already does today. Set // GEO_FAIL_CLOSED=true to invert it, if a platform would rather refuse. require_once __DIR__ . '/functions_client_ip.php'; // platform_client_ip() const GEO_DATA_DIR = __DIR__ . '/data/geo'; /** off | forms | site */ function geo_mode(): string { $m = strtolower(trim((string) geo_env('GEO_MODE', 'off'))); return in_array($m, ['forms', 'site'], true) ? $m : 'off'; } /** Country codes that may reach us, upper-case. Empty means "no gate". */ function geo_allowed_countries(): array { $raw = (string) geo_env('GEO_ALLOW_COUNTRIES', ''); $out = []; foreach (preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $cc) { $cc = strtoupper($cc); if (preg_match('/^[A-Z]{2}$/', $cc)) { $out[] = $cc; } } return array_values(array_unique($out)); } function geo_fail_closed(): bool { return filter_var(geo_env('GEO_FAIL_CLOSED', 'false'), FILTER_VALIDATE_BOOLEAN); } /** * Config value: primeiro a base de dados, depois o .env, depois o default. * * A base ganha para a página do backoffice servir para alguma coisa -- mudar o * modo no .env obriga a recriar o contentor, o que não é coisa que se peça a * quem só quer desligar o filtro porque um cliente ficou de fora. * * Uma linha vazia conta como "não definido" e cai para o .env. É de propósito: * em `platform_settings_get()` uma string vazia gravada silenciosamente ganha a * um .env preenchido, e essa foi das armadilhas mais caras desta plataforma. * Aqui vazio quer mesmo dizer vazio. * * O $pdo é apanhado do global porque este ficheiro é carregado por caminhos que * ainda não têm ligação (o guard do site corre cedo). Sem base, usa o .env. * * A cache é por pedido. A página de configuração grava e redirige, por isso o * pedido seguinte já lê o valor novo -- não é preciso invalidá-la à mão. */ function geo_env(string $key, string $default = ''): string { static $dbCache = []; if (!array_key_exists($key, $dbCache)) { global $pdo; // So se guarda em cache o que a base de dados chegou mesmo a responder. // // Guardar tambem o falhanco parecia inofensivo e nao era: o guard do // site corre cedo no bootstrap, antes de o functions_settings.php estar // carregado, e a primeira pergunta -- GEO_MODE -- apanhava sempre o // caminho sem base. O vazio ficava colado ao pedido inteiro, por isso o // modo lia-se do .env (logo "off") enquanto a lista de paises, pedida // mais tarde, ja vinha certa da base. O interruptor da pagina gravava e // nao acendia nada, e a assimetria entre os dois valores tornava a // avaria dificil de ver. $consultada = false; $valor = ''; if ($pdo instanceof PDO && function_exists('platform_settings_get')) { try { $valor = trim((string) platform_settings_get($pdo, strtolower($key), '')); $consultada = true; } catch (Throwable) { $consultada = false; } } if (!$consultada) { // Sem base ainda: responde-se pelo .env desta vez e tenta-se de novo // na proxima chamada, que pode ja ter ligacao. return geo_env_fallback($key, $default); } $dbCache[$key] = $valor; } if ($dbCache[$key] !== '') { return $dbCache[$key]; } return geo_env_fallback($key, $default); } /** O valor do .env (ou do ambiente), quando a base nao tem nada a dizer. */ function geo_env_fallback(string $key, string $default = ''): string { if (function_exists('env')) { $v = (string) env($key, ''); if ($v !== '') { return $v; } return $default; } $v = getenv($key); if ($v === false || $v === '') { $v = $_ENV[$key] ?? $default; } return (string) $v; } /** * Ranges for one country, as [startBinary, endBinary] pairs sorted ascending. * * Addresses are compared as the packed bytes inet_pton() produces, so the same * strcmp() works for v4 (4 bytes) and v6 (16) without any integer arithmetic — * and without the signed-32-bit trap ip2long() sets for anything above * 127.255.255.255. */ function geo_ranges(string $cc, bool $v6): array { static $cache = []; $key = $cc . ($v6 ? '.v6' : '.v4'); if (isset($cache[$key])) { return $cache[$key]; } $file = GEO_DATA_DIR . '/' . strtolower($cc) . ($v6 ? '.v6' : '.v4'); if (!is_file($file)) { return $cache[$key] = []; } $ranges = []; foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { $line = trim($line); if ($line === '' || $line[0] === '#') { continue; } $r = geo_cidr_to_range($line); if ($r !== null) { $ranges[] = $r; } } usort($ranges, fn($a, $b) => strcmp($a[0], $b[0])); return $cache[$key] = $ranges; } /** "2.80.0.0/14" → [packed first address, packed last address], or null. */ function geo_cidr_to_range(string $cidr): ?array { $parts = explode('/', $cidr, 2); $net = $parts[0]; $packed = @inet_pton($net); if ($packed === false) { return null; } $bits = strlen($packed) * 8; $prefix = isset($parts[1]) ? (int) $parts[1] : $bits; if ($prefix < 0 || $prefix > $bits) { return null; } // Set every host bit to get the top of the range; the network address as // given is the bottom. Done byte by byte so v4 and v6 share the code. $end = $packed; $host = $bits - $prefix; for ($i = strlen($end) - 1; $i >= 0 && $host > 0; $i--) { $take = min(8, $host); $mask = (1 << $take) - 1; $end[$i] = chr(ord($end[$i]) | $mask); $host -= $take; } return [$packed, $end]; } /** Is this address inside one of the country's ranges? */ function geo_ip_in_country(string $ip, string $cc): bool { $packed = @inet_pton($ip); if ($packed === false) { return false; } $ranges = geo_ranges($cc, strlen($packed) === 16); if (!$ranges) { return false; } $lo = 0; $hi = count($ranges) - 1; while ($lo <= $hi) { $mid = intdiv($lo + $hi, 2); if (strcmp($packed, $ranges[$mid][0]) < 0) { $hi = $mid - 1; } elseif (strcmp($packed, $ranges[$mid][1]) > 0) { $lo = $mid + 1; } else { return true; } } return false; } /** * Loopback, private and link-local addresses always pass. * * Behind NPM the container talks to itself for health checks and cron, and a * platform whose own cron is geo-blocked fails in ways that take a long time to * understand. */ function geo_ip_is_internal(string $ip): bool { if ($ip === '') { return true; } return !filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } // ─── Duas maneiras de não bloquear quem não devia ser bloqueado ───────────── // // O portão por país compara o endereço com as alocações da ipdeny, e isso // pressupõe uma coisa que já não é verdade para toda a gente: que o endereço de // saída fica no país de quem navega. Dois casos em que não fica, e ambos // custaram leads reais: // // 1. **iCloud Private Relay.** O tráfego do Safari sai por endereços da Apple // (e dos parceiros dela), registados onde a Apple os registou e não onde // está a pessoa. Alguém em Aveiro aparece-nos como estrangeiro, é recusado // em silêncio, e fica à espera de uma resposta que nunca vem. // 2. **VPNs de empresa, o escritório do cliente, um serviço de monitorização.** // Casos que ninguém adivinha de antemão e que aparecem um de cada vez. // // Daí duas listas, com propósitos diferentes: // // GEO_ALLOW_IPS à mão, para o caso concreto que apareceu hoje. // data/geo/icloud-relay.* automática, e com o país lá dentro. // // A diferença da segunda é que **não é uma whitelist**: a Apple publica, para // cada gama de saída, o país que ela serve. Um utilizador de Relay em Portugal // passa a contar como PT; um em França continua a contar como FR e o portão // recusa-o na mesma. Uma whitelist cega às gamas do Relay abria o portão a // qualquer subscritor do iCloud+ no mundo inteiro, que é precisamente o que o // portão existe para não fazer. /** Gamas que passam sempre, escritas à mão. Vazio = não há lista. */ function geo_allow_ips(): array { $raw = (string) geo_env('GEO_ALLOW_IPS', ''); $out = []; foreach (preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $entry) { $r = geo_cidr_to_range($entry); if ($r !== null) { $out[] = $r; } else { error_log('[geo] GEO_ALLOW_IPS: entrada inválida ignorada: ' . $entry); } } return $out; } /** * Está na lista escrita à mão? * * Percorre tudo em vez de procurar por bisseção, ao contrário das listas de * país: isto tem meia dúzia de linhas escritas por uma pessoa, e ordenar um * array de cinco elementos para poupar três comparações seria código a mais * para quem vier a seguir. */ function geo_ip_allowlisted(?string $ip): bool { if ($ip === null || $ip === '') { return false; } $packed = @inet_pton($ip); if ($packed === false) { return false; } foreach (geo_allow_ips() as [$start, $end]) { if (strlen($start) === strlen($packed) && strcmp($packed, $start) >= 0 && strcmp($packed, $end) <= 0) { return true; } } return false; } /** O Relay é para ter em conta? Desligar faz o portão voltar a vê-lo como estrangeiro. */ function geo_trust_relay(): bool { return filter_var(geo_env('GEO_TRUST_RELAY', 'true'), FILTER_VALIDATE_BOOLEAN); } /** * Este endereço é uma saída do Private Relay ao serviço deste país? * * As gamas vivem em `relay-.v4|.v6`, no mesmo formato e lidas pelo mesmo * código que as listas da ipdeny — daí o `geo_ip_in_country()` com um prefixo * em vez de um leitor novo. Foram filtradas por país à nascença: a lista que a * Apple publica traz 42 mil gamas IPv4 e 246 mil IPv6 (855 KB e 6,7 MB), e * carregar isso a cada pedido para responder a uma pergunta sobre Portugal — * 611 gamas — não se justifica. * * Sem ficheiro devolve false, e o portão comporta-se exactamente como antes de * isto existir. É de propósito: uma lista por descarregar não pode ser motivo * para deixar entrar alguém que o operador quis barrar. */ function geo_relay_serves(string $ip, string $cc): bool { return geo_ip_in_country($ip, 'relay-' . strtolower($cc)); } /** Há listas do Relay instaladas para estes países? A página usa isto para avisar. */ function geo_relay_available(array $countries): bool { foreach ($countries as $cc) { if (geo_ranges('relay-' . strtolower($cc), false) || geo_ranges('relay-' . strtolower($cc), true)) { return true; } } return false; } /** * Why this address should be refused, or null when it may pass. * * Returns the reason rather than a bool so the caller can log it next to the * antispam reasons, which read the same way. */ function geo_block_reason(?string $ip = null): ?string { if (geo_mode() === 'off') { return null; } $allowed = geo_allowed_countries(); if (!$allowed) { return null; // gate configured but no list: inert } $ip ??= platform_client_ip() ?? ''; if (geo_ip_is_internal($ip)) { return null; } // A lista escrita à mão manda em tudo o resto, incluindo no fail-closed // abaixo: é a única defesa que continua a funcionar quando os dados de país // faltam, e é para isso que serve — desbloquear alguém agora, sem esperar // por um download. if (geo_ip_allowlisted($ip)) { return null; } // Saída do Private Relay ao serviço de um país permitido. Corre antes das // listas da ipdeny porque para uma gama da Apple a resposta delas é onde a // Apple registou o endereço, que não diz nada sobre quem está a navegar. // // Uma saída que sirva outro país não é tratada à parte: cai no ciclo abaixo // e é recusada como qualquer endereço estrangeiro. Isto **não** é uma // whitelist do Relay — abrir todas as gamas dele deixava entrar qualquer // subscritor do iCloud+ no mundo inteiro, que é precisamente o que o portão // existe para não fazer. if (geo_trust_relay()) { foreach ($allowed as $cc) { if (geo_relay_serves($ip, $cc)) { return null; } } } // No usable data for any allowed country is a deployment fault, not a // visitor's. Say so in the log and let them through. $haveData = false; foreach ($allowed as $cc) { if (geo_ranges($cc, false) || geo_ranges($cc, true)) { $haveData = true; break; } } if (!$haveData) { error_log('[geo] sem dados para ' . implode(',', $allowed) . ' em ' . GEO_DATA_DIR . ' -- correr bin/geo-update.sh'); return geo_fail_closed() ? 'geo: dados indisponíveis' : null; } foreach ($allowed as $cc) { if (geo_ip_in_country($ip, $cc)) { return null; } } return 'origem fora de ' . implode('/', $allowed); } /** * Gate for a public form POST. Returns the reason, or null to carry on. * * Only bites under GEO_MODE=forms or site — the caller decides what to do with * the reason, exactly as it does with antispam_check(). */ function geo_form_block_reason(?string $ip = null): ?string { return geo_mode() === 'off' ? null : geo_block_reason($ip); } /** * Gate for the whole platform. Call once, early, from the bootstrap. * * Sends 403 and stops. Under GEO_MODE=forms this does nothing, so it is safe to * wire in permanently and switch modes from the env alone. */ function geo_guard_site(): void { if (geo_mode() !== 'site') { return; } $reason = geo_block_reason(); if ($reason === null) { return; } http_response_code(403); header('Content-Type: text/html; charset=utf-8'); // Tell caches this is about who asked, not about the URL. header('Vary: X-Forwarded-For'); echo '' . 'Indisponível na sua região' . '
' . '

Indisponível na sua região

' . '

Este site serve apenas visitantes em Portugal. Se acha que se trata de ' . 'um engano, contacte-nos por email.

'; exit; } // ─── Atribuição de país, para a analítica ─────────────────────────────────── // // O bloqueio acima responde a "este endereço está na lista?" e só precisa dos // países permitidos. A analítica faz a pergunta ao contrário -- "de que país // veio isto?" -- e para isso é preciso o mundo inteiro. // // O índice é gerado por bin/geo-build-index.php e não é versionado (ver o // cabeçalho desse ficheiro). Sem ele, isto devolve null e quem chama regista o // país como desconhecido: para uma estatística é um degradar aceitável, ao // contrário do bloqueio, onde dados em falta seriam uma decisão de segurança. /** * Código ISO do país deste endereço, ou null se não se souber. * * Busca binária por fseek() sobre registos de largura fixa. São centenas de * milhares de ranges: carregá-los para memória a cada visita custaria mais do * que a página que se está a servir. */ function geo_ip_country(?string $ip): ?string { if ($ip === null || $ip === '') { return null; } $packed = @inet_pton($ip); if ($packed === false) { return null; } $len = strlen($packed); // 4 (v4) ou 16 (v6) $file = GEO_DATA_DIR . '/country-index.' . ($len === 16 ? 'v6' : 'v4'); $rec = $len * 2 + 2; // início + fim + código static $handles = []; if (!array_key_exists($file, $handles)) { $handles[$file] = is_file($file) ? (fopen($file, 'rb') ?: null) : null; } $fh = $handles[$file]; if ($fh === null) { return null; } $size = fstat($fh)['size'] ?? 0; $n = intdiv((int) $size, $rec); if ($n === 0) { return null; } $lo = 0; $hi = $n - 1; while ($lo <= $hi) { $mid = intdiv($lo + $hi, 2); if (fseek($fh, $mid * $rec) !== 0) { return null; } $row = fread($fh, $rec); if ($row === false || strlen($row) !== $rec) { return null; } $start = substr($row, 0, $len); $end = substr($row, $len, $len); if (strcmp($packed, $start) < 0) { $hi = $mid - 1; } elseif (strcmp($packed, $end) > 0) { $lo = $mid + 1; } else { return substr($row, $len * 2, 2); } } return null; } /** Há índice de países instalado? A página de analítica usa isto para avisar. */ function geo_country_index_available(): bool { return is_file(GEO_DATA_DIR . '/country-index.v4'); }