" to inline scripts if (empty($_SESSION['csp_nonce']) || time() > ($_SESSION['csp_nonce_ts'] ?? 0) + 3600) { $_SESSION['csp_nonce'] = base64_encode(random_bytes(16)); $_SESSION['csp_nonce_ts'] = time(); } $csp_nonce = $_SESSION['csp_nonce']; // ── CSRF Token ──────────────────────────────────────────────── function csrf_token(): string { if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf_token']; } function csrf_field(): string { return ''; } function csrf_verify(): bool { $token = $_POST['csrf_token'] ?? ''; if (empty($_SESSION['csrf_token']) || !$token) return false; return hash_equals($_SESSION['csrf_token'], $token); } // ── Rate Limiting (file-based, no Redis required) ───────────── function rate_limit(string $key, int $max_hits = 10, int $window_seconds = 60): bool { $dir = sys_get_temp_dir() . '/anon_rl/'; if (!is_dir($dir)) @mkdir($dir, 0700, true); $file = $dir . md5($key) . '.json'; $now = time(); $data = ['hits' => [], 'blocked_until' => 0]; if (file_exists($file)) { $raw = @file_get_contents($file); if ($raw) $data = json_decode($raw, true) ?: $data; } // Still blocked? if ($data['blocked_until'] > $now) return false; // Remove hits outside window $data['hits'] = array_filter($data['hits'], fn($t) => $t > $now - $window_seconds); if (count($data['hits']) >= $max_hits) { $data['blocked_until'] = $now + $window_seconds * 2; // double-window block @file_put_contents($file, json_encode($data), LOCK_EX); return false; } $data['hits'][] = $now; @file_put_contents($file, json_encode($data), LOCK_EX); return true; } function rate_limit_key(): string { // Use IP + optional user agent hash for fingerprinting $ip = $_SERVER['HTTP_CF_CONNECTING_IP'] // Cloudflare real IP ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; // Only take the first IP if comma-separated $ip = trim(explode(',', $ip)[0]); return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0'; } // ── URL Validation (open redirect prevention) ───────────────── /** * Validates a URL for use in redirects. * - Must be http:// or https:// * - Must not be a data: or javascript: URI * - Optional: block private/loopback IPs (SSRF prevention for short URL resolver) */ function validate_redirect_url(string $url, bool $block_private = false): string|false { $url = trim($url); // Must start with http or https — reject everything else if (!preg_match('#^https?://#i', $url)) return false; // Reject dangerous schemes that might sneak through if (preg_match('#^(javascript|data|vbscript|file):#i', $url)) return false; // PHP's built-in filter if (!filter_var($url, FILTER_VALIDATE_URL)) return false; // Parse and validate host $parsed = parse_url($url); if (empty($parsed['host'])) return false; // Block private/loopback IPs (SSRF mitigation for URL shortener) if ($block_private) { $host = $parsed['host']; // Resolve hostname to IP $ip = @gethostbyname($host); if ($ip && filter_var($ip, FILTER_VALIDATE_IP)) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { return false; // blocked: private/reserved IP } } } return $url; } // ── Email Header Injection Prevention ──────────────────────── function sanitize_email_header(string $value): string { // Remove any newline characters that could inject headers return str_replace(["\r", "\n", "\t", '%0a', '%0d', '%0D', '%0A'], '', $value); } // ── Input Sanitization Helpers ──────────────────────────────── function clean_string(string $val, int $maxlen = 255): string { return mb_substr(trim(strip_tags($val)), 0, $maxlen); } function clean_int(mixed $val, int $min = 0, int $max = PHP_INT_MAX): int { return max($min, min($max, (int) $val)); } // ── Security Headers ────────────────────────────────────────── function send_security_headers(string $nonce = ''): void { if (headers_sent()) return; $csp = "default-src 'self'; "; $csp .= "script-src 'self' https://www.google.com https://www.gstatic.com https://www.googletagmanager.com https://apis.google.com"; if ($nonce) $csp .= " 'nonce-{$nonce}'"; $csp .= "; "; $csp .= "style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; "; $csp .= "font-src 'self' https://fonts.gstatic.com; "; $csp .= "img-src 'self' data: https:; "; $csp .= "frame-src https://www.google.com; "; $csp .= "connect-src 'self'; "; $csp .= "object-src 'none'; "; $csp .= "base-uri 'self'; "; $csp .= "form-action 'self';"; header('Content-Security-Policy: ' . $csp); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: SAMEORIGIN'); header('X-XSS-Protection: 1; mode=block'); header('Referrer-Policy: no-referrer'); header('Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()'); // Uncomment after confirming HTTPS works: // header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload'); } // ── Abort helpers ───────────────────────────────────────────── function abort(int $code, string $msg = ''): never { http_response_code($code); error_log("[Anonymiz] HTTP {$code}: {$msg} — IP: " . rate_limit_key() . " — URI: " . ($_SERVER['REQUEST_URI'] ?? '')); exit("Error {$code}" . ($msg ? ": $msg" : '')); }