PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / 11.1.0
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress v11.1.0
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
← All changes | back/Helpers/global/security.php +438 -36 10.0.1 → 11.1.0 View file →
@@ -1,34 +1,207 @@
1 1 <?php
2 +defined('ABSPATH') || die('Restricted Access');
2 3
3 -function acym_escape($text, $addSlashes = true)
4 +use AcyMailing\Helpers\UpdatemeHelper;
5 +
6 +/**
7 + * To secure text echoed in HTML attributes
8 + */
9 +function acym_escape(string $text): string
4 10 {
5 - if (is_array($text) || is_object($text)) {
6 - if ($addSlashes) {
7 - $text = str_replace('\\', '\\\\', json_encode($text));
8 - } else {
9 - $text = json_encode($text);
10 - }
11 + if (empty($text) && !is_numeric($text)) {
12 + return '';
11 13 }
12 14
13 - if (empty($text)) {
15 + if (!preg_match('#[&<>"\']#', $text)) {
14 16 return $text;
15 17 }
16 18
17 - return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
19 + return htmlspecialchars($text, ENT_QUOTES, 'UTF-8', false);
18 20 }
19 21
20 -function acym_arrayToInteger(&$array)
22 +/**
23 + * To secure text echoed in HTML content
24 + */
25 +function acym_escapeHtml(string $text): string
21 26 {
22 - if (is_array($array)) {
23 - $array = @array_map('intval', $array);
24 - } else {
25 - $array = [];
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 '';
26 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;
27 188 }
28 189
29 -function acym_getIP()
190 +function acym_arrayToInteger(array &$array): void
30 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 +
31 204 $map = [
32 205 'HTTP_X_FORWARDED_IP',
33 206 'X_FORWARDED_FOR',
34 207 'HTTP_X_FORWARDED_FOR',
@@ -40,63 +213,118 @@
40 213 ];
41 214
42 215 $ipAddress = '';
43 216 foreach ($map as $oneAttribute) {
44 - if (empty($_SERVER[$oneAttribute]) || strlen($_SERVER[$oneAttribute]) < 7) continue;
217 + $serverValue = acym_getVar('string', $oneAttribute, '', 'SERVER');
218 + if (empty($serverValue) || strlen($serverValue) < 7) {
219 + continue;
220 + }
45 221
46 - $ipAddress = $_SERVER[$oneAttribute];
222 + $ipAddress = $serverValue;
47 223 break;
48 224 }
49 225
226 + // Load balancers and CDNs may put multiple IPs comma separated
50 227 if (strstr($ipAddress, ',') !== false) {
51 228 $addresses = explode(',', $ipAddress);
52 229 $ipAddress = trim(end($addresses));
53 230 }
54 231
55 - return strip_tags($ipAddress);
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);
56 240 }
57 241
58 -function acym_generateKey($length)
242 +function acym_anonymizeIP(string $ipAddress): string
59 243 {
60 - $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
61 - $randstring = '';
62 - $max = strlen($characters) - 1;
63 - for ($i = 0 ; $i < $length ; $i++) {
64 - $randstring .= $characters[mt_rand(0, $max)];
244 + if (empty($ipAddress)) {
245 + return '';
65 246 }
66 247
67 - return $randstring;
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);
68 269 }
69 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 +
70 283 function acym_isRobot(): bool
71 284 {
72 285 if (empty($_SERVER)) {
73 286 return false;
74 287 }
75 - if (!empty($_SERVER['HTTP_USER_AGENT']) && strpos(strtolower($_SERVER['HTTP_USER_AGENT']), 'spambayes') !== false) {
288 +
289 + // Real calls are made using GET
290 + $requestMethod = acym_getVar('string', 'REQUEST_METHOD', '', 'SERVER');
291 + if (!empty($requestMethod) && $requestMethod === 'HEAD') {
76 292 return true;
77 293 }
78 - if (!empty($_SERVER['REMOTE_ADDR']) && version_compare($_SERVER['REMOTE_ADDR'], '64.235.144.0', '>=') && version_compare($_SERVER['REMOTE_ADDR'], '64.235.159.255', '<=')) {
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) {
79 298 return true;
80 299 }
81 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 +
82 307 return false;
83 308 }
84 309
85 -function acym_displayErrors()
310 +function acym_displayErrors(): void
86 311 {
312 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting,PluginCheck.CodeAnalysis.PHPErrorReporting.DirectErrorReportingCall -- intended error printing on export and debug mode.
87 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.
88 315 @ini_set('display_errors', 1);
89 316 }
90 317
91 -function acym_checkRobots()
318 +function acym_checkRobots(): void
92 319 {
93 - if (preg_match('#(libwww-perl|python|googlebot)#i', @$_SERVER['HTTP_USER_AGENT'])) {
320 + $userAgent = acym_getVar('string', 'HTTP_USER_AGENT', null, 'SERVER');
321 + if (!empty($userAgent) && preg_match('#(libwww-perl|python|googlebot)#i', $userAgent)) {
94 322 die('Not allowed for robots. Please contact us if you are not a robot');
95 323 }
96 324 }
97 325
98 -function acym_noCache()
326 +function acym_noCache(): void
99 327 {
100 328 acym_header('Cache-Control: no-store, no-cache, must-revalidate');
101 329 acym_header('Cache-Control: post-check=0, pre-check=0', false);
102 330 acym_header('Pragma: no-cache');
@@ -102,12 +330,48 @@
102 330 acym_header('Pragma: no-cache');
103 331 acym_header('Expires: Wed, 17 Sep 1975 21:32:10 GMT');
104 332 }
105 333
106 -function acym_isAllowed($controller, $task = ''): bool
334 +function acym_conditionsContainRestrictedFilter($data): bool
107 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 +
108 372 $config = acym_config();
109 - $globalAccess = $config->get('acl_'.$controller, 'all');
373 + $globalAccess = $config->get('acl_'.$controller, ACYM_ADMIN_GROUP);
110 374 if ($globalAccess === 'all') {
111 375 return true;
112 376 }
113 377
@@ -129,14 +393,97 @@
129 393 return true;
130 394 }
131 395 }
132 396
397 + // Having access to campaigns gives access to some email actions
398 + if ($controller === 'mails' && in_array($task, ['autoSave', '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 +
133 479 return false;
134 480 }
135 481
136 -function acym_raiseError($code, $message)
482 +function acym_raiseError(int $code, string $message): void
137 483 {
138 - echo '<link type="text/css" rel="stylesheet" href="'.ACYM_CSS.'back_global.min.css?v='.filemtime(ACYM_MEDIA.'css'.DS.'back_global.min.css').'">';
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')).'">';
139 486 echo '<div id="acym_wrapper">';
140 487 acym_display('Error '.$code.': '.$message, 'error', false);
141 488 echo '</div>';
142 489 http_response_code($code);
@@ -142,16 +489,71 @@
142 489 http_response_code($code);
143 490 exit;
144 491 }
145 492
493 +/**
494 + * Check if the license is valid, make this check weekly
495 + */
146 496 function acym_isLicenseValidWeekly(): bool
147 497 {
148 498 $config = acym_config();
149 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
150 501 if (empty($expirationDate) || (time() - 604800) > $config->get('lastlicensecheck', 0)) {
151 - acym_checkVersion();
502 + UpdatemeHelper::getLicenseInfo();
152 503 $config = acym_config(true);
153 504 $expirationDate = $config->get('expirationdate', 0);
154 505 }
155 506
156 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);
157 559 }