Completed
Push — master ( caf222...deba87 )
by Jeroen
72:32 queued 44:47
created

engine/classes/Elgg/ActionsService.php (1 issue)

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 341
	public function __construct(Config $config, ElggSession $session, ElggCrypto $crypto) {
68 341
		$this->config = $config;
69 341
		$this->session = $session;
70 341
		$this->crypto = $crypto;
71 341
	}
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 35
	public function execute($action, $forwarder = "") {
85 35
		$action = rtrim($action, '/');
86 35
		$this->currentAction = $action;
87
		
88
		// Logout is for convenience.
89
		$exceptions = [
90 35
			'logout',
91
		];
92
	
93 35
		if (!in_array($action, $exceptions)) {
94
			// All actions require a token.
95 35
			$pass = $this->gatekeeper($action);
96 35
			if (!$pass) {
97 1
				return;
98
			}
99
		}
100
	
101 34
		$forwarder = str_replace($this->config->wwwroot, "", $forwarder);
102 34
		$forwarder = str_replace("http://", "", $forwarder);
103 34
		$forwarder = str_replace("@", "", $forwarder);
104 34
		if (substr($forwarder, 0, 1) == "/") {
105 1
			$forwarder = substr($forwarder, 1);
106
		}
107
108 34
		$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 34
		$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 34
		};
134
135 34
		if (!isset($this->actions[$action])) {
136 5
			return $forward('actionundefined', ELGG_HTTP_NOT_IMPLEMENTED);
137
		}
138
139 29
		$user = $this->session->getLoggedInUser();
140
141
		// access checks
142 29
		switch ($this->actions[$action]['access']) {
143 29
			case 'public':
144 27
				break;
145 2
			case 'logged_in':
146 1
				if (!$user) {
147 1
					return $forward('actionloggedout', ELGG_HTTP_FORBIDDEN);
148
				}
149
				break;
150
			default:
151
				// admin or misspelling
152 1
				if (!$user || !$user->isAdmin()) {
153 1
					return $forward('actionunauthorized', ELGG_HTTP_FORBIDDEN);
154
				}
155
		}
156
157 27
		ob_start();
158
		
159
		// To quietly cancel the file, return a falsey value in the "action" hook.
160 27
		if (!_elgg_services()->hooks->trigger('action', $action, null, true)) {
161 1
			return $forward('', ELGG_HTTP_OK);
162
		}
163
164 26
		$file = $this->actions[$action]['file'];
165
166 26
		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 24
		$action_timeout = $this->config->action_time_limit;
173 24
		if (isset($action_timeout)) {
174
			set_time_limit($action_timeout);
175
		}
176
177 24
		$result = Includer::includeFile($file);
178 24
		if ($result instanceof ResponseBuilder) {
179 11
			ob_end_clean();
180 11
			return $result;
181
		}
182
183 13
		return $forward('', ELGG_HTTP_OK);
184
	}
185
	
186
	/**
187
	 * @see elgg_register_action()
188
	 * @access private
189
	 */
190 329
	public function register($action, $filename = "", $access = 'logged_in') {
191
		// plugins are encouraged to call actions with a trailing / to prevent 301
192
		// redirects but we store the actions without it
193 329
		$action = rtrim($action, '/');
194
	
195 329
		if (empty($filename)) {
196 294
			$path = __DIR__ . '/../../../actions';
197 294
			$filename = realpath("$path/$action.php");
198
		}
199
200 329
		if (!in_array($access, self::$access_levels)) {
201 1
			_elgg_services()->logger->error("Unrecognized value '$access' for \$access in " . __METHOD__);
202 1
			$access = 'admin';
203
		}
204
	
205 329
		$this->actions[$action] = [
206 329
			'file' => $filename,
207 329
			'access' => $access,
208
		];
209 329
		return true;
210
	}
211
	
212
	/**
213
	 * @see elgg_unregister_action()
214
	 * @access private
215
	 */
216 1
	public function unregister($action) {
217 1
		if (isset($this->actions[$action])) {
218 1
			unset($this->actions[$action]);
219 1
			return true;
220
		} else {
221 1
			return false;
222
		}
223
	}
224
225
	/**
226
	 * @see validate_action_token()
227
	 * @access private
228
	 */
229 39
	public function validateActionToken($visible_errors = true, $token = null, $ts = null) {
230 39
		if (!$token) {
231 36
			$token = get_input('__elgg_token');
232
		}
233
	
234 39
		if (!$ts) {
235 36
			$ts = get_input('__elgg_ts');
236
		}
237
238 39
		$session_id = $this->session->getId();
239
240 39
		if (($token) && ($ts) && ($session_id)) {
241 39
			if ($this->validateTokenOwnership($token, $ts)) {
242 37
				if ($this->validateTokenTimestamp($ts)) {
243
					// We have already got this far, so unless anything
244
					// else says something to the contrary we assume we're ok
245 36
					$returnval = _elgg_services()->hooks->trigger('action_gatekeeper:permissions:check', 'all', [
246 36
						'token' => $token,
247 36
						'time' => $ts
248 36
					], true);
249
250 36
					if ($returnval) {
251 36
						return true;
252
					} else if ($visible_errors) {
253
						register_error(_elgg_services()->translator->translate('actiongatekeeper:pluginprevents'));
254
					}
255 1 View Code Duplication
				} else if ($visible_errors) {
256
					// this is necessary because of #5133
257
					if (elgg_is_xhr()) {
258
						register_error(_elgg_services()->translator->translate(
259
							'js:security:token_refresh_failed',
260
							[$this->config->wwwroot]
261
						));
262
					} else {
263 1
						register_error(_elgg_services()->translator->translate('actiongatekeeper:timeerror'));
264
					}
265
				}
266 3 View Code Duplication
			} else if ($visible_errors) {
267
				// this is necessary because of #5133
268 1
				if (elgg_is_xhr()) {
269
					register_error(_elgg_services()->translator->translate('js:security:token_refresh_failed', [$this->config->wwwroot]));
270
				} else {
271 4
					register_error(_elgg_services()->translator->translate('actiongatekeeper:tokeninvalid'));
272
				}
273
			}
274
		} else {
275 1
			$req = _elgg_services()->request;
276 1
			$length = $req->server->get('CONTENT_LENGTH');
277 1
			$post_count = count($req->request);
278 1
			if ($length && $post_count < 1) {
279
				// The size of $_POST or uploaded file has exceed the size limit
280
				$error_msg = _elgg_services()->hooks->trigger('action_gatekeeper:upload_exceeded_msg', 'all', [
281
					'post_size' => $length,
282
					'visible_errors' => $visible_errors,
283
				], _elgg_services()->translator->translate('actiongatekeeper:uploadexceeded'));
284
			} else {
285 1
				$error_msg = _elgg_services()->translator->translate('actiongatekeeper:missingfields');
286
			}
287 1
			if ($visible_errors) {
288 1
				register_error($error_msg);
289
			}
290
		}
291
292 5
		return false;
293
	}
294
295
	/**
296
	 * Is the token timestamp within acceptable range?
297
	 *
298
	 * @param int $ts timestamp from the CSRF token
299
	 *
300
	 * @return bool
301
	 */
302 37
	protected function validateTokenTimestamp($ts) {
303 37
		$timeout = $this->getActionTokenTimeout();
304 37
		$now = $this->getCurrentTime()->getTimestamp();
305 37
		return ($timeout == 0 || ($ts > $now - $timeout) && ($ts < $now + $timeout));
306
	}
307
308
	/**
309
	 * @see ActionsService::validateActionToken
310
	 * @access private
311
	 * @since 1.9.0
312
	 * @return int number of seconds that action token is valid
313
	 */
314 38
	public function getActionTokenTimeout() {
315 38
		if (($timeout = $this->config->action_token_timeout) === null) {
316
			// default to 2 hours
317 38
			$timeout = 2;
318
		}
319 38
		$hour = 60 * 60;
320 38
		return (int) ((float) $timeout * $hour);
321
	}
322
323
	/**
324
	 * @return bool
325
	 * @see action_gatekeeper()
326
	 * @access private
327
	 */
328 36
	public function gatekeeper($action) {
329 36
		if ($action === 'login') {
330
			if ($this->validateActionToken(false)) {
331
				return true;
332
			}
333
334
			$token = get_input('__elgg_token');
335
			$ts = (int) get_input('__elgg_ts');
336
			if ($token && $this->validateTokenTimestamp($ts)) {
337
				// The tokens are present and the time looks valid: this is probably a mismatch due to the
338
				// login form being on a different domain.
339
				register_error(_elgg_services()->translator->translate('actiongatekeeper:crosssitelogin'));
340
				_elgg_services()->responseFactory->redirect('login', 'csrf');
341
				return false;
342
			}
343
		}
344
		
345 36
		if ($this->validateActionToken()) {
346 35
			return true;
347
		}
348
			
349 2
		_elgg_services()->responseFactory->redirect(REFERER, 'csrf');
350 2
		return false;
351
	}
352
353
	/**
354
	 * Was the given token generated for the session defined by session_token?
355
	 *
356
	 * @param string $token         CSRF token
357
	 * @param int    $timestamp     Unix time
358
	 * @param string $session_token Session-specific token
359
	 *
360
	 * @return bool
361
	 * @access private
362
	 */
363 40
	public function validateTokenOwnership($token, $timestamp, $session_token = '') {
364 40
		$required_token = $this->generateActionToken($timestamp, $session_token);
365
366 40
		return _elgg_services()->crypto->areEqual($token, $required_token);
367
	}
368
	
369
	/**
370
	 * Generate a token from a session token (specifying the user), the timestamp, and the site key.
371
	 *
372
	 * @see generate_action_token()
373
	 *
374
	 * @param int    $timestamp     Unix timestamp
375
	 * @param string $session_token Session-specific token
376
	 *
377
	 * @return string
378
	 * @access private
379
	 */
380 40
	public function generateActionToken($timestamp, $session_token = '') {
381 40
		if (!$session_token) {
382 40
			$session_token = elgg_get_session()->get('__elgg_session');
383 40
			if (!$session_token) {
384
				return false;
385
			}
386
		}
387
388 40
		return _elgg_services()->hmac->getHmac([(int) $timestamp, $session_token], 'md5')
389 40
			->getToken();
390
	}
391
	
392
	/**
393
	 * @see elgg_action_exists()
394
	 * @access private
395
	 */
396 4
	public function exists($action) {
397 4
		return (isset($this->actions[$action]) && file_exists($this->actions[$action]['file']));
398
	}
399
	
400
	/**
401
	 * Get all actions
402
	 *
403
	 * @return array
404
	 */
405 3
	public function getAllActions() {
406 3
		return $this->actions;
407
	}
408
409
	/**
410
	 * Send an updated CSRF token, provided the page's current tokens were not fake.
411
	 *
412
	 * @return ResponseBuilder
413
	 * @access private
414
	 */
415 1
	public function handleTokenRefreshRequest() {
416 1
		if (!elgg_is_xhr()) {
417
			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...
418
		}
419
420
		// the page's session_token might have expired (not matching __elgg_session in the session), but
421
		// we still allow it to be given to validate the tokens in the page.
422 1
		$session_token = get_input('session_token', null, false);
423 1
		$pairs = (array) get_input('pairs', [], false);
424 1
		$valid_tokens = (object) [];
425 1
		foreach ($pairs as $pair) {
426 1
			list($ts, $token) = explode(',', $pair, 2);
427 1
			if ($this->validateTokenOwnership($token, $ts, $session_token)) {
428 1
				$valid_tokens->{$token} = true;
429
			}
430
		}
431
432 1
		$ts = $this->getCurrentTime()->getTimestamp();
433 1
		$token = $this->generateActionToken($ts);
434
		$data = [
435
			'token' => [
436 1
				'__elgg_ts' => $ts,
437 1
				'__elgg_token' => $token,
438 1
				'logged_in' => $this->session->isLoggedIn(),
439
			],
440 1
			'valid_tokens' => $valid_tokens,
441 1
			'session_token' => $this->session->get('__elgg_session'),
442 1
			'user_guid' => $this->session->getLoggedInUserGuid(),
443
		];
444
445 1
		elgg_set_http_header("Content-Type: application/json;charset=utf-8");
446 1
		return elgg_ok_response($data);
447
	}
448
}
449
450