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 +559 -0 10.10.1 → 11.1.0 View file →
@@ -1,0 +1,559 @@
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' && 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 +
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 +}