Passed
Push — master ( c0a3a7...3b84a4 )
by Jeroen
58:51
created

engine/classes/Elgg/ActionsService.php (3 issues)

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
It seems like $ts can also be of type string; however, parameter $timestamp of Elgg\ActionsService::validateTokenOwnership() 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 ignore-type  annotation

270
			if ($this->validateTokenOwnership($token, /** @scrutinizer ignore-type */ $ts)) {
Loading history...
271 68
				if ($this->validateTokenTimestamp($ts)) {
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;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
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;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type Elgg\Http\ResponseBuilder.
Loading history...
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