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 +222 -37 10.10.2 → 11.1.0 View file →
@@ -1,18 +1,14 @@
1 1 <?php
2 +defined('ABSPATH') || die('Restricted Access');
2 3
3 4 use AcyMailing\Helpers\UpdatemeHelper;
4 5
5 -function acym_escape($text, bool $addSlashes = true): string
6 +/**
7 + * To secure text echoed in HTML attributes
8 + */
9 +function acym_escape(string $text): string
6 10 {
7 - if (is_array($text) || is_object($text)) {
8 - $text = json_encode($text);
9 -
10 - if ($addSlashes) {
11 - $text = str_replace('\\', '\\\\', $text);
12 - }
13 - }
14 -
15 11 if (empty($text) && !is_numeric($text)) {
16 12 return '';
17 13 }
18 14
@@ -19,13 +15,95 @@
19 15 if (!preg_match('#[&<>"\']#', $text)) {
20 16 return $text;
21 17 }
22 18
23 - return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
19 + return htmlspecialchars($text, ENT_QUOTES, 'UTF-8', false);
24 20 }
25 21
26 -function acym_escapeUrl(string $url): string
22 +/**
23 + * To secure text echoed in HTML content
24 + */
25 +function acym_escapeHtml(string $text): string
27 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 +{
28 106 if (empty($url)) {
29 107 return '';
30 108 }
31 109
@@ -53,19 +131,20 @@
53 131 $url = str_replace("'", '&#039;', $url);
54 132
55 133 if (strpos($url, '[') !== false || strpos($url, ']') !== false) {
56 134 $to_unset = [];
135 + $urlToParse = $url;
57 136
58 137 if (strpos($url, '//') === 0) {
59 138 $to_unset[] = 'scheme';
60 - $url = 'placeholder:'.$url;
139 + $urlToParse = 'placeholder:'.$url;
61 140 } elseif (strpos($url, '/') === 0) {
62 141 $to_unset[] = 'scheme';
63 142 $to_unset[] = 'host';
64 - $url = 'placeholder://placeholder'.$url;
143 + $urlToParse = 'placeholder://placeholder'.$url;
65 144 }
66 145
67 - $parsed = parse_url($url);
146 + $parsed = acym_parseUrl($urlToParse);
68 147
69 148 if (!empty($parsed)) {
70 149 foreach ($to_unset as $key) {
71 150 unset($parsed[$key]);
@@ -112,10 +191,17 @@
112 191 {
113 192 $array = @array_map('intval', $array);
114 193 }
115 194
116 -function acym_getIP(): string
195 +function acym_getIP(bool $full = false): string
117 196 {
197 + $config = acym_config();
198 + $ipCollection = $config->get('ip_collection', 'yes');
199 +
200 + if (!$full && $ipCollection === 'no') {
201 + return '';
202 + }
203 +
118 204 $map = [
119 205 'HTTP_X_FORWARDED_IP',
120 206 'X_FORWARDED_FOR',
121 207 'HTTP_X_FORWARDED_FOR',
@@ -127,22 +213,62 @@
127 213 ];
128 214
129 215 $ipAddress = '';
130 216 foreach ($map as $oneAttribute) {
131 - 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 + }
132 221
133 - $ipAddress = $_SERVER[$oneAttribute];
222 + $ipAddress = $serverValue;
134 223 break;
135 224 }
136 225
226 + // Load balancers and CDNs may put multiple IPs comma separated
137 227 if (strstr($ipAddress, ',') !== false) {
138 228 $addresses = explode(',', $ipAddress);
139 229 $ipAddress = trim(end($addresses));
140 230 }
141 231
142 - 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);
143 240 }
144 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 +
145 271 function acym_generateKey(int $length): string
146 272 {
147 273 $charactersPool = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
148 274 $randomString = '';
@@ -147,9 +273,9 @@
147 273 $charactersPool = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
148 274 $randomString = '';
149 275 $max = strlen($charactersPool) - 1;
150 276 for ($i = 0; $i < $length; $i++) {
151 - $randomString .= $charactersPool[mt_rand(0, $max)];
277 + $randomString .= $charactersPool[acym_rand(0, $max)];
152 278 }
153 279
154 280 return $randomString;
155 281 }
@@ -159,16 +285,23 @@
159 285 if (empty($_SERVER)) {
160 286 return false;
161 287 }
162 288
163 - if (!empty($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'HEAD') {
289 + // Real calls are made using GET
290 + $requestMethod = acym_getVar('string', 'REQUEST_METHOD', '', 'SERVER');
291 + if (!empty($requestMethod) && $requestMethod === 'HEAD') {
164 292 return true;
165 293 }
166 294
167 - if (!empty($_SERVER['HTTP_USER_AGENT']) && strpos(strtolower($_SERVER['HTTP_USER_AGENT']), 'spambayes') !== false) {
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) {
168 298 return true;
169 299 }
170 - if (!empty($_SERVER['REMOTE_ADDR']) && version_compare($_SERVER['REMOTE_ADDR'], '64.235.144.0', '>=') && version_compare($_SERVER['REMOTE_ADDR'], '64.235.159.255', '<=')) {
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', '<=')) {
171 304 return true;
172 305 }
173 306
174 307 return false;
@@ -175,15 +308,18 @@
175 308 }
176 309
177 310 function acym_displayErrors(): void
178 311 {
312 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting,PluginCheck.CodeAnalysis.PHPErrorReporting.DirectErrorReportingCall -- intended error printing on export and debug mode.
179 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.
180 315 @ini_set('display_errors', 1);
181 316 }
182 317
183 318 function acym_checkRobots(): void
184 319 {
185 - 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)) {
186 322 die('Not allowed for robots. Please contact us if you are not a robot');
187 323 }
188 324 }
189 325
@@ -194,10 +330,44 @@
194 330 acym_header('Pragma: no-cache');
195 331 acym_header('Expires: Wed, 17 Sep 1975 21:32:10 GMT');
196 332 }
197 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 +
198 359 function acym_isAllowed(string $controller, string $task = ''): bool
199 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 +
200 370 $controller = str_replace('front', '', $controller);
201 371
202 372 $config = acym_config();
203 373 $globalAccess = $config->get('acl_'.$controller, ACYM_ADMIN_GROUP);
@@ -223,28 +393,34 @@
223 393 return true;
224 394 }
225 395 }
226 396
397 + // Having access to campaigns gives access to some email actions
227 398 if ($controller === 'mails' && in_array($task, ['autoSave', 'getTemplateAjax']) && acym_isAllowed('campaigns')) {
228 399 return true;
229 400 }
230 401
402 + // The language management is made in the configuration
231 403 if ($controller === 'language' && acym_isAllowed('configuration')) {
232 404 return true;
233 405 }
234 406
407 + // When editing a campaign or a template, the user might need attachments
235 408 if ($controller === 'file' && (acym_isAllowed('campaigns') || acym_isAllowed('mails'))) {
236 409 return true;
237 410 }
238 411
412 + // When editing a campaign, one can need to get a segment's number of affected users from the segment step
239 413 if (in_array($task, ['countResultsTotal', 'countGlobalBySegmentId', 'countResults']) && acym_isAllowed('campaigns')) {
240 414 return true;
241 415 }
242 416
417 + // When editing an email, lists might be loaded with ajax
243 418 if ($controller === 'lists' && $task === 'setAjaxListing' && (acym_isAllowed('campaigns') || acym_isAllowed('mails'))) {
244 419 return true;
245 420 }
246 421
422 + // The zones are used in the editor
247 423 if (
248 424 $controller === 'zones'
249 425 && (
250 426 acym_isAllowed('campaigns')
@@ -253,14 +429,15 @@
253 429 ) {
254 430 return true;
255 431 }
256 432
433 + // Togglable icons are located in most features
257 434 if (
258 435 $controller === 'toggle'
259 436 && (
260 437 acym_isAllowed('campaigns')
261 438 || acym_isAllowed('mails')
262 - || acym_isAllowed('automations')
439 + || acym_isAllowed('automation')
263 440 || acym_isAllowed('segments')
264 441 || acym_isAllowed('scenarios')
265 442 || acym_isAllowed('forms')
266 443 || acym_isAllowed('users')
@@ -271,14 +448,15 @@
271 448 ) {
272 449 return true;
273 450 }
274 451
452 + // The add-ons are used in the editor and the filters
275 453 if (
276 454 $controller === 'dynamics'
277 455 && (
278 456 acym_isAllowed('campaigns')
279 457 || acym_isAllowed('mails')
280 - || acym_isAllowed('automations')
458 + || acym_isAllowed('automation')
281 459 || acym_isAllowed('segments')
282 460 || acym_isAllowed('scenarios')
283 461 )
284 462 ) {
@@ -284,8 +462,9 @@
284 462 ) {
285 463 return true;
286 464 }
287 465
466 + // The entity select is used in multiple features
288 467 if (
289 468 $task === 'loadEntityFront'
290 469 && (
291 470 acym_isAllowed('campaigns')
@@ -296,25 +475,15 @@
296 475 ) {
297 476 return true;
298 477 }
299 478
300 - if (
301 - $task === 'getOption'
302 - && (
303 - acym_isAllowed('campaigns')
304 - || acym_isAllowed('mails')
305 - || acym_isAllowed('plugins')
306 - )
307 - ) {
308 - return true;
309 - }
310 -
311 479 return false;
312 480 }
313 481
314 482 function acym_raiseError(int $code, string $message): void
315 483 {
316 - 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')).'">';
317 486 echo '<div id="acym_wrapper">';
318 487 acym_display('Error '.$code.': '.$message, 'error', false);
319 488 echo '</div>';
320 489 http_response_code($code);
@@ -320,12 +489,16 @@
320 489 http_response_code($code);
321 490 exit;
322 491 }
323 492
493 +/**
494 + * Check if the license is valid, make this check weekly
495 + */
324 496 function acym_isLicenseValidWeekly(): bool
325 497 {
326 498 $config = acym_config();
327 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
328 501 if (empty($expirationDate) || (time() - 604800) > $config->get('lastlicensecheck', 0)) {
329 502 UpdatemeHelper::getLicenseInfo();
330 503 $config = acym_config(true);
331 504 $expirationDate = $config->get('expirationdate', 0);
@@ -333,8 +506,13 @@
333 506
334 507 return $expirationDate >= time();
335 508 }
336 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 + */
337 515 function acym_generateAutologinToken(int $subId, string $subKey): string
338 516 {
339 517 $timestamp = time();
340 518 $payload = $subId.'|'.$timestamp;
@@ -343,8 +521,15 @@
343 521
344 522 return dechex($timestamp).'.'.$signature;
345 523 }
346 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 + */
347 532 function acym_verifyAutologinToken(int $subId, string $token, string $storedKey): bool
348 533 {
349 534 $parts = explode('.', $token, 2);
350 535 if (count($parts) !== 2) {