<?php
declare(strict_types=1);
if (!defined('STORE_ROOT')) { http_response_code(403); exit; }
class Security { private static string $nonce = ''; public static function nonce(): string { if (self::$nonce === '') { self::$nonce = bin2hex(random_bytes(16)); } return self::$nonce; } public static function generateToken(): string { return Auth::csrf(); } public static function csrfField(): string { return '<input type="hidden" name="_csrf" value="' . Auth::csrf() . '">'; } public static function csrfMeta(): string { return '<script nonce="' . self::nonce() . '">window._csrf="' . Auth::csrf() . '";</script>'; } public static function verifyCSRF(): bool { $token = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_SERVER['HTTP_X_CSRF_T'] ?? ''; if (empty($token)) { $raw = file_get_contents('php://input'); $body = json_decode($raw, true); if ($body && isset($body['_csrf'])) { $token = $body['_csrf']; } if ($body && isset($body['csrf'])) { $token = $body['csrf']; } } return Auth::validateCsrf($token); } public static function enforceCSRF(): void { if (!self::verifyCSRF()) { http_response_code(403); if (self::isJSON()) { header('Content-Type: application/json'); exit(json_encode(['ok' => false, 'error' => 'Invalid security token. Reload page.'])); } exit('Forbidden'); } } public static function rateLimit(string $action, int $maxAttempts, int $windowSeconds): bool { $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $now = time(); try { DB::run("DELETE FROM rate_limits WHERE expires_at < NOW()"); $count = (int)DB::val("SELECT COUNT(*) FROM rate_limits WHERE ip = :ip AND action = :a AND created_at > DATE_SUB(NOW(), INTERVAL :w SECOND)", [':ip'=>$ip,':a'=>$action,':w'=>$windowSeconds]); if ($count >= $maxAttempts) return false; DB::run("INSERT INTO rate_limits (ip, action, created_at, expires_at) VALUES (:ip, :a, NOW(), DATE_ADD(NOW(), INTERVAL :w SECOND))", [':ip'=>$ip,':a'=>$action,':w'=>$windowSeconds]); return true; } catch (\Throwable $e) { $key = 'rate_' . $action; if (!isset($_SESSION[$key])) $_SESSION[$key] = []; $_SESSION[$key] = array_filter($_SESSION[$key], fn($ts) => ($now - $ts) < $windowSeconds); if (count($_SESSION[$key]) >= $maxAttempts) return false; $_SESSION[$key][] = $now; return true; } } public static function enforceRateLimit(string $action, int $max, int $window): void { if (!self::rateLimit($action, $max, $window)) { http_response_code(429); if (self::isJSON()) { header('Content-Type: application/json'); exit(json_encode(['ok' => false, 'error' => 'Too many requests. Try again later.'])); } exit('Rate limited'); } } public static function sendHeaders(): void { $n = self::nonce(); header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$n}' https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self'"); header("X-Content-Type-Options: nosniff"); header("X-Frame-Options: DENY"); header("X-XSS-Protection: 1; mode=block"); header("Referrer-Policy: strict-origin-when-cross-origin"); header("Permissions-Policy: camera=(), microphone=(), geolocation=()"); header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload"); header("Cross-Origin-Opener-Policy: same-origin"); header("Cross-Origin-Embedder-Policy: unsafe-none"); header("Cross-Origin-Resource-Policy: same-origin"); } public static function trackCampaignHit(): void { $httpHost = $_SERVER['HTTP_HOST'] ?? ''; if ($httpHost === '') return; try { $dom = DB::one("SELECT id FROM store_domains WHERE domain = :d AND is_active = 1", [':d' => $httpHost]); if (!$dom) return; DB::run( "INSERT INTO campaign_hits (domain_id, session_id, ip_hash, user_agent, landing_page, referrer, hit_at) VALUES (:did, :sid, :iph, :ua, :lp, :ref, NOW())", [':did'=>(int)$dom['id'],':sid'=>session_id(),':iph'=>hash('sha256',$_SERVER['REMOTE_ADDR']??''),':ua'=>substr($_SERVER['HTTP_USER_AGENT']??'',0,500),':lp'=>substr($_SERVER['REQUEST_URI']??'',0,255),':ref'=>substr($_SERVER['HTTP_REFERER']??'',0,500)] ); } catch (\Throwable $e) {} } public static function trackConversion(int $orderId): void { } public static function clean(string $v, int $maxLen = 500): string { return substr(trim(strip_tags($v)), 0, $maxLen); } public static function cleanEmail(string $v): string { return strtolower(trim(filter_var($v, FILTER_SANITIZE_EMAIL))); } private static function isJSON(): bool { return str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json') || str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') || str_contains($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '', 'XMLHttpRequest'); } }
