Add Turnstile protection and harden export workflow
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Shared configuration, storage and abuse-protection helpers.
|
||||
*
|
||||
* Secrets are read from environment variables. The legacy env_vars.php file is
|
||||
* loaded when present so existing installations keep working during migration.
|
||||
*/
|
||||
$legacyEnvFile = __DIR__ . '/env_vars.php';
|
||||
if (is_file($legacyEnvFile)) {
|
||||
require_once $legacyEnvFile;
|
||||
}
|
||||
|
||||
function app_env(string $name, string $default = ''): string
|
||||
{
|
||||
$value = getenv($name);
|
||||
return $value === false ? $default : trim((string)$value);
|
||||
}
|
||||
|
||||
function app_env_int(string $name, int $default, int $min, int $max): int
|
||||
{
|
||||
$value = filter_var(app_env($name), FILTER_VALIDATE_INT);
|
||||
if ($value === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return max($min, min($max, (int)$value));
|
||||
}
|
||||
|
||||
function app_private_dir(): string
|
||||
{
|
||||
$configured = app_env('ZEFIX_PRIVATE_DIR');
|
||||
return $configured !== ''
|
||||
? rtrim($configured, '/\\')
|
||||
: dirname(__DIR__) . DIRECTORY_SEPARATOR . 'zefix-private';
|
||||
}
|
||||
|
||||
function app_ensure_dir(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('Private storage could not be created.');
|
||||
}
|
||||
}
|
||||
|
||||
function app_task_dir(): string
|
||||
{
|
||||
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'tasks';
|
||||
app_ensure_dir($directory);
|
||||
return $directory;
|
||||
}
|
||||
|
||||
function app_download_dir(): string
|
||||
{
|
||||
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'downloads';
|
||||
app_ensure_dir($directory);
|
||||
return $directory;
|
||||
}
|
||||
|
||||
function app_rate_limit_dir(): string
|
||||
{
|
||||
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'rate-limits';
|
||||
app_ensure_dir($directory);
|
||||
return $directory;
|
||||
}
|
||||
|
||||
function app_cache_dir(): string
|
||||
{
|
||||
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'cache';
|
||||
app_ensure_dir($directory);
|
||||
return $directory;
|
||||
}
|
||||
|
||||
function app_send_security_headers(): void
|
||||
{
|
||||
if (headers_sent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
header("Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com; upgrade-insecure-requests");
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
|
||||
header('Cross-Origin-Opener-Policy: same-origin');
|
||||
}
|
||||
|
||||
function app_turnstile_site_key(): string
|
||||
{
|
||||
return '0x4AAAAAAD7CJrWpNiK0-A7h';
|
||||
}
|
||||
|
||||
function app_turnstile_secret_key(): string
|
||||
{
|
||||
return app_env('TURNSTILE_SECRET');
|
||||
}
|
||||
|
||||
function app_turnstile_is_configured(): bool
|
||||
{
|
||||
return app_turnstile_site_key() !== '' && app_turnstile_secret_key() !== '';
|
||||
}
|
||||
|
||||
function app_security_secret(): string
|
||||
{
|
||||
$secret = app_env('APP_SECRET');
|
||||
if ($secret === '') {
|
||||
$secret = app_turnstile_secret_key();
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
function app_base64url_encode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
function app_base64url_decode(string $value): string|false
|
||||
{
|
||||
$padding = strlen($value) % 4;
|
||||
if ($padding !== 0) {
|
||||
$value .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
return base64_decode(strtr($value, '-_', '+/'), true);
|
||||
}
|
||||
|
||||
function app_create_form_started_token(?int $timestamp = null): string
|
||||
{
|
||||
$secret = app_security_secret();
|
||||
if ($secret === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$timestamp ??= time();
|
||||
$payload = (string)$timestamp;
|
||||
$signature = hash_hmac('sha256', $payload, $secret, true);
|
||||
return app_base64url_encode($payload . '.' . $signature);
|
||||
}
|
||||
|
||||
function app_validate_form_started_token(string $token): bool
|
||||
{
|
||||
$secret = app_security_secret();
|
||||
$decoded = app_base64url_decode($token);
|
||||
if ($secret === '' || $decoded === false || strlen($decoded) < 34) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$separator = strpos($decoded, '.');
|
||||
if ($separator === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = substr($decoded, 0, $separator);
|
||||
$signature = substr($decoded, $separator + 1);
|
||||
if (!ctype_digit($payload) || strlen($signature) !== 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected = hash_hmac('sha256', $payload, $secret, true);
|
||||
if (!hash_equals($expected, $signature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$age = time() - (int)$payload;
|
||||
$minimumSeconds = app_env_int('FORM_MIN_SECONDS', 2, 0, 60);
|
||||
$maximumSeconds = app_env_int('FORM_MAX_SECONDS', 7200, 60, 86400);
|
||||
return $age >= $minimumSeconds && $age <= $maximumSeconds;
|
||||
}
|
||||
|
||||
function app_client_ip(): string
|
||||
{
|
||||
$remoteAddress = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
if (!filter_var($remoteAddress, FILTER_VALIDATE_IP)) {
|
||||
$remoteAddress = 'unknown';
|
||||
}
|
||||
|
||||
$trustedProxies = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', app_env('TRUSTED_PROXY_IPS'))
|
||||
)));
|
||||
|
||||
if (!in_array($remoteAddress, $trustedProxies, true)) {
|
||||
return $remoteAddress;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
||||
$candidates[] = trim((string)$_SERVER['HTTP_CF_CONNECTING_IP']);
|
||||
}
|
||||
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$forwarded = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
$candidates[] = trim($forwarded[0]);
|
||||
}
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $remoteAddress;
|
||||
}
|
||||
|
||||
/** @return array{allowed: bool, retry_after: int} */
|
||||
function app_rate_limit(string $scope, string $identity, int $limit, int $windowSeconds): array
|
||||
{
|
||||
$now = time();
|
||||
$secret = app_security_secret();
|
||||
$hash = $secret !== ''
|
||||
? hash_hmac('sha256', strtolower($identity), $secret)
|
||||
: hash('sha256', strtolower($identity));
|
||||
$filename = app_rate_limit_dir() . DIRECTORY_SEPARATOR . preg_replace('/[^a-z0-9_-]/i', '_', $scope) . '-' . $hash . '.json';
|
||||
$handle = fopen($filename, 'c+');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Rate-limit storage could not be opened.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($handle, LOCK_EX)) {
|
||||
throw new RuntimeException('Rate-limit storage could not be locked.');
|
||||
}
|
||||
|
||||
$contents = stream_get_contents($handle);
|
||||
$state = $contents !== false && $contents !== '' ? json_decode($contents, true) : null;
|
||||
if (!is_array($state) || !isset($state['started'], $state['count']) || ($now - (int)$state['started']) >= $windowSeconds) {
|
||||
$state = ['started' => $now, 'count' => 0];
|
||||
}
|
||||
|
||||
$allowed = (int)$state['count'] < $limit;
|
||||
if ($allowed) {
|
||||
$state['count'] = (int)$state['count'] + 1;
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
ftruncate($handle, 0);
|
||||
fwrite($handle, json_encode($state, JSON_THROW_ON_ERROR));
|
||||
fflush($handle);
|
||||
@chmod($filename, 0600);
|
||||
flock($handle, LOCK_UN);
|
||||
|
||||
return [
|
||||
'allowed' => $allowed,
|
||||
'retry_after' => max(1, $windowSeconds - ($now - (int)$state['started'])),
|
||||
];
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{success: bool, error: string} */
|
||||
function app_verify_turnstile(string $token, string $remoteIp): array
|
||||
{
|
||||
$secret = app_turnstile_secret_key();
|
||||
if ($secret === '' || $token === '' || strlen($token) > 2048) {
|
||||
return ['success' => false, 'error' => 'missing-input'];
|
||||
}
|
||||
|
||||
$curl = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
|
||||
if ($curl === false) {
|
||||
return ['success' => false, 'error' => 'internal-error'];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'secret' => $secret,
|
||||
'response' => $token,
|
||||
];
|
||||
if ($remoteIp !== 'unknown') {
|
||||
$payload['remoteip'] = $remoteIp;
|
||||
}
|
||||
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($payload),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($curl);
|
||||
$httpCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($curl);
|
||||
curl_close($curl);
|
||||
|
||||
if (!is_string($response) || $httpCode !== 200) {
|
||||
error_log('Turnstile request failed: HTTP ' . $httpCode . ($curlError !== '' ? ' / ' . $curlError : ''));
|
||||
return ['success' => false, 'error' => 'verification-unavailable'];
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (!is_array($result) || ($result['success'] ?? false) !== true) {
|
||||
$errors = is_array($result['error-codes'] ?? null) ? implode(',', $result['error-codes']) : 'invalid-response';
|
||||
error_log('Turnstile validation rejected: ' . $errors);
|
||||
return ['success' => false, 'error' => 'challenge-failed'];
|
||||
}
|
||||
|
||||
$officialTestSecrets = [
|
||||
'1x0000000000000000000000000000000AA',
|
||||
'2x0000000000000000000000000000000AA',
|
||||
'3x0000000000000000000000000000000AA',
|
||||
];
|
||||
$isLocalDevelopmentTest = app_env('APP_ENV') === 'development'
|
||||
&& in_array($remoteIp, ['127.0.0.1', '::1'], true)
|
||||
&& in_array($secret, $officialTestSecrets, true);
|
||||
|
||||
// Cloudflare's dummy testing responses do not always contain the widget's
|
||||
// hostname and action. This exception is restricted to loopback requests,
|
||||
// development mode and Cloudflare's documented test secrets.
|
||||
if ($isLocalDevelopmentTest) {
|
||||
return ['success' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
$allowedHostname = strtolower(app_env('TURNSTILE_ALLOWED_HOSTNAME', 'zefix.silias.ch'));
|
||||
$hostname = strtolower((string)($result['hostname'] ?? ''));
|
||||
if ($allowedHostname !== '' && !hash_equals($allowedHostname, $hostname)) {
|
||||
error_log('Turnstile hostname mismatch.');
|
||||
return ['success' => false, 'error' => 'hostname-mismatch'];
|
||||
}
|
||||
|
||||
if (($result['action'] ?? '') !== 'turnstile-spin-v2') {
|
||||
error_log('Turnstile action mismatch.');
|
||||
return ['success' => false, 'error' => 'action-mismatch'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
/** @return int[]|null */
|
||||
function app_normalize_positive_id_list(mixed $value, int $maximumItems): ?array
|
||||
{
|
||||
if (!is_array($value) || $value === [] || count($value) > $maximumItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($value as $item) {
|
||||
if (is_array($item) || !is_scalar($item)) {
|
||||
return null;
|
||||
}
|
||||
$item = (string)$item;
|
||||
if (!ctype_digit($item)) {
|
||||
return null;
|
||||
}
|
||||
$integer = (int)$item;
|
||||
if ($integer <= 0) {
|
||||
return null;
|
||||
}
|
||||
$normalized[$integer] = $integer;
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
function app_public_base_url(): string
|
||||
{
|
||||
return rtrim(app_env('PUBLIC_BASE_URL', 'https://zefix.silias.ch'), '/');
|
||||
}
|
||||
|
||||
function app_download_path(string $token): string
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
|
||||
throw new InvalidArgumentException('Invalid download token.');
|
||||
}
|
||||
return app_download_dir() . DIRECTORY_SEPARATOR . $token . '.csv';
|
||||
}
|
||||
|
||||
function app_cached_string(string $key, int $ttlSeconds, callable $loader): string
|
||||
{
|
||||
$safeKey = preg_replace('/[^a-z0-9_-]/i', '_', $key);
|
||||
$filename = app_cache_dir() . DIRECTORY_SEPARATOR . $safeKey . '.cache';
|
||||
if (is_file($filename) && filemtime($filename) !== false && filemtime($filename) >= time() - $ttlSeconds) {
|
||||
$cached = file_get_contents($filename);
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$lock = fopen($filename . '.lock', 'c+');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
return (string)$loader();
|
||||
}
|
||||
|
||||
try {
|
||||
clearstatcache(true, $filename);
|
||||
if (is_file($filename) && filemtime($filename) !== false && filemtime($filename) >= time() - $ttlSeconds) {
|
||||
return (string)file_get_contents($filename);
|
||||
}
|
||||
|
||||
$fresh = (string)$loader();
|
||||
if ($fresh !== '') {
|
||||
$temporary = $filename . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||
file_put_contents($temporary, $fresh, LOCK_EX);
|
||||
@chmod($temporary, 0600);
|
||||
if (!@rename($temporary, $filename)) {
|
||||
// Windows cannot atomically replace an existing file with rename().
|
||||
@unlink($filename);
|
||||
if (!@rename($temporary, $filename)) {
|
||||
@unlink($temporary);
|
||||
}
|
||||
}
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
return is_file($filename) ? (string)file_get_contents($filename) : '';
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
function app_cleanup_directory(string $directory, int $maximumAgeSeconds, string $extension): int
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
$cutoff = time() - $maximumAgeSeconds;
|
||||
foreach (glob($directory . DIRECTORY_SEPARATOR . '*.' . $extension) ?: [] as $filename) {
|
||||
$modified = filemtime($filename);
|
||||
if ($modified !== false && $modified < $cutoff && is_file($filename) && unlink($filename)) {
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
function app_migrate_legacy_tasks(): int
|
||||
{
|
||||
$legacyDirectory = __DIR__ . DIRECTORY_SEPARATOR . 'tasks';
|
||||
if (!is_dir($legacyDirectory)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$migrated = 0;
|
||||
foreach (glob($legacyDirectory . DIRECTORY_SEPARATOR . '*.json') ?: [] as $source) {
|
||||
$target = app_task_dir() . DIRECTORY_SEPARATOR . basename($source);
|
||||
if (is_file($target)) {
|
||||
continue;
|
||||
}
|
||||
if (@rename($source, $target) || (@copy($source, $target) && @unlink($source))) {
|
||||
@chmod($target, 0600);
|
||||
$migrated++;
|
||||
}
|
||||
}
|
||||
return $migrated;
|
||||
}
|
||||
Reference in New Issue
Block a user