PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / 11.0.1
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress v11.0.1
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 11.0.1, at back/Helpers/global/security.php

559 lines 15.9 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');
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
136 if (strpos($url, '//') === 0) {
137 $to_unset[] = 'scheme';
138 $url = 'placeholder:'.$url;
139 } elseif (strpos($url, '/') === 0) {
140 $to_unset[] = 'scheme';
141 $to_unset[] = 'host';
142 $url = 'placeholder://placeholder'.$url;
143 }
144
145 $parsed = acym_parseUrl($url);
146
147 if (!empty($parsed)) {
148 foreach ($to_unset as $key) {
149 unset($parsed[$key]);
150 }
151 }
152
153 $front = '';
154
155 if (isset($parsed['scheme'])) {
156 $front .= $parsed['scheme'].'://';
157 } elseif ('/' === $url[0]) {
158 $front .= '//';
159 }
160
161 if (isset($parsed['user'])) {
162 $front .= $parsed['user'];
163 }
164
165 if (isset($parsed['pass'])) {
166 $front .= ':'.$parsed['pass'];
167 }
168
169 if (isset($parsed['user']) || isset($parsed['pass'])) {
170 $front .= '@';
171 }
172
173 if (isset($parsed['host'])) {
174 $front .= $parsed['host'];
175 }
176
177 if (isset($parsed['port'])) {
178 $front .= ':'.$parsed['port'];
179 }
180
181 $end_dirty = str_replace($front, '', $url);
182 $end_clean = str_replace(['[', ']'], ['%5B', '%5D'], $end_dirty);
183 $url = str_replace($end_dirty, $end_clean, $url);
184 }
185
186 return $url;
187 }
188
189 function acym_arrayToInteger(array &$array): void
190 {
191 $array = @array_map('intval', $array);
192 }
193
194 function acym_getIP(bool $full = false): string
195 {
196 $config = acym_config();
197 $ipCollection = $config->get('ip_collection', 'yes');
198
199 if (!$full && $ipCollection === 'no') {
200 return '';
201 }
202
203 $map = [
204 'HTTP_X_FORWARDED_IP',
205 'X_FORWARDED_FOR',
206 'HTTP_X_FORWARDED_FOR',
207 'HTTP_CLIENT_IP',
208 'HTTP_X_FORWARDED',
209 'HTTP_FORWARDED_FOR',
210 'HTTP_FORWARDED',
211 'REMOTE_ADDR',
212 ];
213
214 $ipAddress = '';
215 foreach ($map as $oneAttribute) {
216 $serverValue = acym_getVar('string', $oneAttribute, '', 'SERVER');
217 if (empty($serverValue) || strlen($serverValue) < 7) {
218 continue;
219 }
220
221 $ipAddress = $serverValue;
222 break;
223 }
224
225 // Load balancers and CDNs may put multiple IPs comma separated
226 if (strstr($ipAddress, ',') !== false) {
227 $addresses = explode(',', $ipAddress);
228 $ipAddress = trim(end($addresses));
229 }
230
231 // We strip tags here as the ip could be modified by something
232 $ipAddress = acym_stripTags($ipAddress);
233
234 if ($full || $ipCollection === 'yes') {
235 return $ipAddress;
236 }
237
238 return acym_anonymizeIP($ipAddress);
239 }
240
241 function acym_anonymizeIP(string $ipAddress): string
242 {
243 if (empty($ipAddress)) {
244 return '';
245 }
246
247 // IPv6
248 if (strpos($ipAddress, ':') !== false) {
249 $packed = inet_pton($ipAddress);
250 if ($packed === false) {
251 return '';
252 }
253
254 $anonymized = substr($packed, 0, 8).str_repeat("\x00", 8);
255
256 return inet_ntop($anonymized);
257 }
258
259 // IPv4
260 $parts = explode('.', $ipAddress);
261 if (count($parts) !== 4) {
262 return '';
263 }
264
265 $parts[3] = '0';
266
267 return implode('.', $parts);
268 }
269
270 function acym_generateKey(int $length): string
271 {
272 $charactersPool = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
273 $randomString = '';
274 $max = strlen($charactersPool) - 1;
275 for ($i = 0; $i < $length; $i++) {
276 $randomString .= $charactersPool[acym_rand(0, $max)];
277 }
278
279 return $randomString;
280 }
281
282 function acym_isRobot(): bool
283 {
284 if (empty($_SERVER)) {
285 return false;
286 }
287
288 // Real calls are made using GET
289 $requestMethod = acym_getVar('string', 'REQUEST_METHOD', '', 'SERVER');
290 if (!empty($requestMethod) && $requestMethod === 'HEAD') {
291 return true;
292 }
293
294 // SpamBayes checks the images in the sent emails, so the stats image too... Don't count it as opened!
295 $userAgent = acym_getVar('string', 'HTTP_USER_AGENT', '', 'SERVER');
296 if (!empty($userAgent) && strpos(strtolower($userAgent), 'spambayes') !== false) {
297 return true;
298 }
299
300 // Avoid auto-confirming by the Barracuda firewall installed on some mail clients
301 $remoteIp = acym_getVar('string', 'REMOTE_ADDR', '', 'SERVER');
302 if (!empty($remoteIp) && version_compare($remoteIp, '64.235.144.0', '>=') && version_compare($remoteIp, '64.235.159.255', '<=')) {
303 return true;
304 }
305
306 return false;
307 }
308
309 function acym_displayErrors(): void
310 {
311 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting -- intended error printing on export and debug mode.
312 error_reporting(E_ALL);
313 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Error display is toggled intentionally for debugging purposes.
314 @ini_set('display_errors', 1);
315 }
316
317 function acym_checkRobots(): void
318 {
319 $userAgent = acym_getVar('string', 'HTTP_USER_AGENT', null, 'SERVER');
320 if (!empty($userAgent) && preg_match('#(libwww-perl|python|googlebot)#i', $userAgent)) {
321 die('Not allowed for robots. Please contact us if you are not a robot');
322 }
323 }
324
325 function acym_noCache(): void
326 {
327 acym_header('Cache-Control: no-store, no-cache, must-revalidate');
328 acym_header('Cache-Control: post-check=0, pre-check=0', false);
329 acym_header('Pragma: no-cache');
330 acym_header('Expires: Wed, 17 Sep 1975 21:32:10 GMT');
331 }
332
333 function acym_conditionsContainRestrictedFilter($data): bool
334 {
335 if (!is_array($data)) {
336 return false;
337 }
338
339 foreach ($data as $key => $value) {
340 if ($key === 'sqladvanced' && is_array($value) && (!empty($value['query']) || !empty($value['conn_host']))) {
341 return true;
342 }
343 if (is_array($value) && acym_conditionsContainRestrictedFilter($value)) {
344 return true;
345 }
346 }
347
348 return false;
349 }
350
351 function acym_blockRestrictedFilterAjax($conditions): void
352 {
353 if (acym_conditionsContainRestrictedFilter($conditions) && !acym_hasAdminPermissions()) {
354 acym_sendAjaxResponse(acym_translation('ACYM_ACCESS_DENIED'), [], false);
355 }
356 }
357
358 function acym_isAllowed(string $controller, string $task = ''): bool
359 {
360 if ($controller === 'dashboard') {
361 // Promotional pages and the home page hold no sensitive data, but they stay reserved to the back-end users
362 if (in_array($task, ['acychecker', 'features', 'listing', 'upgrade'])) {
363 return acym_hasBackofficeAccess();
364 }
365
366 return acym_isAllowed('configuration');
367 }
368
369 $controller = str_replace('front', '', $controller);
370
371 $config = acym_config();
372 $globalAccess = $config->get('acl_'.$controller, ACYM_ADMIN_GROUP);
373 if ($globalAccess === 'all') {
374 return true;
375 }
376
377 $globalAccess = explode(',', $globalAccess);
378 $globalAccess[] = ACYM_ADMIN_GROUP;
379
380 $userId = acym_currentUserId();
381 if (empty($userId)) {
382 return false;
383 }
384
385 $userGroups = acym_getGroupsByUser($userId);
386 if (empty($userGroups)) {
387 return false;
388 }
389
390 foreach ($userGroups as $oneGroup) {
391 if (in_array($oneGroup, $globalAccess)) {
392 return true;
393 }
394 }
395
396 // Having access to campaigns gives access to some email actions
397 if ($controller === 'mails' && in_array($task, ['autoSave', 'getTemplateAjax']) && acym_isAllowed('campaigns')) {
398 return true;
399 }
400
401 // The language management is made in the configuration
402 if ($controller === 'language' && acym_isAllowed('configuration')) {
403 return true;
404 }
405
406 // When editing a campaign or a template, the user might need attachments
407 if ($controller === 'file' && (acym_isAllowed('campaigns') || acym_isAllowed('mails'))) {
408 return true;
409 }
410
411 // When editing a campaign, one can need to get a segment's number of affected users from the segment step
412 if (in_array($task, ['countResultsTotal', 'countGlobalBySegmentId', 'countResults']) && acym_isAllowed('campaigns')) {
413 return true;
414 }
415
416 // When editing an email, lists might be loaded with ajax
417 if ($controller === 'lists' && $task === 'setAjaxListing' && (acym_isAllowed('campaigns') || acym_isAllowed('mails'))) {
418 return true;
419 }
420
421 // The zones are used in the editor
422 if (
423 $controller === 'zones'
424 && (
425 acym_isAllowed('campaigns')
426 || acym_isAllowed('mails')
427 )
428 ) {
429 return true;
430 }
431
432 // Togglable icons are located in most features
433 if (
434 $controller === 'toggle'
435 && (
436 acym_isAllowed('campaigns')
437 || acym_isAllowed('mails')
438 || acym_isAllowed('automation')
439 || acym_isAllowed('segments')
440 || acym_isAllowed('scenarios')
441 || acym_isAllowed('forms')
442 || acym_isAllowed('users')
443 || acym_isAllowed('lists')
444 || acym_isAllowed('fields')
445 || acym_isAllowed('bounces')
446 )
447 ) {
448 return true;
449 }
450
451 // The add-ons are used in the editor and the filters
452 if (
453 $controller === 'dynamics'
454 && (
455 acym_isAllowed('campaigns')
456 || acym_isAllowed('mails')
457 || acym_isAllowed('automation')
458 || acym_isAllowed('segments')
459 || acym_isAllowed('scenarios')
460 )
461 ) {
462 return true;
463 }
464
465 // The entity select is used in multiple features
466 if (
467 $task === 'loadEntityFront'
468 && (
469 acym_isAllowed('campaigns')
470 || acym_isAllowed('mails')
471 || acym_isAllowed('users')
472 || acym_isAllowed('lists')
473 )
474 ) {
475 return true;
476 }
477
478 return false;
479 }
480
481 function acym_raiseError(int $code, string $message): void
482 {
483 // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- Ending script here, no hook available.
484 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')).'">';
485 echo '<div id="acym_wrapper">';
486 acym_display('Error '.$code.': '.$message, 'error', false);
487 echo '</div>';
488 http_response_code($code);
489 exit;
490 }
491
492 /**
493 * Check if the license is valid, make this check weekly
494 */
495 function acym_isLicenseValidWeekly(): bool
496 {
497 $config = acym_config();
498 $expirationDate = $config->get('expirationdate', 0);
499 // $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
500 if (empty($expirationDate) || (time() - 604800) > $config->get('lastlicensecheck', 0)) {
501 UpdatemeHelper::getLicenseInfo();
502 $config = acym_config(true);
503 $expirationDate = $config->get('expirationdate', 0);
504 }
505
506 return $expirationDate >= time();
507 }
508
509 /**
510 * Generate a time-limited autologin token for a subscriber.
511 * Token format: hex(timestamp).hmac_signature
512 * The token is bound to the subscriber ID and their secret key, and signed with the site's auth salt.
513 */
514 function acym_generateAutologinToken(int $subId, string $subKey): string
515 {
516 $timestamp = time();
517 $payload = $subId.'|'.$timestamp;
518 $secret = $subKey.acym_getSiteSalt();
519 $signature = hash_hmac('sha256', $payload, $secret);
520
521 return dechex($timestamp).'.'.$signature;
522 }
523
524 /**
525 * Verify an autologin token. Returns true if the token is valid and not expired.
526 *
527 * @param int $subId The subscriber ID
528 * @param string $token The token from the URL
529 * @param string $storedKey The subscriber's stored secret key
530 */
531 function acym_verifyAutologinToken(int $subId, string $token, string $storedKey): bool
532 {
533 $parts = explode('.', $token, 2);
534 if (count($parts) !== 2) {
535 return false;
536 }
537
538 $timestamp = @hexdec($parts[0]);
539 $signature = $parts[1];
540
541 if (empty($timestamp) || $timestamp <= 0) {
542 return false;
543 }
544
545 $config = acym_config();
546 $maxAgeHours = intval($config->get('autologin_token_duration', 48));
547 $maxAge = $maxAgeHours * 3600;
548
549 if ((time() - $timestamp) > $maxAge) {
550 return false;
551 }
552
553 $payload = $subId.'|'.$timestamp;
554 $secret = $storedKey.acym_getSiteSalt();
555 $expectedSignature = hash_hmac('sha256', $payload, $secret);
556
557 return hash_equals($expectedSignature, $signature);
558 }
559