PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
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 (Beta) 0.0.8, at inc/security/protected-options-filter.php

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