fix(audit L6, L5): DesignLock atomar (INSERT IGNORE, Token, Übernahme per UPDATE, Release nur mit eigenem Token); Farben und Theme-Optionen teilen eine Sperre

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 23:52:38 +02:00
co-authored by Claude Sonnet 5.5
parent 522fc8ff4d
commit b59a0c045f
14 changed files with 206 additions and 88 deletions
+60 -14
View File
@@ -9,9 +9,13 @@ use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* One confirmed change per design area at a time (final review I-5): an option lit_mcp_lock_<area> (never
* autoloaded) holds the time the lock was taken. add_option() is atomic – it fails while the option exists. A lock
* older than STALE_AFTER seconds (a request that died) is removed and taken once more.
* One confirmed change per lock at a time (final review I-5). Colors and theme options share ONE lock (audit L5):
* both rewrite the whole et_divi array through et_update_option(). The lock is a row lit_mcp_lock_<name> (never
* autoloaded) in the options table holding "<time>:<token>" – taken with an atomic INSERT IGNORE like
* DiviCheckRoute (add_option() reads first and writes after, so two requests could both win; audit L6). A lock
* older than STALE_AFTER seconds (a request that died) is taken over with a conditional UPDATE on the value just
* read, so only one taker wins; release() deletes the row only while it still holds this instance's token, so a
* holder that lost a stale lock cannot free the new holder's.
*/
final class DesignLock
{
@@ -19,6 +23,12 @@ final class DesignLock
private const PREFIX = 'lit_mcp_lock_';
/** Areas that write the same Divi option share one lock. */
private const SHARED = ['colors' => 'divi_options', 'theme_options' => 'divi_options'];
/** @var array<string, string> Lock option => token this instance holds */
private array $tokens = [];
/** @throws InvalidArgumentException For an area outside SnapshotStore::AREAS. */
public static function option(string $area): string
{
@@ -26,27 +36,63 @@ final class DesignLock
throw new InvalidArgumentException(sprintf('Unknown design area "%s".', $area));
}
return self::PREFIX . $area;
return self::PREFIX . (self::SHARED[$area] ?? $area);
}
public function acquire(string $area): bool
{
global $wpdb;
$option = self::option($area);
if (add_option($option, time(), '', false)) {
return true;
$token = time() . ':' . bin2hex(random_bytes(4));
$taken = $this->insert($wpdb, $option, $token);
if (! $taken) {
$since = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
$option
));
if ($since === null) {
// Released between our failed insert and this read; try once more.
$taken = $this->insert($wpdb, $option, $token);
} elseif (time() - (int) $since > self::STALE_AFTER) {
$taken = (int) $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
$token,
$option,
$since
)) === 1;
}
}
if ($taken) {
$this->tokens[$option] = $token;
}
$since = get_option($option, 0);
if (is_numeric($since) && time() - (int) $since <= self::STALE_AFTER) {
return false;
}
delete_option($option);
return add_option($option, time(), '', false);
return $taken;
}
public function release(string $area): void
{
delete_option(self::option($area));
global $wpdb;
$option = self::option($area);
$token = $this->tokens[$option] ?? null;
unset($this->tokens[$option]);
if ($token !== null) {
$wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
$option,
$token
));
}
}
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours. */
private function insert(\wpdb $wpdb, string $option, string $token): bool
{
return (int) $wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
$option,
$token
)) === 1;
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ final class Uninstaller
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options',
'lit_mcp_lock_colors', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets', 'lit_mcp_lock_theme_options',
'lit_mcp_lock_divi_options', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets',
];
private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state'];
private const CRON_HOOK = 'lit_mcp_log_cleanup';