PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / app / Mcp / Guard.php

Guard.php in Extendify 3.2.2, at app/Mcp/Guard.php

309 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * What a connection may do, whatever the route it reached says.
5 */
6
7 namespace Extendify\Mcp;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 /**
12 * Adds refusals on top of a route's own checks, and lifts the option refusal
13 * only while a plugin's uninstall routine or an upgrader runs.
14 *
15 * A refusal throws inside a tool call, where Surface catches it and the model
16 * reads why. Outside one it is silent: a throw during shutdown is a fatal
17 * error WordPress mails the site owner about.
18 */
19 class Guard
20 {
21 /**
22 * @var boolean
23 */
24 private static $calling = false;
25
26 /**
27 * @var boolean
28 */
29 private static $lifted = false;
30
31 /**
32 * @var array
33 */
34 private static $permitted = [];
35
36 /**
37 * @var boolean
38 */
39 private static $registering = false;
40
41 /**
42 * @param callable $call - The call to run.
43 * @return mixed
44 */
45 public static function registering(callable $call)
46 {
47 self::$registering = true;
48
49 try {
50 return $call();
51 } finally {
52 self::$registering = false;
53 }
54 }
55
56 /**
57 * The named set is the tool's own, so no ability or route can widen it.
58 *
59 * @param array $options - The options this one call may write.
60 * @param callable $call - The call to run.
61 * @return mixed
62 */
63 public static function permitting(array $options, callable $call)
64 {
65 self::$permitted = $options;
66
67 try {
68 return $call();
69 } finally {
70 self::$permitted = [];
71 }
72 }
73
74 /**
75 * Never lifted on a request, so a write deferred to shutdown still lands inside it.
76 * Core fires no generic filter before a network option write, so on a multisite those pass.
77 *
78 * @return void
79 */
80 public static function mark()
81 {
82 foreach (self::hooks() as list($hook, $method, $arguments)) {
83 \add_filter($hook, [self::class, $method], 10, $arguments);
84 }
85 }
86
87 /**
88 * wp-cron runs every due event in one process, so the events after a job must write freely.
89 *
90 * @return void
91 */
92 public static function unmark()
93 {
94 foreach (self::hooks() as list($hook, $method)) {
95 \remove_filter($hook, [self::class, $method], 10);
96 }
97 }
98
99 /**
100 * @return array - Each hook, the method it calls and how many arguments it takes.
101 */
102 private static function hooks()
103 {
104 $hooks = [
105 ['pre_update_option', 'refuseOption', 3],
106 ['add_option', 'refuseOptionChange', 1],
107 ['delete_option', 'refuseOptionChange', 1],
108 ['pre_uninstall_plugin', 'lift', 1],
109 ['delete_plugin', 'hold', 1],
110 ];
111 foreach (['add', 'update', 'delete'] as $change) {
112 $hooks[] = [$change . '_user_metadata', 'refuseRole', 4];
113 }
114
115 return $hooks;
116 }
117
118 /**
119 * @param callable $call - The tool call to run.
120 * @return mixed
121 */
122 public static function during(callable $call)
123 {
124 self::$calling = true;
125 try {
126 return $call();
127 } finally {
128 self::$calling = false;
129 self::$lifted = false;
130 }
131 }
132
133 /**
134 * switch_theme(), default_role and users_can_register all arrive here as option writes.
135 *
136 * @param mixed $value - The value being written.
137 * @param string $option - The option being written.
138 * @param mixed $oldValue - The value it holds now.
139 * @return mixed
140 */
141 public static function refuseOption($value, $option, $oldValue)
142 {
143 if (self::exempt($option) || self::$lifted) {
144 return $value;
145 }
146
147 if (!self::$calling) {
148 return $oldValue;
149 }
150
151 throw new Refused(\esc_html(self::optionMessage($option)));
152 }
153
154 /**
155 * An action cannot answer for the write, so add_option() and delete_option() are held only during a call.
156 *
157 * @param string $option - The option being added or deleted.
158 * @return void
159 */
160 public static function refuseOptionChange($option)
161 {
162 if (self::exempt($option) || self::$lifted || !self::$calling) {
163 return;
164 }
165
166 throw new Refused(\esc_html(self::optionMessage($option)));
167 }
168
169 /**
170 * A role is a usermeta row, so switching one is not an option write.
171 *
172 * @param mixed $check - Null until a filter answers for the write.
173 * @param integer $userId - The user whose meta is being written.
174 * @param string $key - The meta key.
175 * @param mixed $value - The capabilities being written.
176 * @return mixed
177 */
178 public static function refuseRole($check, $userId, $key, $value)
179 {
180 if (!preg_match(self::capabilitiesKey(), $key) || self::allowedRole($userId, $key, $value)) {
181 return $check;
182 }
183
184 if (!self::$calling) {
185 return false;
186 }
187
188 throw new Refused('Changing a user role is not something a connection may do.');
189 }
190
191 /**
192 * Refusing an uninstall routine's or an upgrader's own option writes would abort it half-run.
193 * Role writes stay refused throughout.
194 *
195 * @return void
196 */
197 public static function lift()
198 {
199 self::$lifted = self::$calling;
200 }
201
202 /**
203 * @return void
204 */
205 public static function hold()
206 {
207 self::$lifted = false;
208 }
209
210 /**
211 * Refusing every first role would stop an ability registering a customer or an author.
212 *
213 * @param integer $userId - The user whose meta is being written.
214 * @param string $key - The capabilities meta key.
215 * @param mixed $value - The capabilities being written.
216 * @return boolean
217 */
218 private static function allowedRole($userId, $key, $value)
219 {
220 $roles = array_keys(array_filter((array) $value));
221 // Inserting an account writes its capabilities more than once, and one of those writes is empty.
222 if (self::$registering) {
223 return self::below($roles);
224 }
225
226 return \get_user_meta($userId, $key, true) === '' && $roles !== [] && self::below($roles);
227 }
228
229 /**
230 * @param array $roles - The roles a capabilities write names.
231 * @return boolean - Whether each one exists and manages nothing.
232 */
233 private static function below(array $roles)
234 {
235 foreach ($roles as $role) {
236 $granted = \get_role($role);
237 if (!$granted || $granted->has_cap('manage_options')) {
238 return false;
239 }
240 }
241
242 return true;
243 }
244
245 /**
246 * @param string $option - The option being written.
247 * @return boolean
248 */
249 private static function exempt($option)
250 {
251 // Core's transients and WPForms' own copy of them are cache entries, which escalate nothing.
252 foreach (['_transient_', '_site_transient_', '_wpforms_transient_'] as $cache) {
253 if (strpos($option, $cache) === 0) {
254 return true;
255 }
256 }
257
258 // A tool that writes settings names them for the duration of its own call.
259 if (in_array($option, self::$permitted, true)) {
260 return true;
261 }
262
263 // Core writes these as it registers a user, publishes a post or renders a calendar,
264 // and WPForms as it saves a form, or its abilities abort with the form half-written.
265 $caches = [
266 'user_count',
267 'fresh_site',
268 'wp_calendar_block_has_published_posts',
269 'wpforms_dashboard_cache_generation',
270 'wpforms_forms_first_created',
271 ];
272 if (in_array($option, $caches, true)) {
273 return true;
274 }
275
276 // set_auto_updates writes these, and an update schedule grants nothing either.
277 if (in_array($option, ['auto_update_plugins', 'auto_update_themes'], true)) {
278 return true;
279 }
280
281 // Jobs::start() schedules here, and a cron event runs only code the site already hooks.
282 if ($option === 'cron') {
283 return true;
284 }
285
286 // A term write rebuilds this cache, and a term cache escalates nothing.
287 return substr($option, -9) === '_children' && \is_taxonomy_hierarchical(substr($option, 0, -9));
288 }
289
290 /**
291 * @param string $option - The option refused.
292 * @return string
293 */
294 private static function optionMessage($option)
295 {
296 return sprintf('Changing the %s option is not something a connection may do.', $option);
297 }
298
299 /**
300 * A network keeps one capabilities row per site, prefixed with that site's id.
301 *
302 * @return string
303 */
304 private static function capabilitiesKey()
305 {
306 return '/^' . preg_quote($GLOBALS['wpdb']->base_prefix, '/') . '(\d+_)?capabilities$/';
307 }
308 }
309