PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / trunk
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress vtrunk
11.1.1 11.1.0 11.0.5 11.0.4 11.0.3 11.0.2 11.0.1 11.0.0 10.11.1 10.11.0 10.10.2 10.10.1 10.10.0 10.9.1 trunk 10.0.0 10.0.1 10.1.0 10.1.1 10.1.2 10.1.3 10.1.4 10.2.0 10.2.1 10.2.2 All 62 releases
acymailing / back / Helpers / global / security.php

security.php in AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress trunk, at back/Helpers/global/security.php

560 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 defined('ABSPATH') || die('Restricted Access');
3
4 use AcyMailing\Helpers\UpdatemeHelper;
5
6 /**
7 * To secure text echoed in HTML attributes
8 */
9 function acym_escape(string $text): string
10 {
11 if (empty($text) && !is_numeric($text)) {
12 return '';
13 }
14
15 if (!preg_match('#[&<>"\']#', $text)) {
16 return $text;
17 }
18
19 return htmlspecialchars($text, ENT_QUOTES, 'UTF-8', false);
20 }
21
22 /**
23 * To secure text echoed in HTML content
24 */
25 function acym_escapeHtml(string $text): string
26 {
27 return esc_attr($text);
28 }
29
30 /**
31 * To secure text echoed in HTML content, and whitelisting some HTML tags
32 * $allowedHtmlTags:
33 * [
34 * 'a' => [
35 * 'href' => true
36 * ],
37 * 'br' => []
38 * ]
39 */
40 function acym_escapeHtmlWithAllowedTags(string $text, array $allowedHtmlTags = []): string
41 {
42 // Attributes whose value is an URL and must therefore not carry a dangerous scheme.
43 $urlAttributes = ['href', 'src', 'xlink:href', 'action', 'formaction'];
44
45 return preg_replace_callback(
46 '#<\s*(/?)\s*([a-z][a-z0-9]*)\b([^>]*?)(/?)\s*>#i',
47 static function (array $m) use ($allowedHtmlTags, $urlAttributes): string {
48 [, $closing, $tag, $attrString, $selfClosing] = $m;
49 $tag = strtolower($tag);
50
51 if (!array_key_exists($tag, $allowedHtmlTags)) {
52 return '';
53 }
54
55 if ($closing === '/') {
56 return '</'.$tag.'>';
57 }
58
59 $keptAttrs = '';
60 if (
61 !empty($allowedHtmlTags[$tag])
62 && preg_match_all(
63 '#([a-z][a-z0-9:_-]*)\s*(?:=\s*("[^"]*"|\'[^\']*\'|[^\s>]+))?#i',
64 $attrString,
65 $attrMatches,
66 PREG_SET_ORDER
67 )
68 ) {
69 foreach ($attrMatches as $attr) {
70 $name = strtolower($attr[1]);
71 if (empty($allowedHtmlTags[$tag][$name])) {
72 continue;
73 }
74
75 $value = isset($attr[2]) ? $attr[2] : '';
76
77 // Drop URL attributes whose value uses a dangerous scheme.
78 if ($value !== '' && in_array($name, $urlAttributes, true)) {
79 // Normalize (unquote, decode entities, remove whitespace/control chars
80 // that could hide the scheme) only to run the check.
81 $normalized = strtolower(
82 preg_replace('/[\s\x00-\x20]+/', '', html_entity_decode(trim($value, '"\''), ENT_QUOTES))
83 );
84 if (preg_match('#^(?:javascript|data|vbscript):#', $normalized)) {
85 continue;
86 }
87 }
88
89 $keptAttrs .= $value !== '' ? " {$name}={$value}" : " {$name}";
90 }
91 }
92
93 $selfClose = $selfClosing === '/' ? ' /' : '';
94
95 return '<'.$tag.$keptAttrs.$selfClose.'>';
96 },
97 $text
98 );
99 }
100
101 /**
102 * To secure URLs echoed in HTML attributes
103 */
104 function acym_escapeUrl(?string $url): string
105 {
106 if (empty($url)) {
107 return '';
108 }
109
110 $url = str_replace(' ', '%20', ltrim($url));
111 $url = preg_replace('|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\\x80-\\xff]|i', '', $url);
112
113 if (empty($url)) {
114 return '';
115 }
116
117 if (0 !== stripos($url, 'mailto:')) {
118 $strip = ['%0d', '%0a', '%0D', '%0A'];
119 $count = 1;
120 while ($count) {
121 $url = str_replace($strip, '', $url, $count);
122 }
123 }
124
125 $url = str_replace(';//', '://', $url);
126 if (strpos($url, ':') === false && !in_array($url[0], ['/', '#', '?'], true) && !preg_match('/^[a-z0-9-]+?\.php/i', $url)) {
127 $url = 'https://'.$url;
128 }
129
130 $url = str_replace('&amp;', '&#038;', $url);
131 $url = str_replace("'", '&#039;', $url);
132
133 if (strpos($url, '[') !== false || strpos($url, ']') !== false) {
134 $to_unset = [];
135 $urlToParse = $url;
136
137 if (strpos($url, '//') === 0) {
138 $to_unset[] = 'scheme';
139 $urlToParse = 'placeholder:'.$url;
140 } elseif (strpos($url, '/') === 0) {
141 $to_unset[] = 'scheme';
142 $to_unset[] = 'host';
143 $urlToParse = 'placeholder://placeholder'.$url;
144 }
145
146 $parsed = acym_parseUrl($urlToParse);
147
148 if (!empty($parsed)) {
149 foreach ($to_unset as $key) {
150 unset($parsed[$key]);
151 }
152 }
153
154 $front = '';
155
156 if (isset($parsed['scheme'])) {
157 $front .= $parsed['scheme'].'://';
158 } elseif ('/' === $url[0]) {
159 $front .= '//';
160 }
161
162 if (isset($parsed['user'])) {
163 $front .= $parsed['user'];
164 }
165
166 if (isset($parsed['pass'])) {
167 $front .= ':'.$parsed['pass'];
168 }
169
170 if (isset($parsed['user']) || isset($parsed['pass'])) {
171 $front .= '@';
172 }
173
174 if (isset($parsed['host'])) {
175 $front .= $parsed['host'];
176 }
177
178 if (isset($parsed['port'])) {
179 $front .= ':'.$parsed['port'];
180 }
181
182 $end_dirty = str_replace($front, '', $url);
183 $end_clean = str_replace(['[', ']'], ['%5B', '%5D'], $end_dirty);
184 $url = str_replace($end_dirty, $end_clean, $url);
185 }
186
187 return $url;
188 }
189
190 function acym_arrayToInteger(array &$array): void
191 {
192 $array = @array_map('intval', $array);
193 }
194
195 function acym_getIP(bool $full = false): string
196 {
197 $config = acym_config();
198 $ipCollection = $config->get('ip_collection', 'yes');
199
200 if (!$full && $ipCollection === 'no') {
201 return '';
202 }
203
204 $map = [
205 'HTTP_X_FORWARDED_IP',
206 'X_FORWARDED_FOR',
207 'HTTP_X_FORWARDED_FOR',
208 'HTTP_CLIENT_IP',
209 'HTTP_X_FORWARDED',
210 'HTTP_FORWARDED_FOR',
211 'HTTP_FORWARDED',
212 'REMOTE_ADDR',
213 ];
214
215 $ipAddress = '';
216 foreach ($map as $oneAttribute) {
217 $serverValue = acym_getVar('string', $oneAttribute, '', 'SERVER');
218 if (empty($serverValue) || strlen($serverValue) < 7) {
219 continue;
220 }
221
222 $ipAddress = $serverValue;
223 break;
224 }
225
226 // Load balancers and CDNs may put multiple IPs comma separated
227 if (strstr($ipAddress, ',') !== false) {
228 $addresses = explode(',', $ipAddress);
229 $ipAddress = trim(end($addresses));
230 }
231
232 // We strip tags here as the ip could be modified by something
233 $ipAddress = acym_stripTags($ipAddress);
234
235 if ($full || $ipCollection === 'yes') {
236 return $ipAddress;
237 }
238
239 return acym_anonymizeIP($ipAddress);
240 }
241
242 function acym_anonymizeIP(string $ipAddress): string
243 {
244 if (empty($ipAddress)) {
245 return '';
246 }
247
248 // IPv6
249 if (strpos($ipAddress, ':') !== false) {
250 $packed = inet_pton($ipAddress);
251 if ($packed === false) {
252 return '';
253 }
254
255 $anonymized = substr($packed, 0, 8).str_repeat("\x00", 8);
256
257 return inet_ntop($anonymized);
258 }
259
260 // IPv4
261 $parts = explode('.', $ipAddress);
262 if (count($parts) !== 4) {
263 return '';
264 }
265
266 $parts[3] = '0';
267
268 return implode('.', $parts);
269 }
270
271 function acym_generateKey(int $length): string
272 {
273 $charactersPool = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
274 $randomString = '';
275 $max = strlen($charactersPool) - 1;
276 for ($i = 0; $i < $length; $i++) {
277 $randomString .= $charactersPool[acym_rand(0, $max)];
278 }
279
280 return $randomString;
281 }
282
283 function acym_isRobot(): bool
284 {
285 if (empty($_SERVER)) {
286 return false;
287 }
288
289 // Real calls are made using GET
290 $requestMethod = acym_getVar('string', 'REQUEST_METHOD', '', 'SERVER');
291 if (!empty($requestMethod) && $requestMethod === 'HEAD') {
292 return true;
293 }
294
295 // SpamBayes checks the images in the sent emails, so the stats image too... Don't count it as opened!
296 $userAgent = acym_getVar('string', 'HTTP_USER_AGENT', '', 'SERVER');
297 if (!empty($userAgent) && strpos(strtolower($userAgent), 'spambayes') !== false) {
298 return true;
299 }
300
301 // Avoid auto-confirming by the Barracuda firewall installed on some mail clients
302 $remoteIp = acym_getVar('string', 'REMOTE_ADDR', '', 'SERVER');
303 if (!empty($remoteIp) && version_compare($remoteIp, '64.235.144.0', '>=') && version_compare($remoteIp, '64.235.159.255', '<=')) {
304 return true;
305 }
306
307 return false;
308 }
309
310 function acym_displayErrors(): void
311 {
312 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting,PluginCheck.CodeAnalysis.PHPErrorReporting.DirectErrorReportingCall -- intended error printing on export and debug mode.
313 error_reporting(E_ALL);
314 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,PluginCheck.CodeAnalysis.PHPErrorReporting.IniDirectiveDisplay_errors -- Error display is toggled intentionally for debugging purposes.
315 @ini_set('display_errors', 1);
316 }
317
318 function acym_checkRobots(): void
319 {
320 $userAgent = acym_getVar('string', 'HTTP_USER_AGENT', null, 'SERVER');
321 if (!empty($userAgent) && preg_match('#(libwww-perl|python|googlebot)#i', $userAgent)) {
322 die('Not allowed for robots. Please contact us if you are not a robot');
323 }
324 }
325
326 function acym_noCache(): void
327 {
328 acym_header('Cache-Control: no-store, no-cache, must-revalidate');
329 acym_header('Cache-Control: post-check=0, pre-check=0', false);
330 acym_header('Pragma: no-cache');
331 acym_header('Expires: Wed, 17 Sep 1975 21:32:10 GMT');
332 }
333
334 function acym_conditionsContainRestrictedFilter($data): bool
335 {
336 if (!is_array($data)) {
337 return false;
338 }
339
340 foreach ($data as $key => $value) {
341 if ($key === 'sqladvanced' && is_array($value) && (!empty($value['query']) || !empty($value['conn_host']))) {
342 return true;
343 }
344 if (is_array($value) && acym_conditionsContainRestrictedFilter($value)) {
345 return true;
346 }
347 }
348
349 return false;
350 }
351
352 function acym_blockRestrictedFilterAjax($conditions): void
353 {
354 if (acym_conditionsContainRestrictedFilter($conditions) && !acym_hasAdminPermissions()) {
355 acym_sendAjaxResponse(acym_translation('ACYM_ACCESS_DENIED'), [], false);
356 }
357 }
358
359 function acym_isAllowed(string $controller, string $task = ''): bool
360 {
361 if ($controller === 'dashboard') {
362 // Promotional pages and the home page hold no sensitive data, but they stay reserved to the back-end users
363 if (in_array($task, ['acychecker', 'features', 'listing', 'upgrade'])) {
364 return acym_hasBackofficeAccess();
365 }
366
367 return acym_isAllowed('configuration');
368 }
369
370 $controller = str_replace('front', '', $controller);
371
372 $config = acym_config();
373 $globalAccess = $config->get('acl_'.$controller, ACYM_ADMIN_GROUP);
374 if ($globalAccess === 'all') {
375 return true;
376 }
377
378 $globalAccess = explode(',', $globalAccess);
379 $globalAccess[] = ACYM_ADMIN_GROUP;
380
381 $userId = acym_currentUserId();
382 if (empty($userId)) {
383 return false;
384 }
385
386 $userGroups = acym_getGroupsByUser($userId);
387 if (empty($userGroups)) {
388 return false;
389 }
390
391 foreach ($userGroups as $oneGroup) {
392 if (in_array($oneGroup, $globalAccess)) {
393 return true;
394 }
395 }
396
397 // Having access to campaigns gives access to some email actions
398 if ($controller === 'mails' && $task === 'getTemplateAjax' && acym_isAllowed('campaigns')) {
399 return true;
400 }
401
402 // The language management is made in the configuration
403 if ($controller === 'language' && acym_isAllowed('configuration')) {
404 return true;
405 }
406
407 // When editing a campaign or a template, the user might need attachments
408 if ($controller === 'file' && (acym_isAllowed('campaigns') || acym_isAllowed('mails'))) {
409 return true;
410 }
411
412 // When editing a campaign, one can need to get a segment's number of affected users from the segment step
413 if (in_array($task, ['countResultsTotal', 'countGlobalBySegmentId', 'countResults']) && acym_isAllowed('campaigns')) {
414 return true;
415 }
416
417 // When editing an email, lists might be loaded with ajax
418 if ($controller === 'lists' && $task === 'setAjaxListing' && (acym_isAllowed('campaigns') || acym_isAllowed('mails'))) {
419 return true;
420 }
421
422 // The zones are used in the editor
423 if (
424 $controller === 'zones'
425 && (
426 acym_isAllowed('campaigns')
427 || acym_isAllowed('mails')
428 )
429 ) {
430 return true;
431 }
432
433 // Togglable icons are located in most features
434 if (
435 $controller === 'toggle'
436 && (
437 acym_isAllowed('campaigns')
438 || acym_isAllowed('mails')
439 || acym_isAllowed('automation')
440 || acym_isAllowed('segments')
441 || acym_isAllowed('scenarios')
442 || acym_isAllowed('forms')
443 || acym_isAllowed('users')
444 || acym_isAllowed('lists')
445 || acym_isAllowed('fields')
446 || acym_isAllowed('bounces')
447 )
448 ) {
449 return true;
450 }
451
452 // The add-ons are used in the editor and the filters
453 if (
454 $controller === 'dynamics'
455 && (
456 acym_isAllowed('campaigns')
457 || acym_isAllowed('mails')
458 || acym_isAllowed('automation')
459 || acym_isAllowed('segments')
460 || acym_isAllowed('scenarios')
461 )
462 ) {
463 return true;
464 }
465
466 // The entity select is used in multiple features
467 if (
468 $task === 'loadEntityFront'
469 && (
470 acym_isAllowed('campaigns')
471 || acym_isAllowed('mails')
472 || acym_isAllowed('users')
473 || acym_isAllowed('lists')
474 )
475 ) {
476 return true;
477 }
478
479 return false;
480 }
481
482 function acym_raiseError(int $code, string $message): void
483 {
484 // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- Ending script here, no hook available.
485 echo '<link type="text/css" rel="stylesheet" href="'.esc_url(ACYM_CSS.'back_global.min.css?v='.filemtime(ACYM_MEDIA.'css'.DS.'back_global.min.css')).'">';
486 echo '<div id="acym_wrapper">';
487 acym_display('Error '.$code.': '.$message, 'error', false);
488 echo '</div>';
489 http_response_code($code);
490 exit;
491 }
492
493 /**
494 * Check if the license is valid, make this check weekly
495 */
496 function acym_isLicenseValidWeekly(): bool
497 {
498 $config = acym_config();
499 $expirationDate = $config->get('expirationdate', 0);
500 // $expirationDate is empty when no call has been made yet on our server, or when it is a Starter license. Starter licenses don't have access to the cron
501 if (empty($expirationDate) || (time() - 604800) > $config->get('lastlicensecheck', 0)) {
502 UpdatemeHelper::getLicenseInfo();
503 $config = acym_config(true);
504 $expirationDate = $config->get('expirationdate', 0);
505 }
506
507 return $expirationDate >= time();
508 }
509
510 /**
511 * Generate a time-limited autologin token for a subscriber.
512 * Token format: hex(timestamp).hmac_signature
513 * The token is bound to the subscriber ID and their secret key, and signed with the site's auth salt.
514 */
515 function acym_generateAutologinToken(int $subId, string $subKey): string
516 {
517 $timestamp = time();
518 $payload = $subId.'|'.$timestamp;
519 $secret = $subKey.acym_getSiteSalt();
520 $signature = hash_hmac('sha256', $payload, $secret);
521
522 return dechex($timestamp).'.'.$signature;
523 }
524
525 /**
526 * Verify an autologin token. Returns true if the token is valid and not expired.
527 *
528 * @param int $subId The subscriber ID
529 * @param string $token The token from the URL
530 * @param string $storedKey The subscriber's stored secret key
531 */
532 function acym_verifyAutologinToken(int $subId, string $token, string $storedKey): bool
533 {
534 $parts = explode('.', $token, 2);
535 if (count($parts) !== 2) {
536 return false;
537 }
538
539 $timestamp = @hexdec($parts[0]);
540 $signature = $parts[1];
541
542 if (empty($timestamp) || $timestamp <= 0) {
543 return false;
544 }
545
546 $config = acym_config();
547 $maxAgeHours = intval($config->get('autologin_token_duration', 48));
548 $maxAge = $maxAgeHours * 3600;
549
550 if ((time() - $timestamp) > $maxAge) {
551 return false;
552 }
553
554 $payload = $subId.'|'.$timestamp;
555 $secret = $storedKey.acym_getSiteSalt();
556 $expectedSignature = hash_hmac('sha256', $payload, $secret);
557
558 return hash_equals($expectedSignature, $signature);
559 }
560