*/ private static array $verified = []; /** @return array> */ public static function all(): array { $stored = get_option(self::OPTION, []); return is_array($stored) ? $stored : []; } /** @return array{ok:true,fix:array}|array{ok:false,error:string} */ public static function keep(string $patchId): array { if (!preg_match('~^[0-9a-f-]{36}$~', $patchId)) { return ['ok' => false, 'error' => __('That fix could not be found.', 'sarcio')]; } if (!class_exists(Verifier::class)) { return ['ok' => false, 'error' => __('This copy of the plugin cannot check signatures, so it cannot keep fixes.', 'sarcio')]; } $siteKey = (string) Options::get('site_key'); $secret = (string) Options::get('sidecar_key'); $api = Options::apiUrl(); if ($siteKey === '' || $secret === '') { return ['ok' => false, 'error' => __('Connect this site to Sarcio first.', 'sarcio')]; } $response = wp_remote_post(sprintf('%s/api/sites/%s/patches/%s/keep', $api, rawurlencode($siteKey), rawurlencode($patchId)), [ 'headers' => ['Authorization' => 'Bearer ' . $secret], 'redirection' => 0, 'timeout' => 10, ]); $status = is_wp_error($response) ? 0 : (int) wp_remote_retrieve_response_code($response); $body = $status === 0 ? null : json_decode((string) wp_remote_retrieve_body($response), true); if (($status !== 200 && $status !== 201) || !is_array($body) || !is_array($body['manifest'] ?? null) || !is_string($body['signature'] ?? null)) { return ['ok' => false, 'error' => $status === 404 ? __('Only a fix that is live on this site can be kept.', 'sarcio') : __('Sarcio could not hand over that fix right now. Try again in a moment.', 'sarcio')]; } $publicKey = self::signingKey($api, $siteKey); $target = ($body['target'] ?? '') === 'server' ? 'server' : 'dom'; $fix = [ 'enabled' => true, 'explanation' => sanitize_text_field((string) ($body['explanation'] ?? '')), 'id' => $patchId, 'keptAt' => time(), 'keptBy' => wp_get_current_user()->display_name, 'manifest' => $body['manifest'], 'publicKey' => (string) $publicKey, 'signature' => $body['signature'], 'target' => $target, ]; if ($publicKey === null || !self::isTrustworthy($fix)) { return ['ok' => false, 'error' => __('That fix did not pass the signature check, so it was not kept.', 'sarcio')]; } $all = self::all(); $all[$patchId] = $fix; update_option(self::OPTION, $all, false); return ['ok' => true, 'fix' => $fix]; } public static function setEnabled(string $id, bool $enabled): void { $all = self::all(); if (isset($all[$id])) { $all[$id]['enabled'] = $enabled; update_option(self::OPTION, $all, false); } } public static function remove(string $id): void { $all = self::all(); unset($all[$id]); update_option(self::OPTION, $all, false); } /** @return array> */ public static function domOps(): array { $ops = []; foreach (self::active('dom') as $fix) { foreach ($fix['manifest']['ops'] as $op) { $ops[] = $op; } } return $ops; } /** @return array> */ public static function serverOps(): array { $ops = []; foreach (self::active('server') as $fix) { foreach ($fix['manifest']['ops'] as $op) { $ops[] = $op; } } return $ops; } /** @return array> */ private static function active(string $target): array { return array_values(array_filter( self::all(), static fn ($fix): bool => is_array($fix) && !empty($fix['enabled']) && ($fix['target'] ?? '') === $target && self::isTrustworthy($fix), )); } /** @param array $fix */ private static function isTrustworthy(array $fix): bool { $memo = md5((string) wp_json_encode([$fix['manifest'] ?? null, $fix['signature'] ?? null, $fix['publicKey'] ?? null])); if (isset(self::$verified[$memo])) { return self::$verified[$memo]; } $manifest = $fix['manifest'] ?? null; $ok = class_exists(Verifier::class) && is_array($manifest) && ($manifest['patchId'] ?? null) === ($fix['id'] ?? '') && ($manifest['siteKey'] ?? null) === (string) Options::get('site_key') && is_array($manifest['ops'] ?? null) && self::opsAllowed($manifest['ops'], (string) ($fix['target'] ?? '')); if ($ok) { $verifier = new Verifier(); $ok = $verifier->setKey((string) ($fix['publicKey'] ?? '')) && $verifier->verify(['manifest' => $manifest, 'signature' => (string) ($fix['signature'] ?? '')]); } return self::$verified[$memo] = $ok; } /** @param array $ops */ private static function opsAllowed(array $ops, string $target): bool { if ($ops === [] || count($ops) > 10) { return false; } foreach ($ops as $op) { if (!is_array($op) || !is_string($op['op'] ?? null)) { return false; } if ($target === 'server') { if (!is_string($op['route'] ?? null)) { return false; } continue; } if (!self::domOpAllowed($op)) { return false; } } return true; } /** @param array $op */ private static function domOpAllowed(array $op): bool { if (!in_array($op['op'], self::DOM_OPS, true) || !is_string($op['selector'] ?? null) || $op['selector'] === '' || strlen($op['selector']) > 500) { return false; } switch ($op['op']) { case 'removeAttr': return in_array($op['attr'] ?? null, self::REMOVABLE_ATTRS, true); case 'setAttr': $attr = strtolower((string) ($op['attr'] ?? '')); return preg_match('~^[a-z][a-z0-9:_-]{0,49}$~', $attr) === 1 && !str_starts_with($attr, 'on') && !in_array($attr, self::BLOCKED_ATTRS, true) && is_string($op['value'] ?? null) && strlen($op['value']) <= 500; case 'setStyle': if (!is_array($op['styles'] ?? null) || $op['styles'] === []) { return false; } foreach ($op['styles'] as $property => $value) { if (!is_string($property) || !is_string($value) || preg_match('~url\(|expression\(|@import~i', $value) === 1) { return false; } } return true; case 'setText': return is_string($op['text'] ?? null) && strlen($op['text']) <= 2000; case 'addClass': case 'removeClass': return is_string($op['className'] ?? null) && preg_match('~^[A-Za-z0-9_-]{1,100}$~', $op['className']) === 1; default: return true; } } private static function signingKey(string $api, string $siteKey): ?string { $response = wp_remote_get(sprintf('%s/api/sites/%s/signing-key', $api, rawurlencode($siteKey)), [ 'redirection' => 0, 'timeout' => 5, ]); if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 200) { return null; } $body = json_decode((string) wp_remote_retrieve_body($response), true); return is_array($body) && is_string($body['publicKey'] ?? null) ? $body['publicKey'] : null; } }