PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / ajax / AjaxSecurityGate.php

AjaxSecurityGate.php in 404 Solution trunk, at includes/ajax/AjaxSecurityGate.php

287 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Shared nonce + admin permission gate for AJAX handlers.
9 */
10 class ABJ_404_Solution_AjaxSecurityGate {
11
12 /**
13 * Sentinel for a constructor argument that was not supplied. A dependency
14 * left at this value is resolved lazily from the service container at use
15 * time, instead of being captured at construction. This keeps the gate
16 * from holding a stale admin_access_policy / logging reference when those
17 * services are (re)registered after the gate is first built. The container
18 * caches the gate instance, so a value captured in the constructor would
19 * otherwise never reflect a later re-registration.
20 */
21 const RESOLVE_FROM_CONTAINER = "\0__abj404_resolve_from_container__";
22
23 /** @var object|null|string */
24 private $adminAccessPolicy;
25
26 /** @var object|null|string */
27 private $logger;
28
29 /**
30 * @param object|null|string $adminAccessPolicy Service exposing isPluginAdmin().
31 * Omit (or pass RESOLVE_FROM_CONTAINER) to resolve 'admin_access_policy'
32 * lazily from the container on each authorization.
33 * @param object|null|string $logger Service exposing infoMessage()/warn().
34 * Omit to resolve 'logging' lazily from the container.
35 */
36 public function __construct($adminAccessPolicy = self::RESOLVE_FROM_CONTAINER, $logger = self::RESOLVE_FROM_CONTAINER) {
37 $this->adminAccessPolicy = $adminAccessPolicy;
38 $this->logger = $logger;
39 }
40
41 /**
42 * Resolve the admin-access policy, honoring an explicitly injected value
43 * (including an explicit null, which means "no policy") and otherwise
44 * pulling the current 'admin_access_policy' service from the container.
45 *
46 * @return object|null
47 */
48 protected function getAdminAccessPolicy() {
49 $policy = $this->adminAccessPolicy;
50 if (is_string($policy)) {
51 // Either the resolve-from-container sentinel or, defensively, any
52 // other raw string (never a valid policy): resolve from the
53 // container in both cases.
54 return abj_service_optional('admin_access_policy');
55 }
56 return $policy;
57 }
58
59 /**
60 * Resolve the logger, honoring an explicitly injected value and otherwise
61 * pulling the current 'logging' service from the container.
62 *
63 * @return object|null
64 */
65 protected function getLogger() {
66 $logger = $this->logger;
67 if (is_string($logger)) {
68 // Either the resolve-from-container sentinel or, defensively, any
69 // other raw string (never a valid logger): resolve from the
70 // container in both cases.
71 return abj_service_optional('logging');
72 }
73 return $logger;
74 }
75
76 /**
77 * Verify nonce and admin permissions. Sends JSON error and terminates on failure.
78 *
79 * @param string $action The nonce action string.
80 * @param string $nonceParam The POST/GET parameter name holding the nonce.
81 * @param array<string, mixed> $options Optional nonce_value override.
82 * @return void
83 */
84 public function requireAdminWithNonce(string $action, string $nonceParam = 'nonce', array $options = array()): void {
85 $options['nonce_param'] = $nonceParam;
86 $result = $this->authorizeAdminWithNonce($action, $options);
87 if ($result['ok']) {
88 return;
89 }
90
91 wp_send_json_error(array('message' => $result['message']), $result['status']);
92 return; // @phpstan-ignore deadCode.unreachable
93 }
94
95 /**
96 * Verify nonce and capability. Sends JSON error and terminates on failure.
97 *
98 * @param string $action The nonce action string.
99 * @param string $capability Required WordPress capability.
100 * @param string $nonceParam The POST/GET parameter name holding the nonce.
101 * @param array<string, mixed> $options Optional nonce_value override.
102 * @return void
103 */
104 public function requireCapabilityWithNonce(
105 string $action,
106 string $capability,
107 string $nonceParam = 'nonce',
108 array $options = array()
109 ): void {
110 $options['capability'] = $capability;
111 $options['nonce_param'] = $nonceParam;
112 $result = $this->authorizeAdminWithNonce($action, $options);
113 if ($result['ok']) {
114 return;
115 }
116
117 wp_send_json_error(array('message' => $result['message']), $result['status']);
118 return; // @phpstan-ignore deadCode.unreachable
119 }
120
121 /**
122 * Return a shared authorization decision without emitting the response.
123 * Callers with non-standard transports (diagnostic envelopes, Select2
124 * result arrays) use this to keep the same nonce/capability contract.
125 *
126 * @param string $action The nonce action string.
127 * @param array<string, mixed> $options Supports nonce_param, nonce_value, capability.
128 * @return array{ok: bool, code: string, message: string, status: int, is_plugin_admin: bool}
129 */
130 public function authorizeAdminWithNonce(string $action, array $options = array()): array {
131 $nonce = $this->resolveNonce($options);
132
133 if (!$this->nonceIsValid($nonce, $action, $options)) {
134 return $this->failure('invalid_nonce', __('Invalid security token', '404-solution'), 403);
135 }
136
137 $capability = isset($options['capability']) && is_string($options['capability'])
138 ? $options['capability'] : '';
139 $isAuthorized = $capability !== ''
140 ? $this->currentUserCan($action, $capability)
141 : $this->isPluginAdmin($action);
142
143 if (!$isAuthorized) {
144 $message = $capability !== ''
145 ? __('Unauthorized: Insufficient permissions', '404-solution')
146 : __('Unauthorized', '404-solution');
147 return $this->failure('unauthorized', $message, 403);
148 }
149
150 $this->logAuthorizedAction($action);
151 return array(
152 'ok' => true,
153 'code' => 'ok',
154 'message' => '',
155 'status' => 200,
156 'is_plugin_admin' => $capability === '',
157 );
158 }
159
160 /**
161 * @param array<string, mixed> $options
162 * @return bool
163 */
164 private function nonceIsValid(string $nonce, string $action, array $options): bool {
165 if (function_exists('wp_verify_nonce')) {
166 return (bool)wp_verify_nonce($nonce, $action);
167 }
168 if (function_exists('check_ajax_referer')) {
169 $nonceParam = isset($options['nonce_param']) && is_string($options['nonce_param'])
170 ? $options['nonce_param'] : 'nonce';
171 return check_ajax_referer($action, $nonceParam, false) !== false;
172 }
173 return false;
174 }
175
176 /**
177 * @param array<string, mixed> $options
178 */
179 private function resolveNonce(array $options): string {
180 if (array_key_exists('nonce_value', $options)) {
181 return $this->sanitizeNonceValue($options['nonce_value']);
182 }
183
184 $nonceParam = isset($options['nonce_param']) && is_string($options['nonce_param'])
185 ? $options['nonce_param'] : 'nonce';
186 if (isset($_POST[$nonceParam])) {
187 return $this->sanitizeNonceValue($_POST[$nonceParam]);
188 }
189 if (isset($_GET[$nonceParam])) {
190 return $this->sanitizeNonceValue($_GET[$nonceParam]);
191 }
192
193 return '';
194 }
195
196 /**
197 * @param mixed $raw
198 */
199 private function sanitizeNonceValue($raw): string {
200 if (!is_scalar($raw)) {
201 return '';
202 }
203 // Unslash before sanitizing (the one centralized place every nonce
204 // read passes through), so the gate compares the value WordPress
205 // issued rather than a magic-quoted variant of it.
206 return sanitize_text_field(
207 ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($raw));
208 }
209
210 /**
211 * @return array{ok: false, code: string, message: string, status: int, is_plugin_admin: false}
212 */
213 private function failure(string $code, string $message, int $status): array {
214 return array(
215 'ok' => false,
216 'code' => $code,
217 'message' => $message,
218 'status' => $status,
219 'is_plugin_admin' => false,
220 );
221 }
222
223 private function currentUserCan(string $action, string $capability): bool {
224 try {
225 return function_exists('current_user_can') && current_user_can($capability);
226 } catch (\Throwable $e) {
227 $this->warn('AJAX authorization failed for ' . $action .
228 ' while checking capability ' . $capability .
229 ' (code ' . $e->getCode() . '): ' . $e->getMessage());
230 return false;
231 }
232 }
233
234 private function isPluginAdmin(string $action): bool {
235 $adminAccessPolicy = $this->getAdminAccessPolicy();
236
237 if (!is_object($adminAccessPolicy)) {
238 $this->warn('AJAX authorization failed for ' . $action .
239 ' because admin_access_policy service is unavailable.');
240 return false;
241 }
242
243 try {
244 if (method_exists($adminAccessPolicy, 'isPluginAdmin')) {
245 return (bool)$adminAccessPolicy->isPluginAdmin();
246 }
247 $this->warn('AJAX authorization failed for ' . $action .
248 ' because admin_access_policy service has no admin-check method.');
249 return false;
250 } catch (\Throwable $e) {
251 $this->warn('AJAX authorization failed for ' . $action .
252 ' (code ' . $e->getCode() . '): ' . $e->getMessage());
253 return false;
254 }
255 }
256
257 protected function logAuthorizedAction(string $action): void {
258 try {
259 ABJ_404_Solution_AuthorizationLogTracer::trace(function () use ($action): void {
260 $logger = $this->getLogger();
261 if (!is_object($logger) || !method_exists($logger, 'infoMessage')) {
262 throw new RuntimeException('logging service is unavailable');
263 }
264 $logger->infoMessage('AJAX authorized: ' . $action);
265 });
266 } catch (\Throwable $e) {
267 $this->warn('AJAX authorization logging failed for ' . $action .
268 ' (code ' . $e->getCode() . '): ' . $e->getMessage());
269 }
270 }
271
272 private function warn(string $message): void {
273 $logger = $this->getLogger();
274 if (is_object($logger) && method_exists($logger, 'warn')) {
275 $logger->warn($message);
276 return;
277 }
278
279 if (is_object($logger) && method_exists($logger, 'errorMessage')) {
280 $logger->errorMessage($message);
281 return;
282 }
283
284 abj404_logPhpFallback('service-resolution-fallback', $message);
285 }
286 }
287