PluginProbe
CryptX / 4.1.1
CryptX v4.1.1
4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 All 92 releases
cryptx / classes / CryptX.php

CryptX.php in CryptX 4.1.1, at classes/CryptX.php

2,355 lines 90.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX;
4
5 final class CryptX
6 {
7 const NOT_FOUND = false;
8
9 /**
10 * Kept for compatibility: it is public, so a theme may reference it.
11 *
12 * @deprecated 4.1.1 The guard that used it compared against an already
13 * sanitised address and could therefore never match --
14 * sanitize_email('?subject=x') returns an empty string. Query
15 * handling now lives in sanitizeMailtoQuery().
16 */
17 const SUBJECT_IDENTIFIER = "?subject=";
18
19 /** Upper bound for a single mailto header value, in characters. */
20 private const MAX_MAILTO_VALUE_LENGTH = 512;
21
22 /**
23 * Upper bound for the whole "mailto:..." target, in characters.
24 *
25 * Matches CONFIG.MAX_URL_LENGTH in js/cryptx.js and the limit in
26 * SecureEncryption::validateUrl(). Above it the click handler refuses to
27 * navigate, and the link silently does nothing.
28 */
29 private const MAX_MAILTO_URL_LENGTH = 2048;
30
31 /**
32 * Shortcode attributes that describe the mail, not the plugin's settings.
33 *
34 * The names are those of the mailto headers in RFC 6068, so
35 * [cryptx subject="..."] and href="mailto:...?subject=..." mean the same
36 * thing and are cleaned by the same code.
37 */
38 private const MAILTO_ATTRIBUTES = ['subject', 'body', 'cc', 'bcc'];
39
40 /**
41 * Filters after which WordPress expands shortcodes.
42 *
43 * Measured, not assumed: has_filter($name, 'do_shortcode') is 11 for these
44 * four and false for the other five CryptX hangs on. Only here may an
45 * unexpanded [cryptx] be set aside, because only here does something come
46 * along afterwards to deal with it.
47 */
48 /**
49 * The feed counterpart of each content filter.
50 *
51 * WordPress builds a feed from its own filters, not from the ones that
52 * render a page: <description> comes from 'the_excerpt_rss',
53 * <content:encoded> from 'the_content_feed'.
54 */
55 private const FEED_FILTERS = [
56 'the_content' => 'the_content_feed',
57 'the_excerpt' => 'the_excerpt_rss',
58 'comment_text' => 'comment_text_rss',
59 ];
60
61 private const SHORTCODE_EXPANDED_AFTER = [
62 'the_content',
63 'render_block',
64 'widget_text_content',
65 'widget_block_content',
66 ];
67 const ASCII_VALUES_BLACKLIST = ['32', '34', '39', '60', '62', '63', '92', '94', '96', '127'];
68 /** Upper bound for the text rendered into a PNG, see cryptXtinyUrl(). */
69 private const MAX_IMAGE_TEXT_LENGTH = 254;
70 private static ?self $instance = null;
71 private static array $cryptXOptions = [];
72 private static int $imageCounter = 0;
73
74 /** CSS class of the links the click handler in cryptx.js listens for. */
75 private const LINK_CLASS = 'cryptx-link';
76
77 /** Marks a save request as coming from the post meta box. */
78 private const METABOX_NONCE_ACTION = 'cryptx_metabox';
79 private const METABOX_NONCE_FIELD = 'cryptx_metabox_nonce';
80
81 /**
82 * Set as soon as something on this page actually needs them. Version 3.2.7
83 * once had this property ("the javascript will be loaded only if really
84 * needed!"); the 4.0 rewrite lost it and loaded both files on every page,
85 * including pages without a single address.
86 */
87 private static bool $scriptNeeded = false;
88 private static bool $styleNeeded = false;
89
90 /**
91 * Parsed once per request instead of on every call. Both lists are read
92 * from a comma separated option for every filter pass and, in the case of
93 * the whitelist, for every single address found.
94 */
95 private static ?array $excludedIdCache = null;
96 private static ?array $whiteListCache = null;
97
98 private const FONT_EXTENSION = 'ttf';
99 private const PAYPAL_DONATION_URL = 'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=4026696';
100 private Admin\SettingsPage $settingsPage;
101 private Config $config;
102
103 private function __construct()
104 {
105 $this->settingsPage = new Admin\SettingsPage();
106 $this->config = new Config(get_option('cryptX', []));
107 self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
108 }
109
110 /**
111 * Retrieves the singleton instance of the class.
112 *
113 * @return self The singleton instance of the class.
114 */
115 public static function get_instance(): self
116 {
117 $needs_initialization = !(self::$instance instanceof self);
118
119 if ($needs_initialization) {
120 self::$instance = new self();
121 }
122
123 return self::$instance;
124 }
125
126
127 /**
128 * @return Config
129 */
130 public function getConfig(): Config
131 {
132 return $this->config;
133 }
134
135 /**
136 * Initializes the CryptX plugin by setting up version checks, applying filters, registering core hooks, initializing meta boxes (if enabled), and adding additional hooks.
137 *
138 * @return void
139 */
140 public function startCryptX(): void
141 {
142 // The settings screen registers its own menu entry and REST routes.
143 // Doing it here rather than in the constructor keeps the hooks out of
144 // object construction, where they are easy to trigger by accident.
145 $this->settingsPage->register();
146
147 $this->checkAndUpdateVersion();
148 $this->addUniversalWidgetFilters(); // Add this line
149 $this->initializePluginFilters();
150 $this->registerCoreHooks();
151 $this->initializeMetaBoxIfEnabled();
152 $this->registerAdditionalHooks();
153 }
154
155 /**
156 * Rebuilds the cached options and configuration for the site now in scope.
157 *
158 * Hooked to 'switch_blog', which WordPress fires for both switch_to_blog()
159 * and restore_current_blog(), so the object follows the site rather than
160 * the request.
161 *
162 * @return void
163 */
164 public function refreshForCurrentSite(): void
165 {
166 // wp_insert_site() switches into the new site BEFORE its tables exist,
167 // and reading options there produces a database error in the log while
168 // telling us nothing. wp_is_site_initialized() answers the question
169 // without that -- it suppresses errors around its own query.
170 //
171 // The flag is not needed for the call below as the core stands today:
172 // wp_is_site_initialized() only switches when the id differs from the
173 // current one (wp-includes/ms-site.php), and we pass our own. It is
174 // here for the two ways that changes -- a plugin filtering
175 // 'pre_wp_is_site_initialized', or a later core version that switches
176 // unconditionally -- either of which would call this method back into
177 // itself.
178 static $busy = false;
179
180 if ($busy) {
181 return;
182 }
183
184 $busy = true;
185
186 try {
187 if (is_multisite() && !wp_is_site_initialized(get_current_blog_id())) {
188 return;
189 }
190
191 $this->config = new Config(get_option('cryptX', []));
192 self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
193 self::resetOptionCaches();
194 } finally {
195 $busy = false;
196 }
197 }
198
199 /**
200 * Checks the current version of the application against the stored version and updates settings if the application version is newer.
201 *
202 * @return void
203 */
204 private function checkAndUpdateVersion(): void
205 {
206 $currentVersion = self::$cryptXOptions['version'] ?? null;
207 if ($currentVersion && version_compare(CRYPTX_VERSION, $currentVersion) > 0) {
208 $this->updateCryptXSettings();
209 }
210 }
211
212 /**
213 * Initializes and registers plugin filters based on the configuration settings.
214 *
215 * This method retrieves the active filters from the configuration and applies
216 * each filter by either adding widget-specific filters or other plugin-related filters.
217 * If the theme is a block theme, it transforms certain filters to an appropriate block-based equivalent.
218 * It also checks if autolink functionality is enabled and adds the respective filters when applicable.
219 *
220 * @return void
221 */
222 public function initializePluginFilters(): void
223 {
224 if (empty($this->config)) {
225 return;
226 }
227
228 $activeFilters = $this->config->getActiveFilters();
229
230 if (function_exists('wp_is_block_theme') && wp_is_block_theme()) {
231 $activeFilters = array_map(
232 fn($value) => $value === 'the_content' ? 'render_block' : $value,
233 $activeFilters
234 );
235 }
236
237 foreach ($activeFilters as $filter) {
238 if ($filter === 'widget_text') {
239 $this->addWidgetFilters();
240 } else {
241 // Add autolink filters for non-widget filters if autolink is enabled
242 if ($this->config->isAutolinkEnabled()) {
243 $this->addAutoLinkFilters($filter, 11);
244 }
245 $this->addOtherFilters($filter);
246 }
247 }
248
249 $this->addFeedFilters();
250 }
251
252 /**
253 * Registers the feed counterparts of the active content filters.
254 *
255 * Without these, "Leave RSS feeds unprotected = off" only half worked. A
256 * feed's <description> comes from the_excerpt_rss(), and nothing CryptX
257 * hangs on runs on the way there: on a block theme the plugin sits on
258 * 'render_block', which fires only from do_blocks() -- and
259 * wp_trim_excerpt() detaches do_blocks before building the excerpt. The
260 * address went out in the feed while the setting said it would not.
261 *
262 * The guard inside the three stages stays as it is; it is what makes the
263 * option work in the other direction, for filters that run in both feed
264 * and page context.
265 *
266 * @return void
267 */
268 private function addFeedFilters(): void
269 {
270 // The default: feeds are deliberately left alone, because a feed
271 // reader runs no JavaScript and a protected link would be dead in it.
272 //
273 // Read from the store rather than from the static list. The two agree
274 // when this runs during startup, but the static one is swapped for the
275 // duration of a shortcode and of the settings preview -- and a method
276 // that decides which hooks exist has no business depending on which of
277 // those happened to be in flight.
278 $options = $this->loadCryptXOptionsWithDefaults();
279
280 if (!empty($options['disable_rss'])) {
281 return;
282 }
283
284 foreach ($this->config->getActiveFilters() as $filter) {
285 if (!isset(self::FEED_FILTERS[$filter])) {
286 continue;
287 }
288
289 $feedFilter = self::FEED_FILTERS[$filter];
290
291 if ($this->config->isAutolinkEnabled()) {
292 $this->addAutoLinkFilters($feedFilter, 11);
293 }
294
295 $this->addOtherFilters($feedFilter);
296 }
297 }
298
299 /**
300 * Registers core hooks for the plugin's functionality.
301 *
302 * @return void
303 */
304 private function registerCoreHooks(): void
305 {
306 add_action('activate_' . CRYPTX_BASENAME, [$this, 'installCryptX']);
307
308 // Multisite: this object is built once per request, from whichever site
309 // was current at the time. switch_to_blog() changes what get_option()
310 // returns but not what this instance already holds -- and
311 // getCryptXOptionsDefaults() hands out $this->config, which is the
312 // FIRST site's stored values, not a set of defaults. Everything read
313 // after a switch therefore came from the wrong site, up to and
314 // including its encryption secret.
315 add_action('switch_blog', [$this, 'refreshForCurrentSite']);
316 add_action('wp_enqueue_scripts', [$this, 'loadJavascriptFiles']);
317 // Priority 1 so this still runs before wp_print_footer_scripts.
318 add_action('wp_footer', [$this, 'enqueueAssetsIfNeeded'], 1);
319 }
320
321 /**
322 * Initializes the meta box functionality if enabled in the configuration.
323 *
324 * This method checks whether the meta box feature is enabled in the cryptX options.
325 * If enabled, it adds the necessary actions for administering the meta box and managing the posts' exclusion list.
326 *
327 * @return void
328 */
329 private function initializeMetaBoxIfEnabled(): void
330 {
331 if (!isset(self::$cryptXOptions['metaBox']) || !self::$cryptXOptions['metaBox']) {
332 return;
333 }
334
335 add_action('admin_menu', [$this, 'metaBox']);
336
337 // Only 'wp_insert_post'. There was a second registration on
338 // 'wp_update_post' -- a hook WordPress does not have: the core defines
339 // a *function* of that name, and the only do_action() calls are
340 // 'wp_insert_post' in wp-includes/post.php. Since wp_update_post()
341 // routes through wp_insert_post(), an update was covered all along;
342 // the line did nothing and suggested it did.
343 add_action('wp_insert_post', [$this, 'addPostIdToExcludedList']);
344 }
345
346 /**
347 * Registers additional WordPress hooks and shortcodes.
348 *
349 * @return void
350 */
351 private function registerAdditionalHooks(): void
352 {
353 add_filter('plugin_row_meta', [$this, 'add_plugin_action_links'], 10, 2);
354 // add_action, nicht add_filter: 'init' ist eine Action. Intern
355 // dasselbe, aber der Aufruf soll sagen, was er tut.
356 add_action('init', [$this, 'cryptXtinyUrl']);
357 add_shortcode('cryptx', [$this, 'cryptXShortcode']);
358 }
359
360 /**
361 * Retrieves the default options for CryptX configuration.
362 *
363 * @return array The default CryptX options, including version and font settings.
364 */
365 public function getCryptXOptionsDefaults(): array
366 {
367 return array_merge(
368 $this->config->getAll(),
369 [
370 'version' => CRYPTX_VERSION,
371 'c2i_font' => $this->getDefaultFont()
372 ]
373 );
374 }
375
376 /**
377 * Retrieves the default font from the available fonts directory.
378 *
379 * @return string|null Returns the name of the default font found, or null if no fonts are available.
380 */
381 private function getDefaultFont(): ?string
382 {
383 $availableFonts = $this->getFilesInDirectory(
384 CRYPTX_DIR_PATH . 'fonts',
385 [self::FONT_EXTENSION]
386 );
387
388 return $availableFonts[0] ?? null;
389 }
390
391 /**
392 * Loads the cryptX options with default values.
393 *
394 * @return array The cryptX options array with default values.
395 */
396 public function loadCryptXOptionsWithDefaults(): array
397 {
398 $defaultValues = $this->getCryptXOptionsDefaults();
399 $currentOptions = get_option('cryptX');
400
401 return wp_parse_args($currentOptions, $defaultValues);
402 }
403
404 /**
405 * Saves the cryptX options by updating the 'cryptX' option with the saved options merged with the default options.
406 *
407 * @param array $saveOptions The options to be saved.
408 *
409 * @return void
410 */
411 public function saveCryptXOptions(array $saveOptions): void
412 {
413 update_option('cryptX', wp_parse_args($saveOptions, $this->loadCryptXOptionsWithDefaults()));
414 }
415
416 /**
417 * Decodes attributes from their encoded state and returns the decoded array.
418 *
419 * @param array $attributes The array of attributes, potentially encoded.
420 * @return array The array of decoded attributes with the 'encoded' key removed if present.
421 */
422 /**
423 * Runs a processing step with unexpanded [cryptx] shortcodes masked out.
424 *
425 * On a block theme the three filters hang on 'render_block', which fires
426 * from do_blocks() at 'the_content' priority 9 -- while do_shortcode()
427 * runs at priority 11. CryptX therefore sees the shortcode as raw text,
428 * long before it becomes anything.
429 *
430 * Left alone, that ends badly in two ways. The address inside
431 * "[cryptx]info@example.com[/cryptx]" is not linked, because it sits
432 * behind a "]", yet the display stage replaces it anyway -- the same
433 * silent failure the autolink patterns were widened for. And once the
434 * replacement inserts "[at]" and "[dot]", the new square brackets tear the
435 * shortcode apart, so the parser later prints the wreckage into the page.
436 *
437 * Masking hands the shortcode to do_shortcode() untouched. It does its own
438 * encrypting, with its own attributes, exactly as on a classic theme.
439 *
440 * @param string $content The content.
441 * @param callable $process Receives the masked content, returns the result.
442 *
443 * @return string The processed content, with the shortcodes back in place.
444 */
445 private function withShortcodesProtected(string $content, callable $process): string
446 {
447 // Masking is only safe where do_shortcode() runs after us. In
448 // 'comment_text', 'the_excerpt', 'the_meta_key', 'widget_text' and
449 // 'widget_custom_html_content' it does not -- WordPress never expands
450 // shortcodes there. Masking unconditionally therefore handed the
451 // address to nobody at all: it was skipped here and never picked up
452 // later, and a "[cryptx]" written into a comment shipped the address in
453 // the clear. 4.1.0 at least obfuscated it.
454 //
455 // Where the shortcode is not going to be expanded, the literal
456 // "[cryptx]" stays visible in the output and the address inside it is
457 // obfuscated like any other. Ugly, and the same as before -- but the
458 // address is covered.
459 if (stripos($content, '[cryptx') === false
460 || !in_array(current_filter(), self::SHORTCODE_EXPANDED_AFTER, true)) {
461 return $process($content);
462 }
463
464 $store = [];
465
466 // WordPress' own idea of what a shortcode looks like, rather than a
467 // hand-rolled one: it knows the self-closing form, the enclosing form
468 // and -- the reason this matters below -- the escaped form.
469 $pattern = '/' . get_shortcode_regex(['cryptx']) . '/s';
470
471 $masked = preg_replace_callback(
472 $pattern,
473 static function (array $match) use (&$store): string {
474 // "[[cryptx]...[/cryptx]]" is how a page shows a shortcode
475 // instead of running it -- an instructions page explaining
476 // CryptX, typically. do_shortcode() deliberately leaves it as
477 // text, so masking it would carry the address straight through
478 // to the visitor in the clear. Groups 1 and 6 are the extra
479 // brackets; when both are there, this is not ours to protect
480 // and has to go through the normal obfuscation.
481 if (($match[1] ?? '') === '[' && ($match[6] ?? '') === ']') {
482 return $match[0];
483 }
484
485 $store[] = $match[0];
486
487 return sprintf('<!--cryptx:%d-->', count($store) - 1);
488 },
489 $content
490 );
491
492 // A PCRE failure must not cost the content; process it unmasked.
493 if ($masked === null) {
494 return $process($content);
495 }
496
497 $result = $process($masked);
498
499 // Under 'render_block' the shortcode is expanded here rather than left
500 // for later. The other three entries in SHORTCODE_EXPANDED_AFTER carry
501 // do_shortcode() themselves; 'render_block' does not -- it relies on
502 // the_content running afterwards, and there are core paths where that
503 // never happens. A block pattern pulled in through core/pattern is
504 // rendered by do_blocks() alone (wp-includes/blocks/pattern.php), so a
505 // masked shortcode would have been handed to nobody and the address
506 // would have reached the page in the clear.
507 //
508 // Expanding twice is harmless: whatever runs later finds an anchor, no
509 // shortcode.
510 if (current_filter() === 'render_block') {
511 $store = array_map('do_shortcode', $store);
512 }
513
514 $tokens = array_map(
515 static fn(int $index): string => sprintf('<!--cryptx:%d-->', $index),
516 array_keys($store)
517 );
518
519 return str_replace($tokens, $store, $result);
520 }
521
522 /**
523 * Builds a mailto query from the shortcode's mail attributes.
524 *
525 * @param array<string, mixed> $attributes Lower-cased shortcode attributes.
526 *
527 * @return string The cleaned query, or an empty string.
528 */
529 private function buildMailtoQueryFromAttributes(array $attributes): string
530 {
531 $pairs = [];
532
533 foreach (self::MAILTO_ATTRIBUTES as $name) {
534 if (!isset($attributes[$name]) || is_array($attributes[$name])) {
535 continue;
536 }
537
538 $value = (string) $attributes[$name];
539
540 if (trim($value) === '') {
541 continue;
542 }
543
544 $pairs[] = $name . '=' . rawurlencode($value);
545 }
546
547 // Straight through the same gate an address in the page goes through,
548 // so the shortcode cannot express anything a link could not.
549 return $this->sanitizeMailtoQuery(implode('&', $pairs));
550 }
551
552 /**
553 * Appends a query to every mailto link that does not already carry one.
554 *
555 * A link written by hand with its own "?subject=" keeps it: the more
556 * specific instruction wins over the shortcode's blanket one.
557 *
558 * @param string $content The content, after autolinking.
559 * @param string $query The query to append, without the "?".
560 *
561 * @return string The content with the query in place.
562 */
563 private function addQueryToMailtoLinks(string $content, string $query): string
564 {
565 $result = preg_replace_callback(
566 '/(href\s*=\s*(["\']))mailto:([^"\']+)(\2)/i',
567 static function (array $match) use ($query): string {
568 if (strpos($match[3], '?') !== false) {
569 return $match[0];
570 }
571
572 return $match[1] . 'mailto:' . $match[3] . '?' . $query . $match[4];
573 },
574 $content
575 );
576
577 // Same reasoning as every other preg_* call site here: a PCRE failure
578 // yields null, and handing that on would empty the content.
579 return $result ?? $content;
580 }
581
582 private function decodeAttributes(array $attributes): array
583 {
584 if (($attributes['encoded'] ?? '') !== 'true') {
585 return $attributes;
586 }
587
588 $decodedAttributes = array_map(
589 fn($value) => $this->decodeString($value),
590 $attributes
591 );
592 unset($decodedAttributes['encoded']);
593
594 return $decodedAttributes;
595 }
596
597 /**
598 * Processes the provided shortcode attributes and content, encrypts content, and optionally creates links for email addresses.
599 *
600 * @param array $atts Attributes passed to the shortcode. Defaults to an empty array.
601 * @param string $content The content enclosed within the shortcode. Defaults to an empty string.
602 * @param string $tag The name of the shortcode tag. Defaults to an empty string.
603 * @return string The processed and encrypted content, optionally including links for email addresses.
604 */
605 public function cryptXShortcode(array $atts = [], string $content = '', string $tag = ''): string
606 {
607 // Decode attributes if needed
608 $attributes = $this->decodeAttributes($atts);
609 $attributes = array_change_key_case($attributes, CASE_LOWER);
610
611 // The mail headers are pulled out first. They are not options -- there
612 // is no "subject" in the option store and never was -- so leaving them
613 // in would hand them to shortcode_atts(), which drops anything it does
614 // not recognise. That is precisely what happened to "subject" for
615 // years: accepted by the parser, silently discarded, and documented as
616 // working.
617 $mailQuery = $this->buildMailtoQueryFromAttributes($attributes);
618 $attributes = array_diff_key($attributes, array_flip(self::MAILTO_ATTRIBUTES));
619
620 // Update options if attributes provided
621 if (!empty($attributes)) {
622 self::$cryptXOptions = shortcode_atts(
623 $this->loadCryptXOptionsWithDefaults(),
624 $attributes,
625 $tag
626 );
627 self::resetOptionCaches();
628 }
629
630 try {
631 // Process content (inline the encryptAndLinkContent logic)
632 if (self::$cryptXOptions['autolink'] ?? false) {
633 $content = $this->addLinkToEmailAddresses($content, true);
634 }
635
636 // After autolinking, so a bare address in the shortcode body has a
637 // link to carry the headers, and before encrypting, so they end up
638 // inside the payload rather than in the page.
639 if ($mailQuery !== '') {
640 $content = $this->addQueryToMailtoLinks($content, $mailQuery);
641 }
642
643 $content = $this->findEmailAddressesInContent($content, true);
644 $processedContent = $this->replaceEmailInContent($content, true);
645 } finally {
646 // Restored in a finally block: self::$cryptXOptions is static, so
647 // an exception escaping from here would leave the shortcode's
648 // values in place for the rest of the request.
649 self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
650 self::resetOptionCaches();
651 }
652
653 return $processedContent;
654 }
655
656 /**
657 * Retrieves the ID of the current post.
658 *
659 * @return int The current post ID if available, or -1 if no post object is present.
660 */
661 private function getCurrentPostId(): int
662 {
663 global $post;
664 return (is_object($post)) ? $post->ID : -1;
665 }
666
667
668 /**
669 * Generates and returns a tiny URL image.
670 *
671 * @return void
672 */
673 public function cryptXtinyUrl(): void
674 {
675 // sanitize_text_field(), not esc_url(): the latter is an output
676 // escaper and turned "&" into "&#038;" on the way in.
677 $url = (!empty($_SERVER['REQUEST_URI']))
678 ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
679 : '';
680 $params = explode('/', $url);
681
682 if (count($params) < 2) {
683 return;
684 }
685
686 if (!hash_equals(md5(get_bloginfo('url')), $params[count($params) - 2])) {
687 return;
688 }
689
690 // Everything below writes an image to the output stream. Any PHP notice
691 // that slips through would end up inside that stream, be served as
692 // image/png and disclose the server path to the visitor. So every
693 // prerequisite is checked first and the request is abandoned quietly
694 // if one is missing.
695 if (!function_exists('imagettfbbox')) {
696 return;
697 }
698
699 $fontFile = self::$cryptXOptions['c2i_font'] ?? $this->getDefaultFont();
700 if (!is_string($fontFile) || $fontFile === '') {
701 return;
702 }
703
704 // basename() keeps the option from reaching outside the fonts folder,
705 // even if it was tampered with in the database.
706 $font = CRYPTX_DIR_PATH . 'fonts/' . basename(str_replace(' ', '_', $fontFile));
707 // is_file(), not is_readable(): the latter is true for a directory as
708 // well, and imagettfbbox() would then emit "Could not read font" with
709 // the full server path -- exactly the disclosure this rewrite removes.
710 if (!is_file($font) || !is_readable($font)) {
711 return;
712 }
713
714 // The text comes straight from the URL. Without a bound, a long request
715 // would size the canvas up accordingly and exhaust the memory limit --
716 // a cheap denial of service. No address is anywhere near this long.
717 $msg = substr(rawurldecode($params[count($params) - 1]), 0, self::MAX_IMAGE_TEXT_LENGTH);
718 if ($msg === '') {
719 return;
720 }
721
722 $size = (int) (self::$cryptXOptions['c2i_fontSize'] ?? 10);
723 $size = max(1, min(96, $size));
724
725 $rgb = ltrim((string) (self::$cryptXOptions['c2i_fontRGB'] ?? '#000000'), '#');
726 if (!preg_match('/^[0-9a-f]{6}$/i', $rgb)) {
727 $rgb = '000000';
728 }
729 $red = hexdec(substr($rgb, 0, 2));
730 $grn = hexdec(substr($rgb, 2, 2));
731 $blu = hexdec(substr($rgb, 4, 2));
732
733 $pad = 1;
734 $bounds = imagettfbbox($size, 0, $font, 'W');
735 if ($bounds === false) {
736 return;
737 }
738 $font_height = abs($bounds[7] - $bounds[1]);
739
740 $bounds = imagettfbbox($size, 0, $font, $msg);
741 if ($bounds === false) {
742 return;
743 }
744 $width = abs($bounds[4] - $bounds[6]);
745 $height = abs($bounds[7] - $bounds[1]);
746 if ($width < 1 || $height < 1) {
747 return;
748 }
749
750 $offset_y = $font_height + abs(($height - $font_height) / 2) - 1;
751 $offset_x = 0;
752
753 $image = imagecreatetruecolor($width + ($pad * 2), $height + ($pad * 2));
754 if ($image === false) {
755 return;
756 }
757 imagesavealpha($image, true);
758 $foreground = imagecolorallocate($image, $red, $grn, $blu);
759 $background = imagecolorallocatealpha($image, 0, 0, 0, 127);
760
761 // Both return false when the palette is exhausted. Passing that on
762 // would emit a warning into the image stream -- the very thing this
763 // method is built to avoid.
764 if ($foreground === false || $background === false) {
765 imagedestroy($image);
766 return;
767 }
768
769 imagefill($image, 0, 0, $background);
770 imagettftext($image, $size, 0, (int) round($offset_x + $pad), (int) round($offset_y + $pad), $foreground, $font, $msg);
771
772 header('Content-Type: image/png');
773 header('X-Content-Type-Options: nosniff');
774 imagepng($image);
775 imagedestroy($image);
776 die;
777 }
778
779 /**
780 * Adds common filters to a given filter name.
781 *
782 * This function adds the common filter 'autolink' to the provided $filterName.
783 *
784 * @param string $filterName The name of the filter to add common filters to.
785 *
786 * @return void
787 */
788 private function addAutoLinkFilters(string $filterName, $prio = 5): void
789 {
790 add_filter($filterName, [$this, 'addLinkToEmailAddresses'], $prio);
791 }
792
793 /**
794 * Adds additional filters to a given filter name.
795 *
796 * This function adds two additional filters, 'encryptx' and 'replaceEmailInContent',
797 * to the specified filter name. The 'encryptx' filter is added with a priority of 12,
798 * and the 'replaceEmailInContent' filter is added with a priority of 13.
799 *
800 * @param string $filterName The name of the filter to add the additional filters to.
801 *
802 * @return void
803 */
804 private function addOtherFilters(string $filterName): void
805 {
806 // Check if this is a widget filter
807 $widgetFilters = $this->config->getWidgetFilters();
808 $isWidgetFilter = in_array($filterName, $widgetFilters);
809
810 if ($isWidgetFilter) {
811 // Use higher priority for widget filters (after autolink at priority 10)
812 add_filter($filterName, [$this, 'findEmailAddressesInContent'], 15);
813 add_filter($filterName, [$this, 'replaceEmailInContent'], 16);
814 } else {
815 // Standard priorities for other filters
816 add_filter($filterName, [$this, 'findEmailAddressesInContent'], 12);
817 add_filter($filterName, [$this, 'replaceEmailInContent'], 13);
818 }
819 }
820
821
822 /**
823 * Adds and applies widget filters from the configuration.
824 *
825 * @return void
826 */
827 private function addWidgetFilters(): void
828 {
829 $widgetFilters = $this->config->getWidgetFilters();
830
831 foreach ($widgetFilters as $widgetFilter) {
832 $this->addAutoLinkFilters($widgetFilter, 11);
833 $this->addOtherFilters($widgetFilter);
834 }
835 }
836
837 /**
838 * Checks if a given ID is excluded based on the 'excludedIDs' variable.
839 *
840 * @param int $ID The ID to check if excluded.
841 *
842 * @return bool Returns true if the ID is excluded, false otherwise.
843 */
844 private function isIdExcluded(int $ID): bool
845 {
846 if (self::$excludedIdCache === null) {
847 $raw = (string) (self::$cryptXOptions['excludedIDs'] ?? '');
848 self::$excludedIdCache = array_map(
849 'intval',
850 array_filter(array_map('trim', explode(',', $raw)), 'strlen')
851 );
852 }
853
854 return in_array($ID, self::$excludedIdCache, true);
855 }
856
857 /**
858 * Drops the parsed option lists.
859 *
860 * Both caches mirror values from self::$cryptXOptions. Whenever those are
861 * replaced -- by the shortcode or after saving -- the caches have to go
862 * with them, otherwise a stale exclusion list survives the change.
863 *
864 * @return void
865 */
866 private static function resetOptionCaches(): void
867 {
868 self::$excludedIdCache = null;
869 self::$whiteListCache = null;
870 }
871
872 /**
873 * Replaces email addresses in content with link texts.
874 *
875 * @param string|null $content The content to replace the email addresses in.
876 * @param bool $isShortcode Flag indicating whether the method is called from a shortcode.
877 *
878 * @return string|null The content with replaced email addresses.
879 */
880 public function replaceEmailInContent(?string $content, bool $isShortcode = false): ?string
881 {
882 global $post;
883
884 if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
885
886 // Nothing to find without an at sign. Bailing out here skips the whole
887 // regular expression machinery for the vast majority of content -- and
888 // on a block theme this filter runs once per block, not once per post.
889 if ($content === null || strpos($content, '@') === false) {
890 return $content;
891 }
892
893 // Check if current filter is a widget filter
894 $widgetFilters = $this->config->getWidgetFilters();
895 $isWidgetContext = in_array(current_filter(), $widgetFilters);
896
897 $postId = (is_object($post)) ? $post->ID : -1;
898
899 // For widgets, always process; for other content, check exclusion rules
900 if (($isWidgetContext || !$this->isIdExcluded($postId) || $isShortcode) && !empty($content)) {
901 $content = $this->withShortcodesProtected(
902 $content,
903 fn(string $masked): string => $this->replaceEmailWithLinkText($masked)
904 );
905 }
906
907 return $content;
908 }
909
910
911 /**
912 * Replace email addresses in a given content with link text.
913 *
914 * @param string $content The content to search for email addresses.
915 *
916 * @return string The content with email addresses replaced with link text.
917 */
918 private function replaceEmailWithLinkText(string $content): string
919 {
920 $emailPattern = "/([_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,}))/i";
921
922 $result = preg_replace_callback($emailPattern, [$this, 'encodeEmailToLinkText'], $content);
923
924 // On a PCRE error -- a backtrack or recursion limit on unusually large
925 // or awkward content -- preg_* returns null. Handing that back would
926 // make the whole post body disappear, so the untouched content wins.
927 return $result ?? $content;
928 }
929
930 /**
931 * Encode email address to link text.
932 *
933 * @param array $Match The matched email address.
934 *
935 * @return string The encoded link text.
936 */
937 private function encodeEmailToLinkText(array $Match): string
938 {
939 if ($this->inWhiteList($Match)) {
940 return $Match[1];
941 }
942 switch (self::$cryptXOptions['opt_linktext']) {
943 case 1:
944 $text = $this->getLinkText();
945 break;
946 case 2:
947 $text = $this->getLinkImage();
948 break;
949 case 3:
950 $img_url = wp_get_attachment_url(self::$cryptXOptions['alt_uploadedimage']);
951 // false when the attachment was deleted; would have produced
952 // <img src=""> and a TypeError on the string parameter.
953 $text = $img_url === false ? $this->getDefaultLinkText($Match) : $this->getUploadedImage($img_url);
954 self::$imageCounter++;
955 break;
956 case 4:
957 $text = antispambot($Match[1]);
958 break;
959 case 5:
960 $text = $this->getImageFromText($Match);
961 self::$imageCounter++;
962 break;
963 default:
964 $text = $this->getDefaultLinkText($Match);
965 }
966
967 return $text;
968 }
969
970 /**
971 * Check if the given match is in the whitelist.
972 *
973 * @param array $Match The match to check against the whitelist.
974 *
975 * @return bool True if the match is in the whitelist, false otherwise.
976 */
977 private function inWhiteList(array $Match): bool
978 {
979 if (self::$whiteListCache === null) {
980 $raw = (string) (self::$cryptXOptions['whiteList'] ?? '');
981 self::$whiteListCache = array_filter(array_map('trim', explode(',', $raw)), 'strlen');
982 }
983
984 if (self::$whiteListCache === []) {
985 return false;
986 }
987
988 $tmp = explode(".", $Match[0]);
989
990 return in_array(end($tmp), self::$whiteListCache, true);
991 }
992
993 /**
994 * Get the link text from cryptXOptions
995 *
996 * @return string The link text
997 */
998 private function getLinkText(): string
999 {
1000 // Escaped here rather than at the source: a shortcode attribute of the
1001 // same name reaches self::$cryptXOptions without passing through the
1002 // settings validation at all.
1003 //
1004 // esc_html and not wp_kses_post, although the settings screen stores
1005 // the value with wp_kses_post: the link text sits inside an anchor that
1006 // CryptX builds itself, and markup there could close that anchor early.
1007 // The two stages therefore mean different things on purpose -- storage
1008 // keeps what a post may contain, output shows it as text. See
1009 // SettingsSchema::sanitizeValue().
1010 return esc_html((string) self::$cryptXOptions['alt_linktext']);
1011 }
1012
1013 /**
1014 * Generate an HTML image tag with the link image URL as the source
1015 *
1016 * @return string The HTML image tag
1017 */
1018 private function getLinkImage(): string
1019 {
1020 self::$styleNeeded = true;
1021 $title = (string) self::$cryptXOptions['alt_linkimage_title'];
1022
1023 return sprintf(
1024 '<img src="%s" class="cryptxImage" alt="%s" title="%s" />',
1025 esc_url(self::$cryptXOptions['alt_linkimage']),
1026 esc_attr($title),
1027 esc_attr(antispambot($title))
1028 );
1029 }
1030
1031 /**
1032 * Get the HTML tag for an uploaded image.
1033 *
1034 * @param string $img_url The URL of the image.
1035 *
1036 * @return string The HTML tag for the image.
1037 */
1038 private function getUploadedImage(string $img_url): string
1039 {
1040 self::$styleNeeded = true;
1041 $title = (string) self::$cryptXOptions['http_linkimage_title'];
1042
1043 // The alt attribute used to be missing its closing quote, which ran the
1044 // title straight into it and produced broken markup.
1045 return sprintf(
1046 '<img src="%s" class="cryptxImage cryptxImage_%d" alt="%s" title="%s" />',
1047 esc_url($img_url),
1048 self::$imageCounter,
1049 esc_attr($title),
1050 esc_attr(antispambot($title))
1051 );
1052 }
1053
1054 /**
1055 * Converts a matched image URL into an HTML image element with cryptX classes and attributes.
1056 *
1057 * @param array $Match The matched image URL and other related data.
1058 *
1059 * @return string Returns the HTML image element.
1060 */
1061 private function getImageFromText(array $Match): string
1062 {
1063 self::$styleNeeded = true;
1064 $scrambled = antispambot($Match[1]);
1065
1066 return sprintf(
1067 '<img src="%s" class="cryptxImage cryptxImage_%d" alt="%s" title="%s" />',
1068 esc_url(get_bloginfo('url') . '/' . md5(get_bloginfo('url')) . '/' . $scrambled),
1069 self::$imageCounter,
1070 esc_attr($scrambled),
1071 esc_attr($scrambled)
1072 );
1073 }
1074
1075 /**
1076 * Replaces specific characters with values from cryptX options in a given string.
1077 *
1078 * @param array $Match The array containing matches from a regular expression search.
1079 * Array format: `[0 => string, 1 => string, ...]`.
1080 * The first element is ignored, and the second element is used as input string.
1081 *
1082 * @return string The string with replaced characters or the original array if no matches were found.
1083 * If the input string is an array, the function returns an array with replaced characters
1084 * for each element.
1085 */
1086 private function getDefaultLinkText(array $Match): string
1087 {
1088 // Escaped here for the same reason as in getLinkText(): the settings
1089 // page runs both values through wp_kses_post(), but a shortcode
1090 // attribute of the same name reaches self::$cryptXOptions unfiltered.
1091 // Today only KSES stops an author from putting markup here -- that is
1092 // WordPress protecting the plugin, not the plugin protecting itself.
1093 $at = esc_html((string) self::$cryptXOptions['at']);
1094 $dot = esc_html((string) self::$cryptXOptions['dot']);
1095
1096 $text = str_replace("@", $at, $Match[1]);
1097
1098 return str_replace(".", $dot, $text);
1099 }
1100
1101 /**
1102 * List all files in a directory that match the given filter.
1103 *
1104 * @param string $path The path of the directory to list files from.
1105 * @param array $filter The file extensions to filter by.
1106 * If it's a string, it will be converted to an array of a single element.
1107 *
1108 * @return array An array of file names that match the filter.
1109 */
1110 public function getFilesInDirectory(string $path, array $filter): array
1111 {
1112 if (!is_dir($path)) {
1113 return [];
1114 }
1115
1116 $directoryContent = [];
1117 foreach (new \DirectoryIterator($path) as $file) {
1118 if (!$file->isFile()) {
1119 continue;
1120 }
1121 if (in_array(strtolower($file->getExtension()), $filter, true)) {
1122 $directoryContent[] = $file->getFilename();
1123 }
1124 }
1125
1126 // readdir() order depends on the file system, which made the default
1127 // font differ between servers. Sorting keeps it reproducible.
1128 sort($directoryContent);
1129
1130 return $directoryContent;
1131 }
1132
1133 /**
1134 * Finds and processes email addresses within the given content.
1135 *
1136 * This method scans the provided content for email addresses and encrypts them based on the configuration.
1137 * It checks for RSS feed settings and excluded post IDs to determine whether encryption should be applied.
1138 *
1139 * @param string|null $content The content to search for email addresses. If null, the method returns null.
1140 * @param bool $shortcode Specifies whether the method is invoked via a shortcode.
1141 * @return string|null The processed content with email addresses encrypted, or null if the input content is null.
1142 */
1143 public function findEmailAddressesInContent(?string $content, bool $shortcode = false): ?string
1144 {
1145 global $post;
1146
1147 if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
1148
1149 if ($content === null) {
1150 return null;
1151 }
1152
1153 // A mailto link without an at sign cannot carry an address. Cheapest
1154 // possible way out before the regular expression runs.
1155 if (strpos($content, '@') === false) {
1156 return $content;
1157 }
1158
1159 // Check if current filter is a widget filter
1160 $widgetFilters = $this->config->getWidgetFilters();
1161 $isWidgetContext = in_array(current_filter(), $widgetFilters);
1162
1163 $postId = (is_object($post)) ? $post->ID : -1;
1164 $isIdExcluded = $this->isIdExcluded($postId);
1165
1166 // Quoted attribute values may contain ">", so the tag must not simply
1167 // end at the first one -- title="a > b" used to cut the match in half
1168 // and produce mangled markup. Same construction as in
1169 // rewriteOpeningAnchorTag(); the two have to agree on what a tag is.
1170 $mailtoRegex = '/<a\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*?href\s*=\s*(["\'])mailto:([^"\']+)\1(?:[^>"\']|"[^"]*"|\'[^\']*\')*>(.*?)<\/a>/is';
1171 $that = $this;
1172
1173 // For widgets, always process since there's no specific post context
1174 // For other content, check exclusion rules
1175 if ($isWidgetContext || !$isIdExcluded || $shortcode) {
1176 $content = $this->withShortcodesProtected($content, static function (string $masked) use ($mailtoRegex, $that): string {
1177 $result = preg_replace_callback($mailtoRegex, [$that, 'encryptEmailAddressSecure'], $masked);
1178
1179 // null means PCRE gave up (backtrack limit). Keeping the
1180 // original content is far better than returning null and
1181 // wiping the page.
1182 return $result ?? $masked;
1183 });
1184 }
1185
1186 return $content;
1187 }
1188
1189 /**
1190 * Generate a hash string for the given input string.
1191 *
1192 * @param string $inputString The input string to generate a hash for.
1193 *
1194 * @return string The generated hash string.
1195 */
1196 private function generateHashFromString(string $inputString): string
1197 {
1198 $inputString = str_replace("&", "&", $inputString);
1199 $crypt = '';
1200
1201 for ($i = 0; $i < strlen($inputString); $i++) {
1202 do {
1203 $salt = wp_rand(0, 3);
1204 $asciiValue = ord(substr($inputString, $i)) + $salt;
1205 if (8364 <= $asciiValue) {
1206 $asciiValue = 128;
1207 }
1208 } while (in_array($asciiValue, self::ASCII_VALUES_BLACKLIST));
1209
1210 $crypt .= $salt . chr($asciiValue);
1211 }
1212
1213 return $crypt;
1214 }
1215
1216 /**
1217 * add link to email addresses
1218 */
1219 /**
1220 * Auto-link emails in the given content.
1221 *
1222 * @param string $content The content to process.
1223 * @param bool $shortcode Whether the function is called from a shortcode or not.
1224 *
1225 * @return string The content with emails auto-linked.
1226 */
1227 public function addLinkToEmailAddresses(string $content, bool $shortcode = false): string
1228 {
1229 global $post;
1230
1231 // The same gate the other two stages carry, and missing here until
1232 // 4.1.1. "Leave RSS feeds unprotected" is meant as "do not touch
1233 // feeds"; without this, the autolink stage still turned a bare address
1234 // into a mailto link in the feed, while the two stages that protect it
1235 // stepped aside. The result was not a leak -- with the option on, the
1236 // address is in the feed either way -- but it was CryptX changing
1237 // content it had just been told to leave alone.
1238 //
1239 // The $shortcode exception is made here and not in the other two
1240 // stages: those bail out of a feed unconditionally. Keeping it means
1241 // the shortcode path behaves exactly as it did before this guard
1242 // existed, which is the point -- the shortcode is an explicit
1243 // instruction and outranks a blanket setting.
1244 if (!$shortcode && self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) {
1245 return $content;
1246 }
1247
1248 // Eight regular expressions follow, each carrying the full address
1249 // pattern. Without an at sign not one of them can match, so this test
1250 // saves the entire pass.
1251 if (strpos($content, '@') === false) {
1252 return $content;
1253 }
1254
1255 // Check if current filter is a widget filter
1256 $widgetFilters = $this->config->getWidgetFilters();
1257 $isWidgetContext = in_array(current_filter(), $widgetFilters);
1258
1259 $postID = is_object($post) ? $post->ID : -1;
1260
1261 // For widgets, always process; for other content, check exclusion rules
1262 if (!$isWidgetContext && $this->isIdExcluded($postID) && !$shortcode) {
1263 return $content;
1264 }
1265
1266 $emailPattern = "[_a-zA-Z0-9-+]+(\\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*(\\.[a-zA-Z]{2,})";
1267 $linkPattern = "<a href=\"mailto:\\2\">\\2</a>";
1268 // Two widenings, both from the same report. The patterns after ">"
1269 // required a "<" or whitespace to follow, so an address that ended the
1270 // string right after a tag -- "Kontakt:<br>info@example.com" -- was
1271 // never linked; hence the "$" variant. And they accepted only ">",
1272 // while wp_kses_post() turns a bare ">" into "&gt;", leaving a ";"
1273 // in front of the address; hence "[>;]", which covers the end of any
1274 // HTML entity.
1275 //
1276 // In post content neither showed much, because a closing tag almost
1277 // always follows an address. Through cryptx_encrypt() both showed every
1278 // time. Worse than the missing link was what came next: the display
1279 // stage still swapped the address for the configured link text, so the
1280 // address vanished from the page without anything working taking its
1281 // place.
1282 $src = [
1283 "/([\\s])($emailPattern)/si",
1284 "/([>;])($emailPattern)(<)/si",
1285 "/(\\()($emailPattern)(\\))/si",
1286 "/([>;])($emailPattern)([\\s])/si",
1287 "/([\\s])($emailPattern)(<)/si",
1288 "/([>;])($emailPattern)$/si",
1289 "/^($emailPattern)/si",
1290 "/(<a[^>]*>)<a[^>]*>/",
1291 "/(<\\/A>)<\\/A>/i"
1292 ];
1293 $tar = [
1294 "\\1$linkPattern",
1295 "\\1$linkPattern\\6",
1296 "\\1$linkPattern\\6",
1297 "\\1$linkPattern\\6",
1298 "\\1$linkPattern\\6",
1299 "\\1$linkPattern",
1300 "<a href=\"mailto:\\0\">\\0</a>",
1301 "\\1",
1302 "\\1"
1303 ];
1304
1305 return $this->withShortcodesProtected($content, static function (string $masked) use ($src, $tar): string {
1306 $result = preg_replace($src, $tar, $masked);
1307
1308 // Same reasoning as elsewhere: a PCRE failure yields null, and
1309 // handing that on would silently empty the page.
1310 return $result ?? $masked;
1311 });
1312 }
1313
1314 /**
1315 * Installs the CryptX plugin by updating its options and loading default values.
1316 */
1317 public function installCryptX(): void
1318 {
1319 global $wpdb;
1320
1321 // Load-bearing, not a duplicate of the 'switch_blog' hook -- do not
1322 // remove it as one. When a plugin is activated, WordPress includes its
1323 // file from activate_plugin(), long after plugins_loaded has fired, so
1324 // startCryptX() never runs in that request and the hook is not
1325 // registered. Measured: activating an inactive plugin, has_action(
1326 // 'switch_blog') is false throughout. Without this line the network
1327 // activation loop writes site 1's values into every other site --
1328 // secret, link text and exclusion list -- which is how the bug was
1329 // found in the first place.
1330 $this->refreshForCurrentSite();
1331
1332 self::$cryptXOptions['admin_notices_deprecated'] = true;
1333 if (self::$cryptXOptions['excludedIDs'] == "") {
1334 $tmp = array();
1335 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1336 $excludes = $wpdb->get_results($wpdb->prepare(
1337 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s",
1338 'cryptxoff',
1339 'true'
1340 ));
1341 if (count($excludes) > 0) {
1342 foreach ($excludes as $exclude) {
1343 $tmp[] = $exclude->post_id;
1344 }
1345 sort($tmp);
1346 self::$cryptXOptions['excludedIDs'] = implode(",", $tmp);
1347 update_option('cryptX', self::$cryptXOptions);
1348 self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); // reread Options
1349 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1350 $wpdb->query($wpdb->prepare(
1351 "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
1352 'cryptxoff'
1353 ));
1354 }
1355 }
1356 if (empty(self::$cryptXOptions['c2i_font'])) {
1357 // Only the file name is stored here. cryptXtinyUrl() prepends
1358 // CRYPTX_DIR_PATH . 'fonts/' itself, so an absolute path would
1359 // produce an unusable font path.
1360 self::$cryptXOptions['c2i_font'] = $this->getDefaultFont();
1361 }
1362 if (empty(self::$cryptXOptions['c2i_fontSize'])) {
1363 self::$cryptXOptions['c2i_fontSize'] = 10;
1364 }
1365 if (empty(self::$cryptXOptions['c2i_fontRGB'])) {
1366 self::$cryptXOptions['c2i_fontRGB'] = '000000';
1367 }
1368 update_option('cryptX', self::$cryptXOptions);
1369 self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); // reread Options
1370 }
1371
1372 private function addHooksHelper($function_name, $hook_name): void
1373 {
1374 if (function_exists($function_name)) {
1375 call_user_func($function_name, 'cryptx', 'CryptX', [$this, 'metaCheckbox'], $hook_name);
1376 } else {
1377 add_action("dbx_{$hook_name}_sidebar", [$this, 'metaOptionFieldset']);
1378 }
1379 }
1380
1381 public function metaBox(): void
1382 {
1383 $this->addHooksHelper('add_meta_box', 'post');
1384 $this->addHooksHelper('add_meta_box', 'page');
1385 }
1386
1387 /**
1388 * Displays a checkbox to disable CryptX for the current post or page.
1389 *
1390 * This function outputs HTML code for a checkbox that allows the user to disable CryptX
1391 * functionality for the current post or page. If the current post or page ID is excluded
1392 **/
1393 public function metaCheckbox(): void
1394 {
1395 global $post;
1396
1397 if (!is_object($post)) {
1398 return;
1399 }
1400
1401 wp_nonce_field(self::METABOX_NONCE_ACTION, self::METABOX_NONCE_FIELD);
1402 ?>
1403 <label><input type="checkbox" name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
1404 echo 'checked="checked"';
1405 } ?>/>
1406 <?php esc_html_e('Disable CryptX for this post/page', 'cryptx'); ?></label>
1407 <?php
1408 }
1409
1410 /**
1411 * Renders the CryptX option fieldset for the current post/page if the user has permission to edit posts.
1412 * This fieldset allows the user to enable or disable CryptX for the current post/page.
1413 *
1414 * @return void
1415 */
1416 public function metaOptionFieldset(): void
1417 {
1418 global $post;
1419
1420 if (!is_object($post) || !current_user_can('edit_post', $post->ID)) {
1421 return;
1422 }
1423 ?>
1424 <fieldset id="cryptxoption" class="dbx-box">
1425 <h3 class="dbx-handle">CryptX</h3>
1426 <div class="dbx-content">
1427 <?php wp_nonce_field(self::METABOX_NONCE_ACTION, self::METABOX_NONCE_FIELD); ?>
1428 <label><input type="checkbox"
1429 name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
1430 echo 'checked="checked"';
1431 } ?>/> <?php esc_html_e('Disable CryptX for this post/page', 'cryptx'); ?></label>
1432 </div>
1433 </fieldset>
1434 <?php
1435 }
1436
1437 /**
1438 * Adds a post ID to the excluded list in the cryptX options.
1439 *
1440 * @param int $postId The post ID to be added to the excluded list.
1441 *
1442 * @return void
1443 */
1444 public function addPostIdToExcludedList(int $postId): void
1445 {
1446 // The meta box has to have taken part in this request. Without this
1447 // gate every save that carries no $_POST at all -- REST, WP-CLI,
1448 // autosave, the block editor's first pass -- removed the post from the
1449 // exclusion list and silently switched CryptX back on for it.
1450 //
1451 // The gate hangs on the nonce, deliberately not on the checkbox: an
1452 // unchecked box is not submitted at all, so "checkbox missing" would
1453 // mean both "meta box was not involved" and "user cleared the tick".
1454 // Guarding on that would make an excluded post impossible to include
1455 // again.
1456 if (!isset($_POST[self::METABOX_NONCE_FIELD])) {
1457 return;
1458 }
1459
1460 $nonce = sanitize_text_field(wp_unslash($_POST[self::METABOX_NONCE_FIELD]));
1461 if (!wp_verify_nonce($nonce, self::METABOX_NONCE_ACTION)) {
1462 return;
1463 }
1464
1465 $postId = wp_is_post_revision($postId) ?: $postId;
1466
1467 if (!current_user_can('edit_post', $postId)) {
1468 return;
1469 }
1470
1471 // Read the option fresh instead of writing back self::$cryptXOptions.
1472 // That property is static and the shortcode overwrites it while it
1473 // runs; storing it wholesale could persist a shortcode's temporary
1474 // values. Only the one key we are responsible for is touched.
1475 $options = get_option('cryptX', []);
1476 if (!is_array($options)) {
1477 $options = [];
1478 }
1479
1480 $excludedIds = $this->updateExcludedIdsList((string) ($options['excludedIDs'] ?? ''), $postId);
1481 $options['excludedIDs'] = implode(',', array_filter($excludedIds));
1482
1483 update_option('cryptX', $options);
1484
1485 self::$cryptXOptions['excludedIDs'] = $options['excludedIDs'];
1486 self::resetOptionCaches();
1487 }
1488
1489 /**
1490 * Updates the excluded IDs list based on a given ID and the current list.
1491 *
1492 * @param string $excludedIds The current excluded IDs list, separated by commas.
1493 * @param int $postId The ID to be updated in the excluded IDs list.
1494 *
1495 * @return array The updated excluded IDs list as an array, with the ID removed if it existed and added if necessary.
1496 */
1497 private function updateExcludedIdsList(string $excludedIds, int $postId): array
1498 {
1499 $excludedIdsArray = explode(",", $excludedIds);
1500 $excludedIdsArray = $this->removePostIdFromExcludedIds($excludedIdsArray, $postId);
1501 $excludedIdsArray = $this->addPostIdToExcludedIdsIfNecessary($excludedIdsArray, $postId);
1502
1503 return $this->makeExcludedIdsUniqueAndSorted($excludedIdsArray);
1504 }
1505
1506 /**
1507 * Removes a specific post ID from the array of excluded IDs.
1508 *
1509 * @param array $excludedIds The array of excluded IDs.
1510 * @param int $postId The ID of the post to be removed from the excluded IDs.
1511 *
1512 * @return array The updated array of excluded IDs without the specified post ID.
1513 */
1514 private function removePostIdFromExcludedIds(array $excludedIds, int $postId): array
1515 {
1516 foreach ($excludedIds as $key => $id) {
1517 if ($id == $postId) {
1518 unset($excludedIds[$key]);
1519 break;
1520 }
1521 }
1522
1523 return $excludedIds;
1524 }
1525
1526 /**
1527 * Adds the post ID to the list of excluded IDs if necessary.
1528 *
1529 * @param array $excludedIds The array of excluded IDs.
1530 * @param int $postId The post ID to be added to the excluded IDs.
1531 *
1532 * @return array The updated array of excluded IDs.
1533 */
1534 private function addPostIdToExcludedIdsIfNecessary(array $excludedIds, int $postId): array
1535 {
1536 if (isset($_POST['disable_cryptx_pageid'])) {
1537 $excludedIds[] = $postId;
1538 }
1539
1540 return $excludedIds;
1541 }
1542
1543 /**
1544 * Makes the excluded IDs unique and sorted.
1545 *
1546 * @param array $excludedIds The array of excluded IDs.
1547 *
1548 * @return array The array of excluded IDs with duplicate values removed and sorted in ascending order.
1549 */
1550 private function makeExcludedIdsUniqueAndSorted(array $excludedIds): array
1551 {
1552 $excludedIds = array_unique($excludedIds);
1553 sort($excludedIds);
1554
1555 return $excludedIds;
1556 }
1557
1558 /**
1559 * Retrieves the domain from the current site URL.
1560 *
1561 * @return string The domain of the current site URL.
1562 */
1563 public function getDomain(): string
1564 {
1565 return $this->trimSlashFromDomain($this->removeProtocolFromUrl($this->getSiteUrl()));
1566 }
1567
1568 /**
1569 * Retrieves the site URL.
1570 *
1571 * @return string The site URL.
1572 */
1573 private function getSiteUrl(): string
1574 {
1575 return get_option('siteurl');
1576 }
1577
1578 /**
1579 * Removes the protocol from a URL.
1580 *
1581 * @param string $url The URL string to remove the protocol from.
1582 *
1583 * @return string The URL string without the protocol.
1584 */
1585 private function removeProtocolFromUrl(string $url): string
1586 {
1587 return preg_replace('|https?://|', '', $url);
1588 }
1589
1590 /**
1591 * Trims the trailing slash from a domain.
1592 *
1593 * @param string $domain The domain to trim the slash from.
1594 *
1595 * @return string The domain with the trailing slash removed.
1596 */
1597 private function trimSlashFromDomain(string $domain): string
1598 {
1599 if ($slashPosition = strpos($domain, '/')) {
1600 $domain = substr($domain, 0, $slashPosition);
1601 }
1602
1603 return $domain;
1604 }
1605
1606 /**
1607 * Registers the frontend assets.
1608 *
1609 * Registering is not loading. Whether the files end up on the page is
1610 * decided in enqueueAssetsIfNeeded() once the content has been processed
1611 * and it is known whether anything was encrypted at all.
1612 *
1613 * One exception: with the script placed in the head (load_java = 0) that
1614 * decision cannot be deferred -- the head is sent before the content runs.
1615 * In that configuration the script is enqueued unconditionally, as before.
1616 *
1617 * @return void
1618 */
1619 public function loadJavascriptFiles(): void
1620 {
1621 $inFooter = !empty(self::$cryptXOptions['load_java']);
1622
1623 wp_register_script('cryptx-js', CRYPTX_DIR_URL . 'js/cryptx.min.js', [], CRYPTX_VERSION, $inFooter);
1624 wp_localize_script('cryptx-js', 'cryptxConfig', SecureEncryption::getJavaScriptConfig());
1625 wp_register_style('cryptx-styles', CRYPTX_DIR_URL . 'css/cryptx.css', [], CRYPTX_VERSION);
1626
1627 if (!$inFooter) {
1628 wp_enqueue_script('cryptx-js');
1629 wp_enqueue_style('cryptx-styles');
1630 }
1631 }
1632
1633 /**
1634 * Loads the assets that this page turned out to need.
1635 *
1636 * Runs late, in the footer, when every filter has done its work.
1637 *
1638 * @return void
1639 */
1640 public function enqueueAssetsIfNeeded(): void
1641 {
1642 if (self::$scriptNeeded) {
1643 wp_enqueue_script('cryptx-js');
1644 }
1645
1646 if (self::$styleNeeded) {
1647 wp_enqueue_style('cryptx-styles');
1648 }
1649 }
1650
1651 /**
1652 * Updates the CryptX settings.
1653 *
1654 * This method retrieves the current CryptX options from the database and checks if the version of CryptX
1655 * stored in the options is less than the current version of CryptX. If the version is outdated, the method
1656 * updates the necessary settings and saves the updated options back to the database.
1657 *
1658 * @return void
1659 */
1660 private function updateCryptXSettings(): void
1661 {
1662 self::$cryptXOptions = get_option('cryptX');
1663
1664 $storedVersion = self::$cryptXOptions['version'] ?? null;
1665
1666 if ($storedVersion === null || version_compare(CRYPTX_VERSION, $storedVersion) <= 0) {
1667 return;
1668 }
1669
1670 // Every step below used to run on EVERY version bump, although each was
1671 // written for one particular upgrade. Measured on an installation
1672 // carrying 4.1.0: the chosen font fell back to the first available one,
1673 // the colour "#3366ff" became "##3366ff" -- gaining another "#" with
1674 // every future update -- and the encryption secret was thrown away, so
1675 // every link on an already cached page stopped resolving. None of that
1676 // was intended, and none of it was visible to the site owner.
1677 //
1678 // Each migration is now tied to the version it belongs to, or written
1679 // so that repeating it changes nothing.
1680
1681 // Up to 4.0.11 the password was derived from AUTH_KEY and
1682 // SECURE_AUTH_KEY, and that value is published in the markup of every
1683 // page. Since site_url is public, an attacker could test candidate keys
1684 // offline -- above all the placeholders from wp-config-sample.php that
1685 // unattended installations still carry. Dropping it lets
1686 // Config::getEncryptionPassword() mint a random one. The price is that
1687 // links on pages already sitting in a cache stop resolving until that
1688 // cache turns over, which is why it must happen exactly once.
1689 if (version_compare($storedVersion, '4.0.12', '<')) {
1690 unset(self::$cryptXOptions['encryption_password']);
1691
1692 // 4.0.12 replaced the bundled Arial, Times New Roman and Verdana
1693 // with freely licensed faces. A stored name from the old set no
1694 // longer exists on disk, so the choice has to be made again.
1695 unset(self::$cryptXOptions['c2i_font']);
1696 }
1697
1698 // Value-based rather than version-based, and therefore harmless to
1699 // repeat: colours were stored without the leading "#" before 4.0.
1700 if (!empty(self::$cryptXOptions['c2i_fontRGB'])
1701 && strpos((string) self::$cryptXOptions['c2i_fontRGB'], '#') !== 0) {
1702 self::$cryptXOptions['c2i_fontRGB'] = '#' . self::$cryptXOptions['c2i_fontRGB'];
1703 }
1704
1705 // Also value-based: an attachment id that is not an id is unusable, no
1706 // matter which version wrote it.
1707 if (isset(self::$cryptXOptions['alt_uploadedimage'])
1708 && !is_int(self::$cryptXOptions['alt_uploadedimage'])
1709 && !ctype_digit((string) self::$cryptXOptions['alt_uploadedimage'])) {
1710 unset(self::$cryptXOptions['alt_uploadedimage']);
1711
1712 if ((int) (self::$cryptXOptions['opt_linktext'] ?? 0) === 3) {
1713 unset(self::$cryptXOptions['opt_linktext']);
1714 }
1715 }
1716
1717 self::$cryptXOptions['version'] = CRYPTX_VERSION;
1718 self::$cryptXOptions = wp_parse_args(self::$cryptXOptions, $this->getCryptXOptionsDefaults());
1719 update_option('cryptX', self::$cryptXOptions);
1720 }
1721
1722 /**
1723 * Encodes a string by replacing special characters with their corresponding HTML entities.
1724 *
1725 * @param string|null $str The string to be encoded.
1726 *
1727 * @return string The encoded string, or an array of encoded strings if an array was passed.
1728 */
1729 private function encodeString(?string $str): string
1730 {
1731 $str = htmlentities($str, ENT_QUOTES, 'UTF-8');
1732 $special = array(
1733 '[' => '&#91;',
1734 ']' => '&#93;',
1735 );
1736
1737 return str_replace(array_keys($special), array_values($special), $str);
1738 }
1739
1740 /**
1741 * Decodes a string that has been HTML entity encoded.
1742 *
1743 * @param string|null $str The string to decode. If null, an empty string is returned.
1744 *
1745 * @return string The decoded string.
1746 */
1747 private function decodeString(?string $str): string
1748 {
1749 return html_entity_decode($str, ENT_QUOTES, 'UTF-8');
1750 }
1751
1752 /**
1753 * Converts an associative array into an argument string.
1754 *
1755 * @param array $args An optional associative array where keys represent argument names and values represent argument values.
1756 * @return string A formatted string of arguments where each key-value pair is encoded and concatenated.
1757 */
1758 public function convertArrayToArgumentString(array $args = []): string
1759 {
1760 $string = "";
1761 if (!empty($args)) {
1762 foreach ($args as $key => $value) {
1763 $string .= sprintf(" %s=\"%s\"", $key, esc_attr($value));
1764 }
1765 $string .= " encoded=\"true\"";
1766 }
1767
1768 return $string;
1769 }
1770
1771 /**
1772 * Check if current request is for an RSS feed
1773 *
1774 * @return bool True if current request is for an RSS feed, false otherwise
1775 */
1776 private function isRssFeed(): bool
1777 {
1778 return is_feed();
1779 }
1780
1781 /**
1782 * Adds plugin action links to the WordPress plugin row
1783 *
1784 * @param array $links Existing plugin row links
1785 * @param string $file Plugin file path
1786 * @return array Modified plugin row links
1787 */
1788 public function add_plugin_action_links(array $links, string $file): array
1789 {
1790 if ($file !== CRYPTX_BASENAME) {
1791 return $links;
1792 }
1793
1794 $additional_links = [
1795 $this->create_settings_link(),
1796 $this->create_donation_link()
1797 ];
1798
1799 return array_merge($links, $additional_links);
1800 }
1801
1802 /**
1803 * Creates and returns a settings link for the options page.
1804 *
1805 * @return string The HTML link to the settings page.
1806 */
1807 private function create_settings_link(): string
1808 {
1809 // Admin\SettingsPage::MENU_SLUG und nicht CRYPTX_BASEFOLDER: die
1810 // Seite haengt am Slug, nicht am Verzeichnisnamen. Auf wordpress.org
1811 // sind beide 'cryptx', nach einem Umbenennen des Ordners zeigte der
1812 // Link ins Leere.
1813 return sprintf(
1814 '<a href="%s">%s</a>',
1815 esc_url(admin_url('options-general.php?page=' . Admin\SettingsPage::MENU_SLUG)),
1816 esc_html__('Settings', 'cryptx')
1817 );
1818 }
1819
1820 /**
1821 * Creates and returns a donation link in HTML format.
1822 *
1823 * @return string The HTML string for the donation link.
1824 */
1825 private function create_donation_link(): string
1826 {
1827 return sprintf(
1828 '<a href="%s">%s</a>',
1829 esc_url(self::PAYPAL_DONATION_URL),
1830 esc_html__('Donate', 'cryptx')
1831 );
1832 }
1833
1834 /**
1835 * Adds a universal filter for all widget types by hooking into the widget display process.
1836 *
1837 * @return void
1838 */
1839 private function addUniversalWidgetFilters(): void
1840 {
1841 // Hook into the widget display process to catch all widget types
1842 add_filter('widget_display_callback', [$this, 'processWidgetContent'], 10, 3);
1843 }
1844
1845 /**
1846 * Processes widget content to handle email addresses by adding links, identifying occurrences,
1847 * and replacing them based on predefined rules.
1848 *
1849 * @param array|false $instance An array containing widget instance data, or false if no instance was provided.
1850 * @param object $widget The widget object whose content is being processed.
1851 * @param array $args Additional arguments provided to the widget.
1852 *
1853 * @return array|false Modified widget instance data as an array, or false if processing was not applicable.
1854 */
1855 public function processWidgetContent(array|false $instance, $widget, $args): array|false
1856 {
1857 if ($instance === false) {
1858 return false;
1859 }
1860
1861 // Only process if widget_text option is enabled
1862 if (!(self::$cryptXOptions['widget_text'] ?? false)) {
1863 return $instance;
1864 }
1865
1866 // Check if instance has text content (traditional text widgets)
1867 if (isset($instance['text']) && stripos($instance['text'], '@') !== false) {
1868 $instance['text'] = $this->addLinkToEmailAddresses($instance['text']);
1869 $instance['text'] = $this->findEmailAddressesInContent($instance['text']);
1870 $instance['text'] = $this->replaceEmailInContent($instance['text']);
1871 }
1872
1873 // Check if instance has content field (block widgets)
1874 if (isset($instance['content']) && stripos($instance['content'], '@') !== false) {
1875 $instance['content'] = $this->addLinkToEmailAddresses($instance['content']);
1876 $instance['content'] = $this->findEmailAddressesInContent($instance['content']);
1877 $instance['content'] = $this->replaceEmailInContent($instance['content']);
1878 }
1879
1880 return $instance;
1881 }
1882
1883 /**
1884 * Enhanced email encryption with security validation
1885 *
1886 * @param array $searchResults
1887 * @return string
1888 */
1889 /**
1890 * Cleans the query of a mailto link -- the "?subject=..." part.
1891 *
1892 * A positive list, not an exclusion list, because this value ends up
1893 * decrypted in the browser and handed to window.location. RFC 6068 defines
1894 * exactly these four headers as safe to accept from a link; everything else
1895 * is dropped rather than escaped, because there is no legitimate reason for
1896 * it to be there and no way to be sure what a mail client would do with it.
1897 *
1898 * Values are decoded and re-encoded rather than passed through: an incoming
1899 * "Hallo%20Welt" must not become "Hallo%2520Welt", and a raw space must not
1900 * stay a raw space.
1901 *
1902 * @param string $rawQuery The query as written in the href, without the "?".
1903 * @param int $budget How many characters the finished query may occupy.
1904 *
1905 * @return string The cleaned query, or an empty string if nothing survives.
1906 */
1907 private function sanitizeMailtoQuery(string $rawQuery, int $budget = PHP_INT_MAX): string
1908 {
1909 if ($rawQuery === '' || $budget <= 0) {
1910 return '';
1911 }
1912
1913 // "&amp;" is how a second parameter is spelled in valid HTML, and that
1914 // is what the regular expression handed us.
1915 $rawQuery = html_entity_decode($rawQuery, ENT_QUOTES, 'UTF-8');
1916
1917 $allowed = ['subject', 'body', 'cc', 'bcc'];
1918 $parts = [];
1919
1920 foreach (explode('&', $rawQuery) as $pair) {
1921 if ($pair === '' || strpos($pair, '=') === false) {
1922 continue;
1923 }
1924
1925 [$key, $value] = explode('=', $pair, 2);
1926 $key = strtolower(trim($key));
1927
1928 if (!in_array($key, $allowed, true) || isset($parts[$key])) {
1929 continue;
1930 }
1931
1932 $value = rawurldecode($value);
1933
1934 // A recipient list is still a list of addresses, and an invalid one
1935 // has no business being carried into a mail client.
1936 if ($key === 'cc' || $key === 'bcc') {
1937 $addresses = array_filter(array_map(
1938 static fn($address) => sanitize_email(trim($address)),
1939 explode(',', $value)
1940 ));
1941
1942 if ($addresses === []) {
1943 continue;
1944 }
1945
1946 $value = implode(',', $addresses);
1947 } else {
1948 // Control characters would let a payload break out of the
1949 // header it is written into.
1950 $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? '';
1951
1952 if (trim($value) === '') {
1953 continue;
1954 }
1955
1956 $value = mb_substr($value, 0, self::MAX_MAILTO_VALUE_LENGTH);
1957 }
1958
1959 $pair = $this->fitPairToBudget(
1960 $key,
1961 $value,
1962 // What is left once the pairs already collected, and the "&"
1963 // that would join this one, are accounted for.
1964 $budget - strlen(implode('&', $parts)) - ($parts === [] ? 0 : 1),
1965 ($key === 'cc' || $key === 'bcc') ? ',' : ''
1966 );
1967
1968 if ($pair === '') {
1969 continue;
1970 }
1971
1972 $parts[$key] = $pair;
1973 }
1974
1975 return implode('&', $parts);
1976 }
1977
1978 /**
1979 * Encodes one header and shortens it until it fits the space left.
1980 *
1981 * The value is cut before encoding, never after: percent encoding turns one
1982 * character into up to twelve, and a cut through "%C3%A4" leaves a sequence
1983 * no client can read.
1984 *
1985 * Why there is a budget at all: cryptx.js refuses to navigate to a URL
1986 * longer than 2048 characters, and so does SecureEncryption::validateUrl().
1987 * Counting the value in characters before encoding is not the same measure
1988 * -- 512 characters of Japanese become over 4000 once encoded. The link
1989 * then did nothing at all, with nothing on the page to say why.
1990 *
1991 * @param string $key The header name.
1992 * @param string $value The decoded value.
1993 * @param int $available Characters left for the encoded pair.
1994 * @param string $separator Set for list values: whole entries are dropped
1995 * instead of characters.
1996 *
1997 * @return string The encoded pair, or an empty string if it cannot fit.
1998 */
1999 private function fitPairToBudget(
2000 string $key,
2001 string $value,
2002 int $available,
2003 string $separator = ''
2004 ): string {
2005 $encodedKey = rawurlencode($key);
2006
2007 // The shortest useful pair is "key=" plus one character.
2008 if ($available < strlen($encodedKey) + 2) {
2009 return '';
2010 }
2011
2012 $pair = $encodedKey . '=' . rawurlencode($value);
2013
2014 // A recipient list is not free text. Cutting it by characters leaves a
2015 // fragment like "chef@examp" in a header a mail client will act on --
2016 // either bouncing or, worse, delivering somewhere unintended. Whole
2017 // addresses go, or the header goes.
2018 if ($separator !== '') {
2019 $items = explode($separator, $value);
2020
2021 while (strlen($pair) > $available && count($items) > 1) {
2022 array_pop($items);
2023 $pair = $encodedKey . '=' . rawurlencode(implode($separator, $items));
2024 }
2025
2026 return strlen($pair) > $available ? '' : $pair;
2027 }
2028
2029 while (strlen($pair) > $available && $value !== '') {
2030 $value = mb_substr($value, 0, mb_strlen($value) - 1);
2031 $pair = $encodedKey . '=' . rawurlencode($value);
2032 }
2033
2034 return $value === '' ? '' : $pair;
2035 }
2036
2037 private function encryptEmailAddressSecure(array $searchResults): string
2038 {
2039 $originalValue = $searchResults[0]; // Full match
2040 $rawTarget = $searchResults[2]; // Everything after "mailto:", verbatim
2041
2042 // Address and query are separated BEFORE sanitising. sanitize_email()
2043 // used to run over the whole target, and it strips "?" and "=" -- so
2044 // "sales@example.com?subject=Hello" became
2045 // "sales@example.comsubjectHello". Two things followed from that, both
2046 // reported in the support forum and neither obvious: the payload
2047 // carried a broken address, and the str_replace() below could no longer
2048 // find its needle, so the untouched "mailto:" href stayed in the page.
2049 $queryPosition = strpos($rawTarget, '?');
2050 $rawAddress = $queryPosition === false ? $rawTarget : substr($rawTarget, 0, $queryPosition);
2051 $rawQuery = $queryPosition === false ? '' : substr($rawTarget, $queryPosition + 1);
2052
2053 $emailAddress = sanitize_email($rawAddress);
2054
2055 if (strpos($emailAddress, '@') === self::NOT_FOUND) {
2056 return $originalValue;
2057 }
2058
2059 // The budget is what the browser will still accept once "mailto:",
2060 // the address and the "?" are in place.
2061 $query = $this->sanitizeMailtoQuery(
2062 $rawQuery,
2063 self::MAX_MAILTO_URL_LENGTH - strlen('mailto:' . $emailAddress . '?')
2064 );
2065 $mailtoTarget = $emailAddress . ($query === '' ? '' : '?' . $query);
2066
2067 $return = $originalValue;
2068
2069 // Apply JavaScript handler if enabled
2070 if (!empty(self::$cryptXOptions['java'])) {
2071 $encryptionMode = $this->config->getEncryptionMode();
2072 $payloadMode = 'legacy';
2073 $password = '';
2074
2075 // Determine which encryption method to use
2076 if ($encryptionMode === 'secure' &&
2077 $this->config->isSecureEncryptionEnabled() &&
2078 class_exists('CryptX\SecureEncryption')) {
2079
2080 // Use modern AES-256-GCM encryption
2081 try {
2082 $password = $this->config->getEncryptionPassword();
2083 $mailtoUrl = 'mailto:' . $mailtoTarget;
2084 $encryptedEmail = SecureEncryption::encrypt($mailtoUrl, $password);
2085 $payloadMode = 'secure';
2086 } catch (\Exception $e) {
2087 // Fallback to legacy if secure encryption fails
2088 $encryptedEmail = $this->generateHashFromString($mailtoTarget);
2089 $password = '';
2090 }
2091 } else {
2092 // Use legacy encryption (original algorithm). cryptx.js puts
2093 // "mailto:" in front of whatever comes out, so the query rides
2094 // along here as well.
2095 $encryptedEmail = $this->generateHashFromString($mailtoTarget);
2096 }
2097
2098 self::$scriptNeeded = true;
2099
2100 if ($this->getLinkMode() === 'data') {
2101 // Preferred form: the payload travels in data attributes and a
2102 // delegated click handler in cryptx.js does the work. A
2103 // "javascript:" URI would be blocked outright by any halfway
2104 // strict Content-Security-Policy, taking every CryptX link on
2105 // the page with it -- silently.
2106 $attributes = sprintf(
2107 ' data-cx="%s" data-cxm="%s"',
2108 esc_attr($encryptedEmail),
2109 esc_attr($payloadMode)
2110 );
2111 if ($payloadMode === 'secure') {
2112 $attributes .= sprintf(' data-cxk="%s"', esc_attr($password));
2113 }
2114
2115 // The raw target, not the sanitised address: they differ as
2116 // soon as a query is present, and a needle that is not in the
2117 // haystack leaves the plain "mailto:" href untouched.
2118 //
2119 // str_ireplace, because the pattern above matches case
2120 // insensitively: an href written "MAILTO:" was found, but a
2121 // lower-case needle then missed it -- same failure, reached
2122 // through the spelling of the scheme instead of the query.
2123 $return = str_ireplace('mailto:' . $rawTarget, '#', $originalValue);
2124 $return = $this->addAttributesToAnchor($return, $attributes);
2125 $return = $this->addClassToAnchor($return, self::LINK_CLASS);
2126 } else {
2127 // Legacy form, kept for installations that depend on it.
2128 $javaHandler = $payloadMode === 'secure'
2129 ? "javascript:secureDecryptAndNavigate('" . esc_js($encryptedEmail) . "', '" . esc_js($password) . "')"
2130 : "javascript:DeCryptX('" . esc_js($encryptedEmail) . "')";
2131
2132 $return = str_ireplace('mailto:' . $rawTarget, $javaHandler, $originalValue);
2133 }
2134 } else {
2135 // Fallback to antispambot if JavaScript is not enabled
2136 $return = str_ireplace('mailto:' . $rawTarget,
2137 antispambot('mailto:' . $mailtoTarget), $return);
2138 }
2139
2140 // Add CSS attributes if specified
2141 if (!empty(self::$cryptXOptions['css_id'])) {
2142 // Guarded like every other preg_* call site in this class: a PCRE
2143 // error yields null, and $return is declared string.
2144 $return = $this->addIdToAnchor($return, self::$cryptXOptions['css_id']);
2145 }
2146
2147 if (!empty(self::$cryptXOptions['css_class'])) {
2148 $return = $this->addClassToAnchor($return, self::$cryptXOptions['css_class']);
2149 }
2150
2151 return $return;
2152 }
2153
2154 /**
2155 * Runs a sample through the real processing chain for the settings preview.
2156 *
2157 * Deliberately not a reimplementation: the preview calls the same three
2158 * filters the front end calls, with the same encryption. A separate
2159 * "preview renderer" would drift away from the truth sooner or later, and
2160 * a preview that lies is worse than none.
2161 *
2162 * Nothing is written. Both the static option list and the Config instance
2163 * are swapped for the duration and restored in a finally block -- Config
2164 * matters because the encryption path reads its mode and password from
2165 * there, not from the static list.
2166 *
2167 * @param array $overrides Option values as they stand in the unsaved form.
2168 * @param string $content The sample content.
2169 *
2170 * @return string The processed markup.
2171 */
2172 public function renderPreviewMarkup(array $overrides, string $content): string
2173 {
2174 $previousOptions = self::$cryptXOptions;
2175 $previousConfig = $this->config;
2176
2177 // Make sure a secret exists before the swap, and mint it through the
2178 // REAL Config if it does not.
2179 //
2180 // Config::getEncryptionPassword() writes when it has to mint, and
2181 // Config::save() stores the whole option array -- which, on the
2182 // throwaway Config below, is the administrator's unsaved form state.
2183 // A preview would then silently persist settings that were only being
2184 // tried out. The window is real: updateCryptXSettings() drops the
2185 // secret on every version bump, and the settings screen is the first
2186 // place an administrator goes after an update.
2187 $stored = $this->loadCryptXOptionsWithDefaults();
2188
2189 if (empty($stored['encryption_password'])) {
2190 // Mint through a Config built from the STORED options, and carry the
2191 // result into $merged by hand.
2192 //
2193 // Doing it through the live Config instead was not enough: that one
2194 // holds an in-memory copy taken at startup, so it can believe it has
2195 // a password while the row no longer does. It then writes nothing,
2196 // $merged is still without a secret, and the throwaway Config below
2197 // mints -- persisting the unsaved form along with it. A test that
2198 // watches pre_update_option_cryptX found exactly that.
2199 $stored['encryption_password'] = (new Config($stored))->getEncryptionPassword();
2200 }
2201
2202 $merged = wp_parse_args($overrides, $stored);
2203
2204 self::$cryptXOptions = $merged;
2205 $this->config = new Config($merged);
2206 self::resetOptionCaches();
2207
2208 try {
2209 if (!empty(self::$cryptXOptions['autolink'])) {
2210 $content = $this->addLinkToEmailAddresses($content, true);
2211 }
2212
2213 $content = $this->findEmailAddressesInContent($content, true);
2214
2215 return (string) $this->replaceEmailInContent($content, true);
2216 } finally {
2217 self::$cryptXOptions = $previousOptions;
2218 $this->config = $previousConfig;
2219 self::resetOptionCaches();
2220 }
2221 }
2222
2223 /**
2224 * Which link form the encrypted address is delivered in.
2225 *
2226 * 'data' puts the payload into data attributes and lets a delegated click
2227 * handler take over -- the only form that survives a Content-Security-Policy.
2228 * 'js' is the historical "javascript:" URI, offered under Advanced for
2229 * installations that depend on the old behaviour.
2230 *
2231 * @return string Either 'data' or 'js'.
2232 */
2233 private function getLinkMode(): string
2234 {
2235 $mode = (string) (self::$cryptXOptions['link_mode'] ?? 'data');
2236
2237 return $mode === 'js' ? 'js' : 'data';
2238 }
2239
2240 /**
2241 * Inserts additional attributes into the opening tag of an anchor.
2242 *
2243 * @param string $html The anchor markup.
2244 * @param string $attributes Attribute string, starting with a space.
2245 *
2246 * @return string The markup with the attributes added.
2247 */
2248 private function addAttributesToAnchor(string $html, string $attributes): string
2249 {
2250 return $this->rewriteOpeningAnchorTag(
2251 $html,
2252 static fn(string $tag): string => preg_replace('/(\s*\/?>)$/', $attributes . '$1', $tag, 1) ?? $tag
2253 );
2254 }
2255
2256 /**
2257 * Adds a class to an anchor, keeping any class that is already there.
2258 *
2259 * @param string $html The anchor markup.
2260 * @param string $class The class to add.
2261 *
2262 * @return string The markup with the class added.
2263 */
2264 private function addClassToAnchor(string $html, string $class): string
2265 {
2266 $class = esc_attr($class);
2267
2268 return $this->rewriteOpeningAnchorTag(
2269 $html,
2270 function (string $tag) use ($class): string {
2271 // (?:^|\s) rather than \b: a word boundary also sits
2272 // between the quote and the "c" of an attribute value such
2273 // as data-x="class='y'", so \bclass would bind to the text
2274 // inside that value. Requiring whitespace before the name
2275 // makes this an attribute rather than any occurrence of the
2276 // word -- and it holds no matter which attribute comes
2277 // first, which the greedy and the lazy variant each got
2278 // wrong in one of the two orders.
2279 if (preg_match('/(?:^|\s)class\s*=\s*(["\'])(.*?)\1/i', $tag)) {
2280 return preg_replace(
2281 '/((?:^|\s)class\s*=\s*(["\']))(.*?)\2/i',
2282 '$1$3 ' . $class . '$2',
2283 $tag,
2284 1
2285 ) ?? $tag;
2286 }
2287
2288 return preg_replace('/(\s*\/?>)$/', ' class="' . $class . '"$1', $tag, 1) ?? $tag;
2289 }
2290 );
2291 }
2292
2293 /**
2294 * Applies a rewrite to the opening tag of the first anchor only.
2295 *
2296 * Regular expressions on HTML are a poor tool, and this is the narrow case
2297 * where it is still defensible: the markup comes from CryptX's own mailto
2298 * pattern, so there is exactly one anchor and the payload is escaped before
2299 * it gets here. Isolating the opening tag keeps the rewrite from reaching
2300 * into attribute values or into the link text.
2301 *
2302 * @param string $html The anchor markup.
2303 * @param callable $rewrite Receives the opening tag, returns the new one.
2304 *
2305 * @return string The markup with the rewritten opening tag.
2306 */
2307 private function rewriteOpeningAnchorTag(string $html, callable $rewrite): string
2308 {
2309 // Quoted attribute values may legitimately contain ">", so a plain
2310 // [^>]* would end the tag too early and splice the new attribute into
2311 // the middle of somebody else's title.
2312 $openingTag = '/<a\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/i';
2313
2314 if (!preg_match($openingTag, $html, $matches, PREG_OFFSET_CAPTURE)) {
2315 return $html;
2316 }
2317
2318 $tag = $matches[0][0];
2319 $offset = $matches[0][1];
2320 $rewritten = $rewrite($tag);
2321
2322 return substr($html, 0, $offset) . $rewritten . substr($html, $offset + strlen($tag));
2323 }
2324
2325 /**
2326 * Adds an id to an anchor, keeping any id that is already there.
2327 *
2328 * @param string $html The anchor markup.
2329 * @param string $id The id to add.
2330 *
2331 * @return string The markup with the id added.
2332 */
2333 private function addIdToAnchor(string $html, string $id): string
2334 {
2335 $id = esc_attr($id);
2336
2337 return $this->rewriteOpeningAnchorTag(
2338 $html,
2339 function (string $tag) use ($id): string {
2340 // Same reasoning as in addClassToAnchor().
2341 if (preg_match('/(?:^|\s)id\s*=\s*(["\'])(.*?)\1/i', $tag)) {
2342 return preg_replace(
2343 '/((?:^|\s)id\s*=\s*(["\']))(.*?)\2/i',
2344 '$1$3 ' . $id . '$2',
2345 $tag,
2346 1
2347 ) ?? $tag;
2348 }
2349
2350 return preg_replace('/(\s*\/?>)$/', ' id="' . $id . '"$1', $tag, 1) ?? $tag;
2351 }
2352 );
2353 }
2354
2355 }