| 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 |
|