?', [hash('sha256', $sid), time()]); if ($row) $data = json_decode($row['data'], true); } $GLOBALS['gatecraft_session_existing'] = is_array($data); if (!is_array($data)) { $sid = bin2hex(random_bytes(32)); $data = []; gatecraft_session_set_cookie($sid); } $GLOBALS['gatecraft_sid'] = $sid; sess_replace($data); // The session belongs to one WordPress user: drop it if a different user (or nobody) is now signed in. $uid = (int)get_current_user_id(); if (sess_has('_uid') && (int)sess_get('_uid') !== $uid) sess_replace([]); if ($uid) sess_set('_uid', $uid); $t = time(); if (sess_has('last_seen') && $t - (int)sess_get('last_seen') > setting_int('session_idle_minutes', 5, 1440) * 60) sess_replace([]); sess_set('last_seen', $t); register_shutdown_function(__NAMESPACE__ . '\\gatecraft_session_save'); } function gatecraft_session_set_cookie(string $sid): void { if (headers_sent()) return; setcookie(gatecraft_session_cookie(), $sid, ['expires' => 0, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax']); } function gatecraft_session_save(): void { if (empty($GLOBALS['gatecraft_session_on']) || empty($GLOBALS['gatecraft_sid'])) return; $data = sess_all(); // Do not create a stored session for visitors who have nothing worth keeping. if (count(array_diff_key($data, ['last_seen' => 1, '_uid' => 1])) === 0 && empty($GLOBALS['gatecraft_session_existing'])) return; $GLOBALS['gatecraft_session_existing'] = true; $exp = time() + setting_int('session_idle_minutes', 5, 1440) * 60; q('INSERT INTO {sessions} (id, data, expires) VALUES (?,?,?) ON DUPLICATE KEY UPDATE data = VALUES(data), expires = VALUES(expires)', [hash('sha256', $GLOBALS['gatecraft_sid']), (string)wp_json_encode($data), $exp]); } /** New session id after a privilege change (prevents session fixation). */ function gatecraft_session_regenerate(): void { if (empty($GLOBALS['gatecraft_session_on'])) gatecraft_session_start(); q('DELETE FROM {sessions} WHERE id = ?', [hash('sha256', $GLOBALS['gatecraft_sid'])]); $GLOBALS['gatecraft_sid'] = bin2hex(random_bytes(32)); gatecraft_session_set_cookie($GLOBALS['gatecraft_sid']); } function gatecraft_session_destroy(): void { if (!empty($GLOBALS['gatecraft_sid'])) q('DELETE FROM {sessions} WHERE id = ?', [hash('sha256', $GLOBALS['gatecraft_sid'])]); sess_replace([]); $GLOBALS['gatecraft_sid'] = null; if (!headers_sent()) setcookie(gatecraft_session_cookie(), '', ['expires' => time() - 3600, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax']); } function csp_nonce(): string { static $n = null; return $n ??= base64_encode(random_bytes(18)); } function send_security_headers(): void { $n = csp_nonce(); header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-$n'; style-src 'self' 'nonce-$n'; " . "img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; " . "object-src 'none'; base-uri 'none'; form-action 'self' https://accounts.google.com https://checkout.stripe.com; " . "frame-ancestors 'none'" . (is_https() ? '; upgrade-insecure-requests' : '')); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); header('Referrer-Policy: strict-origin-when-cross-origin'); header('Permissions-Policy: camera=(self), microphone=(), geolocation=(), payment=(), usb=()'); header('Cross-Origin-Opener-Policy: same-origin'); header('Cross-Origin-Resource-Policy: same-origin'); if (is_https()) header('Strict-Transport-Security: max-age=63072000; includeSubDomains'); header_remove('X-Powered-By'); } function csrf_token(): string { $t = sess_get('csrf'); if (!is_string($t) || !preg_match('/^[a-f0-9]{64}$/', $t)) { $t = bin2hex(random_bytes(32)); sess_set('csrf', $t); } return $t; } function csrf_field(): string { return '' . ''; } function csrf_verify(): void { $expired = 'Your session expired or the form was already used. Go back, refresh the page and try again.'; // 1. The WordPress nonce for the current user. Without a valid nonce the request stops here. if (!isset($_POST['_gcnonce'])) abort(403, $expired); if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_gcnonce'])), 'gatecraft')) abort(403, $expired); // 2. The per-session token, compared in constant time. It must exist on both sides and match. $own = sess_get('csrf'); $sent = isset($_POST['_csrf']) ? sanitize_text_field(wp_unslash($_POST['_csrf'])) : server('HTTP_X_CSRF_TOKEN'); if (!is_string($own) || $own === '' || $sent === '') abort(403, $expired); if (!hash_equals($own, $sent)) abort(403, $expired); // Defence in depth: reject cross-site POSTs by Origin when the browser sends it. $origin = server('HTTP_ORIGIN'); if ($origin !== '' && $origin !== 'null') { // The admin screens can live on another address than the site (a separate admin domain, or https only for admin). $o = wp_parse_url($origin); $ok = false; foreach ([home_url(), admin_url(), site_url()] as $u) { $b = wp_parse_url($u); if (($o['host'] ?? '') === ($b['host'] ?? '') && ($o['scheme'] ?? '') === ($b['scheme'] ?? '')) { $ok = true; break; } } if (!$ok) abort(403, 'Cross-site request blocked.'); } } /** Fixed-window rate limit. Returns false when the limit is exceeded. */ /** An IPv6 address as its /64 network (one customer usually controls a whole /64). Anything else unchanged. */ function ip_bucket(string $id): string { if (!filter_var($id, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) return $id; $b = inet_pton($id); return $b === false ? $id : bin2hex(substr($b, 0, 8)) . '::/64'; } function rate_limit(string $bucket, string $id, int $max, int $windowSeconds): bool { $k = hash('sha256', $bucket . '|' . ip_bucket($id)); $t = time(); $win = $t - ($t % $windowSeconds); q('INSERT INTO {rate_limits} (k, hits, window_start) VALUES (?, 1, ?) ON DUPLICATE KEY UPDATE hits = IF(window_start = VALUES(window_start), hits + 1, 1), window_start = VALUES(window_start)', [$k, $win]); return (int)qv('SELECT hits FROM {rate_limits} WHERE k = ?', [$k]) <= $max; } function rate_limit_or_abort(string $bucket, int $max, int $window, ?string $id = null): void { if (!rate_limit($bucket, $id ?? client_ip(), $max, $window)) { header('Retry-After: ' . $window); abort(429, 'Too many attempts. Please wait a few minutes and try again.'); } } /** * Built-in bot protection for the checkout form, with no outside service: a hidden field only bots fill in, * and a signed timestamp so a form posted faster than a person can type is refused. */ function spam_field(): string { // time.nonce.signature: the nonce keeps two forms shown in the same second apart, as each can be sent only once. $t = time() . '.' . bin2hex(random_bytes(8)); $sig = hash_hmac('sha256', 'gc-form|' . $t, app_key()); return '' . ''; } /** Refuses a checkout post that filled the hidden field, came too fast, or carries a forged or very old timestamp. */ function spam_check(): void { $fail = static function (string $why): never { rate_limit('checkout', client_ip(), 20, 600); // a refused post counts toward the limit on this address log_debug('checkout refused (' . $why . ')'); fail('We could not send that form. Please reload the page and try again.'); }; if (post('gc_website') !== '') $fail('hidden field filled'); // time.nonce.signature, or time.signature from pages shown before the nonce was added. $parts = explode('.', post('gc_t'), 3); $sig = (string)array_pop($parts); $signed = implode('.', $parts); if (!$parts || count($parts) > 2 || !ctype_digit($parts[0]) || (isset($parts[1]) && !preg_match('/^[a-f0-9]{16}$/', $parts[1])) || !hash_equals(hash_hmac('sha256', 'gc-form|' . $signed, app_key()), $sig)) $fail('missing or forged timestamp'); $age = time() - (int)$parts[0]; if ($age < 2) $fail('submitted too fast'); if ($age > 86400 * 2) $fail('form too old'); // Each form can be sent once, so a script cannot fetch one page and post it over and over. $used = q('INSERT IGNORE INTO {rate_limits} (k, hits, window_start) VALUES (?, 1, ?)', [hash('sha256', 'gc-form-used|' . $sig), time() + 86400 * 2]); if ($used->rowCount() === 0) $fail('form already sent'); } /** 32-byte key for encrypting 2FA secrets: GATECRAFT_APP_KEY if set, otherwise derived from the site's secret salts. */ function app_key(): string { if (defined('GATECRAFT_APP_KEY')) { $k = base64_decode((string)GATECRAFT_APP_KEY, true); if ($k !== false && strlen($k) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES) return $k; } $salt = (defined('AUTH_KEY') ? AUTH_KEY : '') . (defined('SECURE_AUTH_KEY') ? SECURE_AUTH_KEY : '') . (defined('NONCE_SALT') ? NONCE_SALT : ''); return hash_hmac('sha256', 'gatecraft-totp-key-v1', $salt, true); } function encrypt_secret(string $plain): string { $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); return $nonce . sodium_crypto_secretbox($plain, $nonce, app_key()); } /** The key derived from the site salts, still tried for secrets saved before a dedicated GATECRAFT_APP_KEY was set. */ function app_key_previous(): ?string { if (!defined('GATECRAFT_APP_KEY') || !defined('AUTH_KEY')) return null; $salt = AUTH_KEY . (defined('SECURE_AUTH_KEY') ? SECURE_AUTH_KEY : '') . (defined('NONCE_SALT') ? NONCE_SALT : ''); return hash_hmac('sha256', 'gatecraft-totp-key-v1', $salt, true); } function decrypt_secret(?string $blob): ?string { if (!$blob || strlen($blob) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) return null; $nonce = substr($blob, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); foreach (array_filter([app_key(), app_key_previous()]) as $key) { $plain = sodium_crypto_secretbox_open(substr($blob, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), $nonce, $key); if ($plain !== false) return $plain; } return null; } function audit(string $action, ?string $entity = null, ?int $entityId = null, array $details = []): void { q('INSERT INTO {audit_log} (user_id, action, entity, entity_id, details, ip, created_at) VALUES (?,?,?,?,?,?,?)', [ current_user()['id'] ?? null, $action, $entity, $entityId, $details ? wp_json_encode($details, JSON_UNESCAPED_UNICODE) : null, PHP_SAPI === 'cli' ? 'cli' : client_ip(), now(), ]); } /** Config allowlist plus the one set in Settings. Empty means unrestricted. */ function admin_allowlist(): array { $db = array_filter(array_map('trim', preg_split('/[\s,]+/', setting('admin_ip_allowlist')) ?: [])); return array_values(array_unique(array_merge((array)cfg('admin_ip_allowlist', []), $db))); } function valid_ip_or_cidr(string $v): bool { if (filter_var($v, FILTER_VALIDATE_IP)) return true; if (!str_contains($v, '/')) return false; [$ip, $bits] = explode('/', $v, 2); $max = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 32 : (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 128 : -1); return $max > 0 && ctype_digit($bits) && (int)$bits <= $max; } function ip_in_list(string $ip, array $list): bool { foreach ($list as $entry) { if (!str_contains($entry, '/')) { if ($ip === $entry) return true; continue; } [$net, $bits] = explode('/', $entry, 2); if (!filter_var($ip, FILTER_VALIDATE_IP) || !filter_var($net, FILTER_VALIDATE_IP)) continue; $ipb = inet_pton($ip); $netb = inet_pton($net); if ($ipb === false || $netb === false || strlen($ipb) !== strlen($netb)) continue; $bits = (int)$bits; $bytes = intdiv($bits, 8); $rem = $bits % 8; if (strncmp($ipb, $netb, $bytes) !== 0) continue; if ($rem === 0) return true; $mask = chr((0xFF << (8 - $rem)) & 0xFF); if ((($ipb[$bytes] & $mask) === ($netb[$bytes] & $mask))) return true; } return false; } /** Neutralise spreadsheet formula injection for CSV cells. */ function csv_safe($v): string { $v = (string)$v; return $v !== '' && str_contains("=+-@\t\r", $v[0]) ? "'" . $v : $v; }