PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.14
ZIP AI – AI Website Builder & AI Agent v0.0.14
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / security / protected-options-filter.php

protected-options-filter.php in ZIP AI – AI Website Builder & AI Agent 0.0.14, at inc/security/protected-options-filter.php

270 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Protected-options WP filter. This is a generic backstop.
4 *
5 * Why this exists
6 * ---------------
7 * The server and the WP-CLI ability (RunWpCli::verify_command_security) both
8 * have protected-resource gates. They cover the common paths. Those paths are
9 * the CLI, REST /wp/v2/settings, and the server's tool registry. They do NOT
10 * cover:
11 *
12 * • Custom-plugin REST/AJAX endpoints. These call update_option('siteurl', …)
13 * through their own settings handlers.
14 * • Generic options.php form posts wrapped by a plugin namespace.
15 * • Future MCP abilities. These may wrap update_option() in a shape that the
16 * server extractor does not recognize.
17 *
18 * This file installs `pre_update_option_<key>` filters at WP's native
19 * option-update layer. Every `update_option()` call fires this filter, whatever
20 * the source. Inside an MCP context, the filter refuses mutations to protected
21 * keys. It returns the previous value. This follows WP's own contract for the
22 * `pre_update_option_<key>` filter. Returning a value other than the new value
23 * cancels the update.
24 *
25 * `Rest_Api::handle_mcp_request` and `External_Mcp` (around each tool call on
26 * this plugin's adapter server) toggle the MCP context flag. They set the flag
27 * on entry and clear it on exit. A `register_shutdown_function` handles the
28 * abnormal-exit case.
29 *
30 * Generalization angle
31 * --------------------
32 * This is the single point where WordPress core itself observes ALL option
33 * mutations. To add a new protected key, add it to PROTECTED_KEYS. One entry
34 * then covers every CLI, REST, AJAX and code path that updates that option. You
35 * do not need to find every write surface.
36 *
37 * @package zip-ai
38 */
39
40 namespace ZipAI\MCP\Classes\Security;
41
42 if ( ! defined( 'ABSPATH' ) ) {
43 exit;
44 }
45
46 /**
47 * Installs and manages protected-options filters during MCP requests.
48 *
49 * Usage:
50 * Protected_Options_Filter::install(); // once, on plugin load
51 * Protected_Options_Filter::enter_mcp(); // at MCP request start
52 * Protected_Options_Filter::exit_mcp(); // at MCP request end
53 */
54 class Protected_Options_Filter {
55
56 /**
57 * The single source of truth for keys the MCP context cannot mutate.
58 * Mirrors `RunWpCli::$protected_options` (run-wp-cli.php) and the
59 * server-side protected-options registry.
60 *
61 * Keep this in lockstep with the server registry — adding a key here
62 * without updating the server is fine (defense-in-depth strengthens),
63 * but removing one without updating the server leaves a coverage gap.
64 *
65 * This const is the FILTER list only. Callers asking "may a generic
66 * `option update` / `search-replace` touch this key?" must use
67 * `write_protected_keys()` / `is_write_protected()` below, which is the
68 * superset — a new key belongs here only if no legitimate MCP flow writes
69 * it through a dedicated handler (see GENERIC_WRITE_PROTECTED_KEYS).
70 *
71 * @var string[]
72 */
73 const PROTECTED_KEYS = array(
74 'siteurl', // brick: frontend/admin URL.
75 'home', // brick: site URL.
76 // `template` + `stylesheet` are NOT in the protect-list. The
77 // `wp theme activate <slug>` write path in RunWpCli is explicitly
78 // allowed + approval-gated and calls `switch_theme()` which
79 // updates both options — protecting them here silently no-ops
80 // the switch and the handler reports false success. Theme
81 // switch is the supported way to change them; arbitrary writes
82 // outside that handler aren't a real surface (no MCP tool
83 // exposes raw option writes for these keys).
84 'admin_email', // lock-out: password-recovery email.
85 'db_version', // brick: DB schema versioning.
86 'blog_charset', // mojibake risk.
87 'users_can_register', // security boundary.
88 'default_role', // privilege escalation surface.
89 );
90
91 /**
92 * Extra keys refused for the GENERIC option-write primitives
93 * (`wp option update` / `option delete` / `search-replace`). These keys are
94 * deliberately NOT filter-installed. A legitimate MCP flow writes each of
95 * them through a dedicated handler that must keep working:
96 *
97 * • `template` / `stylesheet` — `switch_theme()` writes these inside
98 * `handle_theme_activate`.
99 * • `active_plugins` — the browser-proxied activate and deactivate
100 * abilities write this.
101 * • `upload_path` / `upload_url_path` — these relocate the uploads dir.
102 * That changes where every future media write lands.
103 *
104 * A `pre_update_option_*` filter on these keys would silently no-op the real
105 * handler. The handler would still report success. This is the failure mode
106 * the PROTECTED_KEYS note above records for `template` and `stylesheet`.
107 *
108 * @var string[]
109 */
110 const GENERIC_WRITE_PROTECTED_KEYS = array(
111 'template',
112 'stylesheet',
113 'active_plugins',
114 'upload_path',
115 'upload_url_path',
116 // The Global Block Styles SSOT: every GBS class, its CSS, and the
117 // design-token graph for the whole site live in this ONE row as JSON.
118 // A generic byte-level write (option update / search-replace) that
119 // leaves it unparseable is site-wide styling loss. The spectra-blocks
120 // editor and the importer write it through their own REST handlers,
121 // which stay unaffected (this list is not filter-installed).
122 'spectra_blocks_pro_gs_user_css',
123 );
124
125 /**
126 * Every option key the generic write primitives must refuse.
127 *
128 * PROTECTED_KEYS + GENERIC_WRITE_PROTECTED_KEYS + this site's prefixed
129 * `user_roles` map (`{$wpdb->prefix}user_roles`) — the role→capability
130 * table `WP_Roles::for_site()` reads. Writing it grants any role
131 * administrator capabilities, which is the exact outcome
132 * `wp user add-cap <id> manage_options` is blocked to prevent. The prefix
133 * is runtime state, so this cannot be a const.
134 *
135 * @return string[] Lowercase option names.
136 */
137 public static function write_protected_keys() {
138 global $wpdb;
139 /**
140 * WordPress database access layer.
141 *
142 * @var \wpdb $wpdb
143 */
144 return array_merge(
145 self::PROTECTED_KEYS,
146 self::GENERIC_WRITE_PROTECTED_KEYS,
147 array( strtolower( $wpdb->prefix . 'user_roles' ) )
148 );
149 }
150
151 /**
152 * Whether a generic option write/delete must be refused for this key.
153 *
154 * Compared case-insensitively: `wp_options.option_name` collates
155 * case-insensitively on a default install, so `SITEURL` addresses the
156 * same row as `siteurl` and a case-sensitive check is a bypass.
157 *
158 * @param string $key Option name.
159 * @return bool
160 */
161 public static function is_write_protected( string $key ) {
162 return in_array( strtolower( $key ), self::write_protected_keys(), true );
163 }
164
165 /**
166 * In-memory flag indicating whether we're servicing an MCP request.
167 * Toggled by `enter_mcp()` / `exit_mcp()`. Defensive default: false
168 * (out-of-MCP traffic is allowed to mutate options normally; the
169 * filter only fires its refusal during MCP-bound calls).
170 *
171 * @var bool
172 */
173 private static $in_mcp_context = false;
174
175 /**
176 * Whether `install()` has already wired the filters. Idempotent guard
177 * for plugin reload / multiple bootstrap paths.
178 *
179 * @var bool
180 */
181 private static $installed = false;
182
183 /**
184 * Wire `pre_update_option_<key>` filters for every protected key.
185 * Idempotent — calling twice does nothing on the second call.
186 *
187 * @return void
188 */
189 public static function install() {
190 if ( self::$installed ) {
191 return;
192 }
193 self::$installed = true;
194
195 foreach ( self::PROTECTED_KEYS as $key ) {
196 \add_filter(
197 "pre_update_option_{$key}",
198 array( __CLASS__, 'maybe_block_update' ),
199 10,
200 3
201 );
202 }
203
204 // Safety net: if a fatal error or early exit prevents `exit_mcp()`
205 // from running, the shutdown hook clears the flag so the next
206 // non-MCP request isn't accidentally treated as MCP-bound.
207 \register_shutdown_function( array( __CLASS__, 'exit_mcp' ) );
208 }
209
210 /**
211 * Mark the start of an MCP request. The filter callback below uses
212 * this flag to decide whether to refuse the option update.
213 *
214 * @return void
215 */
216 public static function enter_mcp() {
217 self::$in_mcp_context = true;
218 }
219
220 /**
221 * Mark the end of an MCP request. Idempotent.
222 *
223 * @return void
224 */
225 public static function exit_mcp() {
226 self::$in_mcp_context = false;
227 }
228
229 /**
230 * Filter callback wired into `pre_update_option_<key>` for every
231 * key in PROTECTED_KEYS.
232 *
233 * Contract (from WP core):
234 * - $value — the new value about to be written.
235 * - $old_value — the existing value in the DB.
236 * - $option — the option name (e.g. 'siteurl').
237 *
238 * Returning $old_value cancels the update. WP treats a new value equal to
239 * the old value as a no-op. It does not write. This method logs the attempt
240 * for observability.
241 *
242 * @param mixed $value New value attempted.
243 * @param mixed $old_value Existing value.
244 * @param string $option Option name.
245 * @return mixed
246 */
247 public static function maybe_block_update( $value, $old_value, $option ) {
248 if ( ! self::$in_mcp_context ) {
249 return $value;
250 }
251
252 // No-op writes (same value) are not worth refusing or logging —
253 // some plugins re-save options on activation as a side-effect.
254 if ( \maybe_serialize( $value ) === \maybe_serialize( $old_value ) ) {
255 return $value;
256 }
257
258 \error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
259 sprintf(
260 '[zip-ai] protected_resource_blocked: refused MCP-context update of `%s`. ' .
261 'Mutating site-critical options via the AI agent is disabled (would brick the site). ' .
262 'If you truly need this change, do it manually in wp-admin → Settings.',
263 $option
264 )
265 );
266
267 return $old_value;
268 }
269 }
270