PluginProbe
CryptX / trunk
CryptX vtrunk
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 trunk, at classes/CryptX.php

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