1 | <?php |
||||
2 | namespace Elgg; |
||||
3 | |||||
4 | use Elgg\Http\ResponseBuilder; |
||||
5 | use ElggCrypto; |
||||
6 | use ElggSession; |
||||
7 | |||||
8 | /** |
||||
9 | * WARNING: API IN FLUX. DO NOT USE DIRECTLY. |
||||
10 | * |
||||
11 | * Use the elgg_* versions instead. |
||||
12 | * |
||||
13 | * @access private |
||||
14 | * |
||||
15 | * @package Elgg.Core |
||||
16 | * @subpackage Actions |
||||
17 | * @since 1.9.0 |
||||
18 | */ |
||||
19 | class ActionsService { |
||||
20 | |||||
21 | use \Elgg\TimeUsing; |
||||
22 | |||||
23 | /** |
||||
24 | * @var Config |
||||
25 | */ |
||||
26 | private $config; |
||||
27 | |||||
28 | /** |
||||
29 | * @var ElggSession |
||||
30 | */ |
||||
31 | private $session; |
||||
32 | |||||
33 | /** |
||||
34 | * @var ElggCrypto |
||||
35 | */ |
||||
36 | private $crypto; |
||||
37 | |||||
38 | /** |
||||
39 | * Registered actions storage |
||||
40 | * |
||||
41 | * Each element has keys: |
||||
42 | * "file" => filename |
||||
43 | * "access" => access level |
||||
44 | * |
||||
45 | * @var array |
||||
46 | */ |
||||
47 | private $actions = []; |
||||
48 | |||||
49 | /** |
||||
50 | * The current action being processed |
||||
51 | * @var string |
||||
52 | */ |
||||
53 | private $currentAction = null; |
||||
54 | |||||
55 | /** |
||||
56 | * @var string[] |
||||
57 | */ |
||||
58 | private static $access_levels = ['public', 'logged_in', 'admin']; |
||||
59 | |||||
60 | /** |
||||
61 | * Constructor |
||||
62 | * |
||||
63 | * @param Config $config Config |
||||
64 | * @param ElggSession $session Session |
||||
65 | * @param ElggCrypto $crypto Crypto service |
||||
66 | */ |
||||
67 | 75 | public function __construct(Config $config, ElggSession $session, ElggCrypto $crypto) { |
|||
68 | 75 | $this->config = $config; |
|||
69 | 75 | $this->session = $session; |
|||
70 | 75 | $this->crypto = $crypto; |
|||
71 | 75 | } |
|||
72 | |||||
73 | /** |
||||
74 | * Executes an action |
||||
75 | * If called from action() redirect will be issued by the response factory |
||||
76 | * If called as /action page handler response will be handled by \Elgg\Router |
||||
77 | * |
||||
78 | * @param string $action Action name |
||||
79 | * @param string $forwarder URL to forward to after completion |
||||
80 | * @return ResponseBuilder|null |
||||
81 | * @see action() |
||||
82 | * @access private |
||||
83 | */ |
||||
84 | 66 | public function execute($action, $forwarder = "") { |
|||
85 | 66 | $action = rtrim($action, '/'); |
|||
86 | 66 | $this->currentAction = $action; |
|||
87 | |||||
88 | // Logout is for convenience. |
||||
89 | $exceptions = [ |
||||
90 | 66 | 'logout', |
|||
91 | ]; |
||||
92 | |||||
93 | 66 | if (!in_array($action, $exceptions)) { |
|||
94 | // All actions require a token. |
||||
95 | 66 | $pass = $this->gatekeeper($action); |
|||
96 | 66 | if (!$pass) { |
|||
97 | 1 | return; |
|||
98 | } |
||||
99 | } |
||||
100 | |||||
101 | 65 | $forwarder = str_replace($this->config->wwwroot, "", $forwarder); |
|||
102 | 65 | $forwarder = str_replace("http://", "", $forwarder); |
|||
103 | 65 | $forwarder = str_replace("@", "", $forwarder); |
|||
104 | 65 | if (substr($forwarder, 0, 1) == "/") { |
|||
105 | 1 | $forwarder = substr($forwarder, 1); |
|||
106 | } |
||||
107 | |||||
108 | 65 | $ob_started = false; |
|||
109 | |||||
110 | /** |
||||
111 | * Prepare action response |
||||
112 | * |
||||
113 | * @param string $error_key Error message key |
||||
114 | * @param int $status_code HTTP status code |
||||
115 | * @return ResponseBuilder |
||||
116 | */ |
||||
117 | 65 | $forward = function ($error_key = '', $status_code = ELGG_HTTP_OK) use ($action, $forwarder, &$ob_started) { |
|||
118 | 23 | if ($error_key) { |
|||
119 | 9 | if ($ob_started) { |
|||
120 | ob_end_clean(); |
||||
121 | } |
||||
122 | 9 | $msg = _elgg_services()->translator->translate($error_key, [$action]); |
|||
123 | 9 | _elgg_services()->systemMessages->addErrorMessage($msg); |
|||
124 | 9 | $response = new \Elgg\Http\ErrorResponse($msg, $status_code); |
|||
125 | } else { |
||||
126 | 14 | $content = ob_get_clean(); |
|||
127 | 14 | $response = new \Elgg\Http\OkResponse($content, $status_code); |
|||
128 | } |
||||
129 | |||||
130 | 23 | $forwarder = empty($forwarder) ? REFERER : $forwarder; |
|||
131 | 23 | $response->setForwardURL($forwarder); |
|||
132 | 23 | return $response; |
|||
133 | 65 | }; |
|||
134 | |||||
135 | 65 | if (!isset($this->actions[$action])) { |
|||
136 | 5 | return $forward('actionundefined', ELGG_HTTP_NOT_IMPLEMENTED); |
|||
137 | } |
||||
138 | |||||
139 | 60 | $user = $this->session->getLoggedInUser(); |
|||
140 | |||||
141 | // access checks |
||||
142 | 60 | switch ($this->actions[$action]['access']) { |
|||
143 | case 'public': |
||||
144 | 35 | break; |
|||
145 | case 'logged_in': |
||||
146 | 21 | if (!$user) { |
|||
147 | 1 | return $forward('actionloggedout', ELGG_HTTP_FORBIDDEN); |
|||
148 | } |
||||
149 | 20 | break; |
|||
150 | default: |
||||
151 | // admin or misspelling |
||||
152 | 4 | if (!$user || !$user->isAdmin()) { |
|||
153 | 1 | return $forward('actionunauthorized', ELGG_HTTP_FORBIDDEN); |
|||
154 | } |
||||
155 | } |
||||
156 | |||||
157 | 58 | ob_start(); |
|||
158 | |||||
159 | // To quietly cancel the file, return a falsey value in the "action" hook. |
||||
160 | 58 | if (!_elgg_services()->hooks->trigger('action', $action, null, true)) { |
|||
161 | 1 | return $forward('', ELGG_HTTP_OK); |
|||
162 | } |
||||
163 | |||||
164 | 57 | $file = $this->actions[$action]['file']; |
|||
165 | |||||
166 | 57 | if (!is_file($file) || !is_readable($file)) { |
|||
167 | 2 | ob_end_clean(); |
|||
168 | 2 | return $forward('actionnotfound', ELGG_HTTP_NOT_IMPLEMENTED); |
|||
169 | } |
||||
170 | |||||
171 | // set the maximum execution time for actions |
||||
172 | 55 | $action_timeout = $this->config->action_time_limit; |
|||
173 | 55 | if (isset($action_timeout)) { |
|||
174 | set_time_limit($action_timeout); |
||||
175 | } |
||||
176 | |||||
177 | 55 | $result = Includer::includeFile($file); |
|||
178 | 55 | if ($result instanceof ResponseBuilder) { |
|||
179 | 42 | ob_end_clean(); |
|||
180 | 42 | return $result; |
|||
181 | } |
||||
182 | |||||
183 | 13 | return $forward('', ELGG_HTTP_OK); |
|||
184 | } |
||||
185 | |||||
186 | /** |
||||
187 | * Registers an action |
||||
188 | * |
||||
189 | * @param string $action The name of the action (eg "register", "account/settings/save") |
||||
190 | * @param string $filename Optionally, the filename where this action is located. If not specified, |
||||
191 | * will assume the action is in elgg/actions/<action>.php |
||||
192 | * @param string $access Who is allowed to execute this action: public, logged_in, admin. |
||||
193 | * (default: logged_in) |
||||
194 | * |
||||
195 | * @return bool |
||||
196 | * |
||||
197 | * @see elgg_register_action() |
||||
198 | * @access private |
||||
199 | */ |
||||
200 | 87 | public function register($action, $filename = "", $access = 'logged_in') { |
|||
201 | // plugins are encouraged to call actions with a trailing / to prevent 301 |
||||
202 | // redirects but we store the actions without it |
||||
203 | 87 | $action = rtrim($action, '/'); |
|||
204 | |||||
205 | 87 | if (empty($filename)) { |
|||
206 | 32 | $path = __DIR__ . '/../../../actions'; |
|||
207 | 32 | $filename = realpath("$path/$action.php"); |
|||
208 | } |
||||
209 | |||||
210 | 87 | if (!in_array($access, self::$access_levels)) { |
|||
211 | 1 | _elgg_services()->logger->error("Unrecognized value '$access' for \$access in " . __METHOD__); |
|||
212 | 1 | $access = 'admin'; |
|||
213 | } |
||||
214 | |||||
215 | 87 | $this->actions[$action] = [ |
|||
216 | 87 | 'file' => $filename, |
|||
217 | 87 | 'access' => $access, |
|||
218 | ]; |
||||
219 | 87 | return true; |
|||
220 | } |
||||
221 | |||||
222 | /** |
||||
223 | * Unregisters an action |
||||
224 | * |
||||
225 | * @param string $action Action name |
||||
226 | * |
||||
227 | * @return bool |
||||
228 | * |
||||
229 | * @see elgg_unregister_action() |
||||
230 | * @access private |
||||
231 | */ |
||||
232 | 1 | public function unregister($action) { |
|||
233 | 1 | if (isset($this->actions[$action])) { |
|||
234 | 1 | unset($this->actions[$action]); |
|||
235 | 1 | return true; |
|||
236 | } else { |
||||
237 | 1 | return false; |
|||
238 | } |
||||
239 | } |
||||
240 | |||||
241 | /** |
||||
242 | * Validate an action token. |
||||
243 | * |
||||
244 | * Calls to actions will automatically validate tokens. If tokens are not |
||||
245 | * present or invalid, the action will be denied and the user will be redirected. |
||||
246 | * |
||||
247 | * Plugin authors should never have to manually validate action tokens. |
||||
248 | * |
||||
249 | * @param bool $visible_errors Emit {@link register_error()} errors on failure? |
||||
250 | * @param mixed $token The token to test against. Default: $_REQUEST['__elgg_token'] |
||||
251 | * @param mixed $ts The time stamp to test against. Default: $_REQUEST['__elgg_ts'] |
||||
252 | * |
||||
253 | * @return bool |
||||
254 | * |
||||
255 | * @see validate_action_token() |
||||
256 | * @access private |
||||
257 | */ |
||||
258 | 70 | public function validateActionToken($visible_errors = true, $token = null, $ts = null) { |
|||
259 | 70 | if (!$token) { |
|||
260 | 67 | $token = get_input('__elgg_token'); |
|||
261 | } |
||||
262 | |||||
263 | 70 | if (!$ts) { |
|||
264 | 67 | $ts = get_input('__elgg_ts'); |
|||
265 | } |
||||
266 | |||||
267 | 70 | $session_id = $this->session->getId(); |
|||
268 | |||||
269 | 70 | if (($token) && ($ts) && ($session_id)) { |
|||
270 | 70 | if ($this->validateTokenOwnership($token, $ts)) { |
|||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||||
271 | 68 | if ($this->validateTokenTimestamp($ts)) { |
|||
0 ignored issues
–
show
It seems like
$ts can also be of type string ; however, parameter $ts of Elgg\ActionsService::validateTokenTimestamp() does only seem to accept integer , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||
272 | // We have already got this far, so unless anything |
||||
273 | // else says something to the contrary we assume we're ok |
||||
274 | 67 | $returnval = _elgg_services()->hooks->trigger('action_gatekeeper:permissions:check', 'all', [ |
|||
275 | 67 | 'token' => $token, |
|||
276 | 67 | 'time' => $ts |
|||
277 | 67 | ], true); |
|||
278 | |||||
279 | 67 | if ($returnval) { |
|||
280 | 67 | return true; |
|||
281 | } else if ($visible_errors) { |
||||
282 | register_error(_elgg_services()->translator->translate('actiongatekeeper:pluginprevents')); |
||||
283 | } |
||||
284 | 1 | } else if ($visible_errors) { |
|||
285 | // this is necessary because of #5133 |
||||
286 | if (elgg_is_xhr()) { |
||||
287 | register_error(_elgg_services()->translator->translate( |
||||
288 | 'js:security:token_refresh_failed', |
||||
289 | [$this->config->wwwroot] |
||||
290 | )); |
||||
291 | } else { |
||||
292 | 1 | register_error(_elgg_services()->translator->translate('actiongatekeeper:timeerror')); |
|||
293 | } |
||||
294 | } |
||||
295 | 3 | } else if ($visible_errors) { |
|||
296 | // this is necessary because of #5133 |
||||
297 | 1 | if (elgg_is_xhr()) { |
|||
298 | register_error(_elgg_services()->translator->translate('js:security:token_refresh_failed', [$this->config->wwwroot])); |
||||
299 | } else { |
||||
300 | 4 | register_error(_elgg_services()->translator->translate('actiongatekeeper:tokeninvalid')); |
|||
301 | } |
||||
302 | } |
||||
303 | } else { |
||||
304 | 1 | $req = _elgg_services()->request; |
|||
305 | 1 | $length = $req->server->get('CONTENT_LENGTH'); |
|||
306 | 1 | $post_count = count($req->request); |
|||
307 | 1 | if ($length && $post_count < 1) { |
|||
308 | // The size of $_POST or uploaded file has exceed the size limit |
||||
309 | $error_msg = _elgg_services()->hooks->trigger('action_gatekeeper:upload_exceeded_msg', 'all', [ |
||||
310 | 'post_size' => $length, |
||||
311 | 'visible_errors' => $visible_errors, |
||||
312 | ], _elgg_services()->translator->translate('actiongatekeeper:uploadexceeded')); |
||||
313 | } else { |
||||
314 | 1 | $error_msg = _elgg_services()->translator->translate('actiongatekeeper:missingfields'); |
|||
315 | } |
||||
316 | 1 | if ($visible_errors) { |
|||
317 | 1 | register_error($error_msg); |
|||
318 | } |
||||
319 | } |
||||
320 | |||||
321 | 5 | return false; |
|||
322 | } |
||||
323 | |||||
324 | /** |
||||
325 | * Is the token timestamp within acceptable range? |
||||
326 | * |
||||
327 | * @param int $ts timestamp from the CSRF token |
||||
328 | * |
||||
329 | * @return bool |
||||
330 | */ |
||||
331 | 68 | protected function validateTokenTimestamp($ts) { |
|||
332 | 68 | $timeout = $this->getActionTokenTimeout(); |
|||
333 | 68 | $now = $this->getCurrentTime()->getTimestamp(); |
|||
334 | 68 | return ($timeout == 0 || ($ts > $now - $timeout) && ($ts < $now + $timeout)); |
|||
335 | } |
||||
336 | |||||
337 | /** |
||||
338 | * Returns the action token timeout in seconds |
||||
339 | * |
||||
340 | * @return int number of seconds that action token is valid |
||||
341 | * |
||||
342 | * @see ActionsService::validateActionToken |
||||
343 | * @access private |
||||
344 | * @since 1.9.0 |
||||
345 | */ |
||||
346 | 69 | public function getActionTokenTimeout() { |
|||
347 | 69 | if (($timeout = $this->config->action_token_timeout) === null) { |
|||
348 | // default to 2 hours |
||||
349 | 69 | $timeout = 2; |
|||
350 | } |
||||
351 | 69 | $hour = 60 * 60; |
|||
352 | 69 | return (int) ((float) $timeout * $hour); |
|||
353 | } |
||||
354 | |||||
355 | /** |
||||
356 | * Validates the presence of action tokens. |
||||
357 | * |
||||
358 | * This function is called for all actions. If action tokens are missing, |
||||
359 | * the user will be forwarded to the site front page and an error emitted. |
||||
360 | * |
||||
361 | * This function verifies form input for security features (like a generated token), |
||||
362 | * and forwards if they are invalid. |
||||
363 | * |
||||
364 | * @param string $action The action being performed |
||||
365 | * |
||||
366 | * @return bool |
||||
367 | * |
||||
368 | * @see action_gatekeeper() |
||||
369 | * @access private |
||||
370 | */ |
||||
371 | 67 | public function gatekeeper($action) { |
|||
372 | 67 | if ($action === 'login') { |
|||
373 | 8 | if ($this->validateActionToken(false)) { |
|||
374 | 8 | return true; |
|||
375 | } |
||||
376 | |||||
377 | $token = get_input('__elgg_token'); |
||||
378 | $ts = (int) get_input('__elgg_ts'); |
||||
379 | if ($token && $this->validateTokenTimestamp($ts)) { |
||||
380 | // The tokens are present and the time looks valid: this is probably a mismatch due to the |
||||
381 | // login form being on a different domain. |
||||
382 | register_error(_elgg_services()->translator->translate('actiongatekeeper:crosssitelogin')); |
||||
383 | _elgg_services()->responseFactory->redirect('login', 'csrf'); |
||||
384 | return false; |
||||
385 | } |
||||
386 | } |
||||
387 | |||||
388 | 59 | if ($this->validateActionToken()) { |
|||
389 | 58 | return true; |
|||
390 | } |
||||
391 | |||||
392 | 2 | _elgg_services()->responseFactory->redirect(REFERER, 'csrf'); |
|||
393 | 2 | return false; |
|||
394 | } |
||||
395 | |||||
396 | /** |
||||
397 | * Was the given token generated for the session defined by session_token? |
||||
398 | * |
||||
399 | * @param string $token CSRF token |
||||
400 | * @param int $timestamp Unix time |
||||
401 | * @param string $session_token Session-specific token |
||||
402 | * |
||||
403 | * @return bool |
||||
404 | * @access private |
||||
405 | */ |
||||
406 | 71 | public function validateTokenOwnership($token, $timestamp, $session_token = '') { |
|||
407 | 71 | $required_token = $this->generateActionToken($timestamp, $session_token); |
|||
408 | |||||
409 | 71 | return _elgg_services()->crypto->areEqual($token, $required_token); |
|||
410 | } |
||||
411 | |||||
412 | /** |
||||
413 | * Generate a token from a session token (specifying the user), the timestamp, and the site key. |
||||
414 | * |
||||
415 | * @see generate_action_token() |
||||
416 | * |
||||
417 | * @param int $timestamp Unix timestamp |
||||
418 | * @param string $session_token Session-specific token |
||||
419 | * |
||||
420 | * @return string |
||||
421 | * @access private |
||||
422 | */ |
||||
423 | 71 | public function generateActionToken($timestamp, $session_token = '') { |
|||
424 | 71 | if (!$session_token) { |
|||
425 | 71 | $session_token = elgg_get_session()->get('__elgg_session'); |
|||
426 | 71 | if (!$session_token) { |
|||
427 | return false; |
||||
428 | } |
||||
429 | } |
||||
430 | |||||
431 | 71 | return _elgg_services()->hmac->getHmac([(int) $timestamp, $session_token], 'md5') |
|||
432 | 71 | ->getToken(); |
|||
433 | } |
||||
434 | |||||
435 | /** |
||||
436 | * Check if an action is registered and its script exists. |
||||
437 | * |
||||
438 | * @param string $action Action name |
||||
439 | * |
||||
440 | * @return bool |
||||
441 | * |
||||
442 | * @see elgg_action_exists() |
||||
443 | * @access private |
||||
444 | */ |
||||
445 | 4 | public function exists($action) { |
|||
446 | 4 | return (isset($this->actions[$action]) && file_exists($this->actions[$action]['file'])); |
|||
447 | } |
||||
448 | |||||
449 | /** |
||||
450 | * Get all actions |
||||
451 | * |
||||
452 | * @return array |
||||
453 | */ |
||||
454 | 4 | public function getAllActions() { |
|||
455 | 4 | return $this->actions; |
|||
456 | } |
||||
457 | |||||
458 | /** |
||||
459 | * Send an updated CSRF token, provided the page's current tokens were not fake. |
||||
460 | * |
||||
461 | * @return ResponseBuilder |
||||
462 | * @access private |
||||
463 | */ |
||||
464 | 1 | public function handleTokenRefreshRequest() { |
|||
465 | 1 | if (!elgg_is_xhr()) { |
|||
466 | return false; |
||||
467 | } |
||||
468 | |||||
469 | // the page's session_token might have expired (not matching __elgg_session in the session), but |
||||
470 | // we still allow it to be given to validate the tokens in the page. |
||||
471 | 1 | $session_token = get_input('session_token', null, false); |
|||
472 | 1 | $pairs = (array) get_input('pairs', [], false); |
|||
473 | 1 | $valid_tokens = (object) []; |
|||
474 | 1 | foreach ($pairs as $pair) { |
|||
475 | 1 | list($ts, $token) = explode(',', $pair, 2); |
|||
476 | 1 | if ($this->validateTokenOwnership($token, $ts, $session_token)) { |
|||
477 | 1 | $valid_tokens->{$token} = true; |
|||
478 | } |
||||
479 | } |
||||
480 | |||||
481 | 1 | $ts = $this->getCurrentTime()->getTimestamp(); |
|||
482 | 1 | $token = $this->generateActionToken($ts); |
|||
483 | $data = [ |
||||
484 | 'token' => [ |
||||
485 | 1 | '__elgg_ts' => $ts, |
|||
486 | 1 | '__elgg_token' => $token, |
|||
487 | 1 | 'logged_in' => $this->session->isLoggedIn(), |
|||
488 | ], |
||||
489 | 1 | 'valid_tokens' => $valid_tokens, |
|||
490 | 1 | 'session_token' => $this->session->get('__elgg_session'), |
|||
491 | 1 | 'user_guid' => $this->session->getLoggedInUserGuid(), |
|||
492 | ]; |
||||
493 | |||||
494 | 1 | elgg_set_http_header("Content-Type: application/json;charset=utf-8"); |
|||
495 | 1 | return elgg_ok_response($data); |
|||
496 | } |
||||
497 | } |
||||
498 | |||||
499 |