PluginProbe
CryptX / 4.0.1
CryptX v4.0.1
4.2.1 4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 All 93 releases
← All changes | classes/CryptX.php +339 -1864 4.2.14.0.1 View file →
@@ -3,174 +3,28 @@
3 3 namespace CryptX;
4 4
5 5 final class CryptX
6 6 {
7 +
7 8 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 - */
9 + const MAIL_IDENTIFIER = 'mailto:';
17 10 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 - ];
11 + const INDEX_TO_CHECK = 4;
12 + const PATTERN = '/(.*)(">)/i';
106 13 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 14 private static ?self $instance = null;
120 15 private static array $cryptXOptions = [];
121 16 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 17 private const FONT_EXTENSION = 'ttf';
160 18 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;
19 + private const MAILTO_PATTERN = '/<a (.*?)(href=("|\')mailto:(.*?)("|\')(.*?)|)>\s*(.*?)\s*<\/a>/i';
20 + private const EMAIL_PATTERN = "/([_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,}))/i";
21 + private CryptXSettingsTabs $settingsTabs;
165 22 private Config $config;
166 23
167 24 private function __construct()
168 25 {
169 - $this->settingsPage = new Admin\SettingsPage();
170 - $this->siteHealth = new Admin\SiteHealth();
171 - $this->reviewNotice = new Admin\ReviewNotice();
172 - $this->block = new Block();
26 + $this->settingsTabs = new CryptXSettingsTabs($this);
173 27 $this->config = new Config(get_option('cryptX', []));
174 28 self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
175 29 }
176 30
@@ -205,16 +59,8 @@
205 59 * @return void
206 60 */
207 61 public function startCryptX(): void
208 62 {
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 63 $this->checkAndUpdateVersion();
218 64 $this->addUniversalWidgetFilters(); // Add this line
219 65 $this->initializePluginFilters();
220 66 $this->registerCoreHooks();
@@ -222,52 +68,8 @@
222 68 $this->registerAdditionalHooks();
223 69 }
224 70
225 71 /**
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 72 * Checks the current version of the application against the stored version and updates settings if the application version is newer.
271 73 *
272 74 * @return void
273 75 */
@@ -273,49 +75,16 @@
273 75 */
274 76 private function checkAndUpdateVersion(): void
275 77 {
276 78 $currentVersion = self::$cryptXOptions['version'] ?? null;
277 -
278 79 if ($currentVersion && version_compare(CRYPTX_VERSION, $currentVersion) > 0) {
279 80 $this->updateCryptXSettings();
280 -
281 - return;
282 81 }
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 82 }
309 83
310 84 /**
311 - * Initializes and registers plugin filters based on the configuration settings.
85 + * Initializes and applies plugin filters based on the defined configuration options.
312 86 *
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 87 * @return void
319 88 */
320 89 public function initializePluginFilters(): void
321 90 {
@@ -324,15 +93,8 @@
324 93 }
325 94
326 95 $activeFilters = $this->config->getActiveFilters();
327 96
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 97 foreach ($activeFilters as $filter) {
336 98 if ($filter === 'widget_text') {
337 99 $this->addWidgetFilters();
338 100 } else {
@@ -337,65 +99,16 @@
337 99 $this->addWidgetFilters();
338 100 } else {
339 101 // Add autolink filters for non-widget filters if autolink is enabled
340 102 if ($this->config->isAutolinkEnabled()) {
341 - $this->addAutoLinkFilters($filter, 11);
103 + $this->addAutoLinkFilters($filter, 10);
342 104 }
343 105 $this->addOtherFilters($filter);
344 106 }
345 107 }
346 -
347 - $this->addFeedFilters();
348 108 }
349 109
350 110 /**
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 111 * Registers core hooks for the plugin's functionality.
399 112 *
400 113 * @return void
401 114 */
@@ -401,20 +114,9 @@
401 114 */
402 115 private function registerCoreHooks(): void
403 116 {
404 117 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 118 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 119 }
418 120
419 121 /**
420 122 * Initializes the meta box functionality if enabled in the configuration.
@@ -430,16 +132,10 @@
430 132 return;
431 133 }
432 134
433 135 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 136 add_action('wp_insert_post', [$this, 'addPostIdToExcludedList']);
137 + add_action('wp_update_post', [$this, 'addPostIdToExcludedList']);
442 138 }
443 139
444 140 /**
445 141 * Registers additional WordPress hooks and shortcodes.
@@ -448,11 +144,9 @@
448 144 */
449 145 private function registerAdditionalHooks(): void
450 146 {
451 147 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']);
148 + add_filter('init', [$this, 'cryptXtinyUrl']);
455 149 add_shortcode('cryptx', [$this, 'cryptXShortcode']);
456 150 }
457 151
458 152 /**
@@ -516,193 +210,8 @@
516 210 *
517 211 * @param array $attributes The array of attributes, potentially encoded.
518 212 * @return array The array of decoded attributes with the 'encoded' key removed if present.
519 213 */
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 214 private function decodeAttributes(array $attributes): array
706 215 {
707 216 if (($attributes['encoded'] ?? '') !== 'true') {
708 217 return $attributes;
@@ -728,73 +237,44 @@
728 237 public function cryptXShortcode(array $atts = [], string $content = '', string $tag = ''): string
729 238 {
730 239 // Decode attributes if needed
731 240 $attributes = $this->decodeAttributes($atts);
732 - $attributes = array_change_key_case($attributes, CASE_LOWER);
733 241
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 242 // Update options if attributes provided
744 243 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)
244 + self::$cryptXOptions = shortcode_atts(
245 + $this->loadCryptXOptionsWithDefaults(),
246 + array_change_key_case($attributes, CASE_LOWER),
247 + $tag
755 248 );
249 + }
756 250
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();
251 + // Process content (inline the encryptAndLinkContent logic)
252 + if (self::$cryptXOptions['autolink'] ?? false) {
253 + $content = $this->addLinkToEmailAddresses($content, true);
765 254 }
766 255
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;
256 + $content = $this->findEmailAddressesInContent($content, true);
257 + $processedContent = $this->replaceEmailInContent($content, true);
771 258
772 - try {
773 - // Process content (inline the encryptAndLinkContent logic)
774 - if (self::$cryptXOptions['autolink'] ?? false) {
775 - $content = $this->addLinkToEmailAddresses($content, true);
776 - }
259 + // Reset options to defaults
260 + self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults();
777 261
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 - }
262 + return $processedContent;
263 + }
784 264
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 - }
265 + /**
266 + * Encrypts and links content.
267 + *
268 + * @param string $content The content to be encrypted and linked.
269 + *
270 + * @return string The encrypted and linked content.
271 + */
272 + private function encryptAndLinkContent(string $content, bool $shortcode = false): string
273 + {
274 + $content = $this->findEmailAddressesInContent($content, $shortcode);
795 275
796 - return $processedContent;
276 + return $this->replaceEmailInContent($content, $shortcode);
797 277 }
798 278
799 279 /**
800 280 * Retrieves the ID of the current post.
@@ -814,152 +294,49 @@
814 294 * @return void
815 295 */
816 296 public function cryptXtinyUrl(): void
817 297 {
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 - : '';
298 + $url = $_SERVER['REQUEST_URI'];
823 299 $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;
300 + if (count($params) > 1) {
301 + $tiny_url = $params[count($params) - 2];
302 + if ($tiny_url == md5(get_bloginfo('url'))) {
303 + $font = CRYPTX_DIR_PATH . 'fonts/' . self::$cryptXOptions['c2i_font'];
304 + $msg = $params[count($params) - 1];
305 + $size = self::$cryptXOptions['c2i_fontSize'];
306 + $pad = 1;
307 + $transparent = 1;
308 + $rgb = str_replace("#", "", self::$cryptXOptions['c2i_fontRGB']);
309 + $red = hexdec(substr($rgb, 0, 2));
310 + $grn = hexdec(substr($rgb, 2, 2));
311 + $blu = hexdec(substr($rgb, 4, 2));
312 + $bg_red = 255 - $red;
313 + $bg_grn = 255 - $grn;
314 + $bg_blu = 255 - $blu;
315 + $width = 0;
316 + $height = 0;
317 + $offset_x = 0;
318 + $offset_y = 0;
319 + $bounds = array();
320 + $image = "";
321 + $bounds = ImageTTFBBox($size, 0, $font, "W");
322 + $font_height = abs($bounds[7] - $bounds[1]);
323 + $bounds = ImageTTFBBox($size, 0, $font, $msg);
324 + $width = abs($bounds[4] - $bounds[6]);
325 + $height = abs($bounds[7] - $bounds[1]);
326 + $offset_y = $font_height + abs(($height - $font_height) / 2) - 1;
327 + $offset_x = 0;
328 + $image = imagecreatetruecolor($width + ($pad * 2), $height + ($pad * 2));
329 + imagesavealpha($image, true);
330 + $foreground = ImageColorAllocate($image, $red, $grn, $blu);
331 + $background = imagecolorallocatealpha($image, 0, 0, 0, 127);
332 + imagefill($image, 0, 0, $background);
333 + ImageTTFText($image, $size, 0, round($offset_x + $pad, 0), round($offset_y + $pad, 0), $foreground, $font, $msg);
334 + Header("Content-type: image/png");
335 + imagePNG($image);
336 + die;
897 337 }
898 -
899 - $msg = $decoded;
900 338 }
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 339 }
963 340
964 341 /**
965 342 * Adds common filters to a given filter name.
@@ -1013,21 +390,9 @@
1013 390 {
1014 391 $widgetFilters = $this->config->getWidgetFilters();
1015 392
1016 393 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);
394 + $this->addAutoLinkFilters($widgetFilter, 10);
1030 395 $this->addOtherFilters($widgetFilter);
1031 396 }
1032 397 }
1033 398
@@ -1039,36 +404,14 @@
1039 404 * @return bool Returns true if the ID is excluded, false otherwise.
1040 405 */
1041 406 private function isIdExcluded(int $ID): bool
1042 407 {
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 - }
408 + $excludedIds = explode(",", self::$cryptXOptions['excludedIDs']);
1050 409
1051 - return in_array($ID, self::$excludedIdCache, true);
410 + return in_array($ID, $excludedIds);
1052 411 }
1053 412
1054 413 /**
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 414 * Replaces email addresses in content with link texts.
1072 415 *
1073 416 * @param string|null $content The content to replace the email addresses in.
1074 417 * @param bool $isShortcode Flag indicating whether the method is called from a shortcode.
@@ -1080,15 +423,8 @@
1080 423 global $post;
1081 424
1082 425 if (self::$cryptXOptions['disable_rss'] && $this->isRssFeed()) return $content;
1083 426
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 427 // Check if current filter is a widget filter
1092 428 $widgetFilters = $this->config->getWidgetFilters();
1093 429 $isWidgetContext = in_array(current_filter(), $widgetFilters);
1094 430
@@ -1095,12 +431,9 @@
1095 431 $postId = (is_object($post)) ? $post->ID : -1;
1096 432
1097 433 // For widgets, always process; for other content, check exclusion rules
1098 434 if (($isWidgetContext || !$this->isIdExcluded($postId) || $isShortcode) && !empty($content)) {
1099 - $content = $this->withShortcodesProtected(
1100 - $content,
1101 - fn(string $masked): string => $this->replaceEmailWithLinkText($masked)
1102 - );
435 + $content = $this->replaceEmailWithLinkText($content);
1103 436 }
1104 437
1105 438 return $content;
1106 439 }
@@ -1116,14 +449,9 @@
1116 449 private function replaceEmailWithLinkText(string $content): string
1117 450 {
1118 451 $emailPattern = "/([_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,}))/i";
1119 452
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;
453 + return preg_replace_callback($emailPattern, [$this, 'encodeEmailToLinkText'], $content);
1126 454 }
1127 455
1128 456 /**
1129 457 * Encode email address to link text.
@@ -1133,9 +461,9 @@
1133 461 * @return string The encoded link text.
1134 462 */
1135 463 private function encodeEmailToLinkText(array $Match): string
1136 464 {
1137 - if ($this->inWhiteList($Match) || $this->isAddressExempt($Match[1])) {
465 + if ($this->inWhiteList($Match)) {
1138 466 return $Match[1];
1139 467 }
1140 468 switch (self::$cryptXOptions['opt_linktext']) {
1141 469 case 1:
@@ -1145,11 +473,9 @@
1145 473 $text = $this->getLinkImage();
1146 474 break;
1147 475 case 3:
1148 476 $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);
477 + $text = $this->getUploadedImage($img_url);
1152 478 self::$imageCounter++;
1153 479 break;
1154 480 case 4:
1155 481 $text = antispambot($Match[1]);
@@ -1165,165 +491,8 @@
1165 491 return $text;
1166 492 }
1167 493
1168 494 /**
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 495 * Check if the given match is in the whitelist.
1327 496 *
1328 497 * @param array $Match The match to check against the whitelist.
1329 498 *
@@ -1330,20 +499,12 @@
1330 499 * @return bool True if the match is in the whitelist, false otherwise.
1331 500 */
1332 501 private function inWhiteList(array $Match): bool
1333 502 {
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 -
503 + $whiteList = array_filter(array_map('trim', explode(",", self::$cryptXOptions['whiteList'])));
1343 504 $tmp = explode(".", $Match[0]);
1344 505
1345 - return in_array(end($tmp), self::$whiteListCache, true);
506 + return in_array(end($tmp), $whiteList);
1346 507 }
1347 508
1348 509 /**
1349 510 * Get the link text from cryptXOptions
@@ -1351,19 +512,9 @@
1351 512 * @return string The link text
1352 513 */
1353 514 private function getLinkText(): string
1354 515 {
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']);
516 + return self::$cryptXOptions['alt_linktext'];
1366 517 }
1367 518
1368 519 /**
1369 520 * Generate an HTML image tag with the link image URL as the source
@@ -1371,17 +522,9 @@
1371 522 * @return string The HTML image tag
1372 523 */
1373 524 private function getLinkImage(): string
1374 525 {
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 - );
526 + return "<img src=\"" . self::$cryptXOptions['alt_linkimage'] . "\" class=\"cryptxImage\" alt=\"" . self::$cryptXOptions['alt_linkimage_title'] . "\" title=\"" . antispambot(self::$cryptXOptions['alt_linkimage_title']) . "\" />";
1384 527 }
1385 528
1386 529 /**
1387 530 * Get the HTML tag for an uploaded image.
@@ -1391,20 +534,9 @@
1391 534 * @return string The HTML tag for the image.
1392 535 */
1393 536 private function getUploadedImage(string $img_url): string
1394 537 {
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 - );
538 + return "<img src=\"" . $img_url . "\" class=\"cryptxImage cryptxImage_" . self::$imageCounter . "\" alt=\"" . self::$cryptXOptions['http_linkimage_title'] . " title=\"" . antispambot(self::$cryptXOptions['http_linkimage_title']) . "\" />";
1407 539 }
1408 540
1409 541 /**
1410 542 * Converts a matched image URL into an HTML image element with cryptX classes and attributes.
@@ -1414,68 +546,9 @@
1414 546 * @return string Returns the HTML image element.
1415 547 */
1416 548 private function getImageFromText(array $Match): string
1417 549 {
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 - );
550 + return "<img src=\"" . get_bloginfo('url') . "/" . md5(get_bloginfo('url')) . "/" . antispambot($Match[1]) . "\" class=\"cryptxImage cryptxImage_" . self::$imageCounter . "\" alt=\"" . antispambot($Match[1]) . "\" title=\"" . antispambot($Match[1]) . "\" />";
1478 551 }
1479 552
1480 553 /**
1481 554 * Replaces specific characters with values from cryptX options in a given string.
@@ -1489,19 +562,11 @@
1489 562 * for each element.
1490 563 */
1491 564 private function getDefaultLinkText(array $Match): string
1492 565 {
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']);
566 + $text = str_replace("@", self::$cryptXOptions['at'], $Match[1]);
1500 567
1501 - $text = str_replace("@", $at, $Match[1]);
1502 -
1503 - return str_replace(".", $dot, $text);
568 + return str_replace(".", self::$cryptXOptions['dot'], $text);
1504 569 }
1505 570
1506 571 /**
1507 572 * List all files in a directory that match the given filter.
@@ -1513,26 +578,17 @@
1513 578 * @return array An array of file names that match the filter.
1514 579 */
1515 580 public function getFilesInDirectory(string $path, array $filter): array
1516 581 {
1517 - if (!is_dir($path)) {
1518 - return [];
1519 - }
1520 -
1521 - $directoryContent = [];
1522 - foreach (new \DirectoryIterator($path) as $file) {
1523 - if (!$file->isFile()) {
1524 - continue;
582 + $directoryHandle = opendir($path);
583 + $directoryContent = array();
584 + while ($file = readdir($directoryHandle)) {
585 + $fileExtension = substr(strtolower($file), -3);
586 + if (in_array($fileExtension, $filter)) {
587 + $directoryContent[] = $file;
1525 588 }
1526 - if (in_array(strtolower($file->getExtension()), $filter, true)) {
1527 - $directoryContent[] = $file->getFilename();
1528 - }
1529 589 }
1530 590
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 591 return $directoryContent;
1536 592 }
1537 593
1538 594 /**
@@ -1554,14 +610,8 @@
1554 610 if ($content === null) {
1555 611 return null;
1556 612 }
1557 613
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 614 // Check if current filter is a widget filter
1565 615 $widgetFilters = $this->config->getWidgetFilters();
1566 616 $isWidgetContext = in_array(current_filter(), $widgetFilters);
1567 617
@@ -1567,32 +617,139 @@
1567 617
1568 618 $postId = (is_object($post)) ? $post->ID : -1;
1569 619 $isIdExcluded = $this->isIdExcluded($postId);
1570 620
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;
621 + $mailtoRegex = '/<a\s+[^>]*href=(["\'])mailto:([^"\']+)\1[^>]*>(.*?)<\/a>/is';
1577 622
1578 623 // For widgets, always process since there's no specific post context
1579 624 // For other content, check exclusion rules
1580 625 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);
626 + // $content = preg_replace_callback($mailtoRegex, [$this, 'encryptEmailAddressNew'], $content);
627 + $content = preg_replace_callback($mailtoRegex, [$this, 'encryptEmailAddressSecure'], $content);
628 + }
1583 629
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 - });
630 + return $content;
631 + }
632 +
633 +
634 + /**
635 + * Encrypts email addresses in search results.
636 + *
637 + * @param array $searchResults The search results containing email addresses.
638 + *
639 + * @return string The search results with encrypted email addresses.
640 + */
641 + private function encryptEmailAddress(array $searchResults): string
642 + {
643 + $originalValue = $searchResults[0];
644 +
645 + if (strpos($searchResults[self::INDEX_TO_CHECK], '@') === self::NOT_FOUND) {
646 + return $originalValue;
1589 647 }
1590 648
1591 - return $content;
649 + $mailReference = self::MAIL_IDENTIFIER . $searchResults[self::INDEX_TO_CHECK];
650 +
651 + if (str_starts_with($searchResults[self::INDEX_TO_CHECK], self::SUBJECT_IDENTIFIER)) {
652 + return $originalValue;
653 + }
654 +
655 + $return = $originalValue;
656 +
657 + // Apply JavaScript handler if enabled
658 + if (!empty(self::$cryptXOptions['java'])) {
659 + $javaHandler = "javascript:DeCryptX('" . $this->generateHashFromString($searchResults[self::INDEX_TO_CHECK]) . "')";
660 + $return = str_replace(self::MAIL_IDENTIFIER . $searchResults[self::INDEX_TO_CHECK], $javaHandler, $originalValue);
661 + } else {
662 + // Only apply antispambot if JavaScript is not enabled
663 + $return = str_replace($mailReference, antispambot($mailReference), $return);
664 + }
665 +
666 + // Add CSS attributes if specified
667 + if (!empty(self::$cryptXOptions['css_id'])) {
668 + $return = preg_replace(self::PATTERN, '$1" id="' . self::$cryptXOptions['css_id'] . '">', $return);
669 + }
670 +
671 + if (!empty(self::$cryptXOptions['css_class'])) {
672 + $return = preg_replace(self::PATTERN, '$1" class="' . self::$cryptXOptions['css_class'] . '">', $return);
673 + }
674 +
675 + return $return;
1592 676 }
1593 677
1594 678 /**
679 + * Encrypts an email address within the provided search results and generates a secure or obfuscated link.
680 + * If secure encryption is enabled, the function uses secure encryption. Otherwise, it falls back to legacy methods
681 + * or antispambot obfuscation if JavaScript is not enabled. Additional CSS attributes can be added if specified.
682 + *
683 + * @param array $searchResults The array containing match results:
684 + * - Index 0: The full match value (original string),
685 + * - Index 2: The email address to encrypt,
686 + * - Index 3: The link text for the email link.
687 + * @return string Returns the modified string where the email address is encrypted or obfuscated based on the configuration.
688 + */
689 + private function encryptEmailAddressNew(array $searchResults): string
690 + {
691 + $originalValue = $searchResults[0]; // Full match
692 + $emailAddress = $searchResults[2]; // Email address (now at index 2)
693 + $linkText = $searchResults[3]; // Link text (now at index 3)
694 +
695 + if (strpos($emailAddress, '@') === self::NOT_FOUND) {
696 + return $originalValue;
697 + }
698 +
699 + if (str_starts_with($emailAddress, self::SUBJECT_IDENTIFIER)) {
700 + return $originalValue;
701 + }
702 +
703 + $return = $originalValue;
704 +
705 + // Apply JavaScript handler if enabled
706 + if (!empty(self::$cryptXOptions['java'])) {
707 + // Check if secure encryption is enabled and working
708 + if ($this->config->isSecureEncryptionEnabled()) {
709 + try {
710 + // Use secure encryption - encrypt the full mailto URL
711 + $password = $this->config->getEncryptionPassword();
712 + $mailtoUrl = 'mailto:' . $emailAddress;
713 + $encryptedEmail = SecureEncryption::encrypt($mailtoUrl, $password);
714 +
715 + $javaHandler = "javascript:secureDecryptAndNavigate('" .
716 + $this->escapeJavaScript($encryptedEmail) . "', '" .
717 + $this->escapeJavaScript($password) . "')";
718 + } catch (\Exception $e) {
719 + // Fallback to legacy encryption if secure encryption fails
720 + error_log('CryptX Secure Encryption failed: ' . $e->getMessage());
721 + $encryptedEmail = $this->generateHashFromString($emailAddress);
722 + $javaHandler = "javascript:DeCryptX('" . $this->escapeJavaScript($encryptedEmail) . "')";
723 + }
724 + } else {
725 + // Use legacy encryption
726 + $encryptedEmail = $this->generateHashFromString($emailAddress);
727 + $javaHandler = "javascript:DeCryptX('" . $this->escapeJavaScript($encryptedEmail) . "')";
728 + }
729 +
730 + $return = str_replace('mailto:' . $emailAddress, $javaHandler, $originalValue);
731 + } else {
732 + // Fallback to antispambot if JavaScript is not enabled
733 + $return = str_replace('mailto:' . $emailAddress,
734 + antispambot('mailto:' . $emailAddress), $return);
735 + }
736 +
737 + // Add CSS attributes if specified
738 + if (!empty(self::$cryptXOptions['css_id'])) {
739 + $return = preg_replace('/(<a\s+[^>]*)(>)/i',
740 + '$1 id="' . self::$cryptXOptions['css_id'] . '"$2', $return);
741 + }
742 +
743 + if (!empty(self::$cryptXOptions['css_class'])) {
744 + $return = preg_replace('/(<a\s+[^>]*)(>)/i',
745 + '$1 class="' . self::$cryptXOptions['css_class'] . '"$2', $return);
746 + }
747 +
748 + return $return;
749 + }
750 +
751 + /**
1595 752 * Generate a hash string for the given input string.
1596 753 *
1597 754 * @param string $inputString The input string to generate a hash for.
1598 755 *
@@ -1604,9 +761,9 @@
1604 761 $crypt = '';
1605 762
1606 763 for ($i = 0; $i < strlen($inputString); $i++) {
1607 764 do {
1608 - $salt = wp_rand(0, 3);
765 + $salt = mt_rand(0, 3);
1609 766 $asciiValue = ord(substr($inputString, $i)) + $salt;
1610 767 if (8364 <= $asciiValue) {
1611 768 $asciiValue = 128;
1612 769 }
@@ -1632,32 +789,8 @@
1632 789 public function addLinkToEmailAddresses(string $content, bool $shortcode = false): string
1633 790 {
1634 791 global $post;
1635 792
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 793 // Check if current filter is a widget filter
1661 794 $widgetFilters = $this->config->getWidgetFilters();
1662 795 $isWidgetContext = in_array(current_filter(), $widgetFilters);
1663 796
@@ -1669,29 +802,14 @@
1669 802 }
1670 803
1671 804 $emailPattern = "[_a-zA-Z0-9-+]+(\\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*(\\.[a-zA-Z]{2,})";
1672 805 $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 806 $src = [
1688 807 "/([\\s])($emailPattern)/si",
1689 - "/([>;])($emailPattern)(<)/si",
808 + "/(>)($emailPattern)(<)/si",
1690 809 "/(\\()($emailPattern)(\\))/si",
1691 - "/([>;])($emailPattern)([\\s])/si",
810 + "/(>)($emailPattern)([\\s])/si",
1692 811 "/([\\s])($emailPattern)(<)/si",
1693 - "/([>;])($emailPattern)$/si",
1694 812 "/^($emailPattern)/si",
1695 813 "/(<a[^>]*>)<a[^>]*>/",
1696 814 "/(<\\/A>)<\\/A>/i"
1697 815 ];
@@ -1700,30 +818,14 @@
1700 818 "\\1$linkPattern\\6",
1701 819 "\\1$linkPattern\\6",
1702 820 "\\1$linkPattern\\6",
1703 821 "\\1$linkPattern\\6",
1704 - "\\1$linkPattern",
1705 822 "<a href=\"mailto:\\0\">\\0</a>",
1706 823 "\\1",
1707 824 "\\1"
1708 825 ];
1709 826
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 - });
827 + return preg_replace($src, $tar, $content);
1726 828 }
1727 829
1728 830 /**
1729 831 * Installs the CryptX plugin by updating its options and loading default values.
@@ -1730,57 +832,12 @@
1730 832 */
1731 833 public function installCryptX(): void
1732 834 {
1733 835 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 836 self::$cryptXOptions['admin_notices_deprecated'] = true;
1775 837 if (self::$cryptXOptions['excludedIDs'] == "") {
1776 838 $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 - ));
839 + $excludes = $wpdb->get_results("SELECT post_id FROM $wpdb->postmeta WHERE meta_key = 'cryptxoff' AND meta_value = 'true'");
1783 840 if (count($excludes) > 0) {
1784 841 foreach ($excludes as $exclude) {
1785 842 $tmp[] = $exclude->post_id;
1786 843 }
@@ -1787,20 +844,13 @@
1787 844 sort($tmp);
1788 845 self::$cryptXOptions['excludedIDs'] = implode(",", $tmp);
1789 846 update_option('cryptX', self::$cryptXOptions);
1790 847 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 - ));
848 + $wpdb->query("DELETE FROM $wpdb->postmeta WHERE meta_key = 'cryptxoff'");
1796 849 }
1797 850 }
1798 851 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();
852 + self::$cryptXOptions['c2i_font'] = CRYPTX_DIR_PATH . 'fonts/' . $firstFont[0];
1803 853 }
1804 854 if (empty(self::$cryptXOptions['c2i_fontSize'])) {
1805 855 self::$cryptXOptions['c2i_fontSize'] = 10;
1806 856 }
@@ -1834,19 +884,13 @@
1834 884 **/
1835 885 public function metaCheckbox(): void
1836 886 {
1837 887 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 888 ?>
1845 889 <label><input type="checkbox" name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
1846 890 echo 'checked="checked"';
1847 891 } ?>/>
1848 - <?php esc_html_e('Disable CryptX for this post/page', 'cryptx'); ?></label>
892 + Disable CryptX for this post/page</label>
1849 893 <?php
1850 894 }
1851 895
1852 896 /**
@@ -1857,24 +901,20 @@
1857 901 */
1858 902 public function metaOptionFieldset(): void
1859 903 {
1860 904 global $post;
1861 -
1862 - if (!is_object($post) || !current_user_can('edit_post', $post->ID)) {
1863 - return;
905 + if (current_user_can('edit_posts')) { ?>
906 + <fieldset id="cryptxoption" class="dbx-box">
907 + <h3 class="dbx-handle">CryptX</h3>
908 + <div class="dbx-content">
909 + <label><input type="checkbox"
910 + name="disable_cryptx_pageid" <?php if ($this->isIdExcluded($post->ID)) {
911 + echo 'checked="checked"';
912 + } ?>/> Disable CryptX for this post/page</label>
913 + </div>
914 + </fieldset>
915 + <?php
1864 916 }
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 917 }
1878 918
1879 919 /**
1880 920 * Adds a post ID to the excluded list in the cryptX options.
@@ -1884,49 +924,12 @@
1884 924 * @return void
1885 925 */
1886 926 public function addPostIdToExcludedList(int $postId): void
1887 927 {
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 928 $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();
929 + $excludedIds = $this->updateExcludedIdsList(self::$cryptXOptions['excludedIDs'], $postId);
930 + self::$cryptXOptions['excludedIDs'] = implode(",", array_filter($excludedIds));
931 + update_option('cryptX', self::$cryptXOptions);
1929 932 }
1930 933
1931 934 /**
1932 935 * Updates the excluded IDs list based on a given ID and the current list.
@@ -1974,18 +977,8 @@
1974 977 * @return array The updated array of excluded IDs.
1975 978 */
1976 979 private function addPostIdToExcludedIdsIfNecessary(array $excludedIds, int $postId): array
1977 980 {
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 981 if (isset($_POST['disable_cryptx_pageid'])) {
1989 982 $excludedIds[] = $postId;
1990 983 }
1991 984
@@ -2007,8 +1000,27 @@
2007 1000 return $excludedIds;
2008 1001 }
2009 1002
2010 1003 /**
1004 + * Displays a message in a styled div.
1005 + *
1006 + * @param string $message The message to be displayed.
1007 + * @param bool $errormsg Optional. Indicates whether the message is an error message. Default is false.
1008 + *
1009 + * @return void
1010 + */
1011 + private function showMessage(string $message, bool $errormsg = false): void
1012 + {
1013 + if ($errormsg) {
1014 + echo '<div id="message" class="error">';
1015 + } else {
1016 + echo '<div id="message" class="updated fade">';
1017 + }
1018 +
1019 + echo "$message</div>";
1020 + }
1021 +
1022 + /**
2011 1023 * Retrieves the domain from the current site URL.
2012 1024 *
2013 1025 * @return string The domain of the current site URL.
2014 1026 */
@@ -2055,117 +1067,19 @@
2055 1067 return $domain;
2056 1068 }
2057 1069
2058 1070 /**
2059 - * Registers the frontend assets.
1071 + * Loads Javascript files required for CryptX functionality.
2060 1072 *
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 - * That head branch deliberately does not look at "java" at all, and never
2070 - * did -- this method leaves it untouched. It follows that a
2071 - * "[cryptx java=...]" shortcode override has nothing to add there: the
2072 - * script is already on every page regardless of the global setting, so
2073 - * only the footer branch below needs to read "java" or care about a
2074 - * shortcode overriding it.
2075 - *
2076 1073 * @return void
2077 1074 */
2078 1075 public function loadJavascriptFiles(): void
2079 1076 {
2080 - $inFooter = !empty(self::$cryptXOptions['load_java']);
2081 -
2082 - wp_register_script('cryptx-js', CRYPTX_DIR_URL . 'js/cryptx.min.js', [], CRYPTX_VERSION, $inFooter);
2083 - wp_localize_script('cryptx-js', 'cryptxConfig', SecureEncryption::getJavaScriptConfig());
2084 - wp_register_style('cryptx-styles', CRYPTX_DIR_URL . 'css/cryptx.css', [], CRYPTX_VERSION);
2085 -
2086 - if (!$inFooter) {
2087 - wp_enqueue_script('cryptx-js');
2088 - wp_enqueue_style('cryptx-styles');
2089 - } elseif (!empty(self::$cryptXOptions['java'])) {
2090 - // Footer placement, JavaScript handler enabled: enqueue cryptx-js
2091 - // unconditionally, here, before it is known whether THIS request's
2092 - // content carries an address. wp_register_script() above already
2093 - // registered it with $inFooter = true, so this does not move the
2094 - // print location -- it still prints in wp_footer, exactly as
2095 - // before. Deferring to enqueueAssetsIfNeeded() (scriptNeeded) only
2096 - // covers a classic page load, where the address a visitor clicks
2097 - // is guaranteed to be in the same document that carried the
2098 - // script. A client-side navigation (swup.js, PJAX, Barba, Turbo)
2099 - // can land a visitor on a page with no address at all and then
2100 - // drop .cryptx-link elements in later, without ever loading a
2101 - // second script -- the delegated handler in cryptx.js was simply
2102 - // never attached. See
2103 - // docs/entscheidungen/2026-09-11-assets-bei-clientseitiger-navigation.md
2104 - // for the analysis.
2105 - //
2106 - // Known remaining gap, not closable from here: this branch only
2107 - // reads the global "java" setting. A page whose first load carries
2108 - // no [cryptx java="1"] shortcode, under a global java = 0, still
2109 - // enqueues nothing here -- so a later client-side navigation to a
2110 - // page that DOES carry that shortcode still finds no click handler
2111 - // attached. See the decision doc above for why this is left open.
2112 - wp_enqueue_script('cryptx-js');
2113 - }
1077 + wp_enqueue_script('cryptx-js', CRYPTX_DIR_URL . 'js/cryptx.min.js', false, false, self::$cryptXOptions['load_java']);
1078 + wp_enqueue_style('cryptx-styles', CRYPTX_DIR_URL . 'css/cryptx.css');
2114 1079 }
2115 1080
2116 1081 /**
2117 - * Loads the assets that this page turned out to need.
2118 - *
2119 - * Runs late, in the footer, when every filter has done its work.
2120 - *
2121 - * @return void
2122 - */
2123 - public function enqueueAssetsIfNeeded(): void
2124 - {
2125 - if (self::$scriptNeeded) {
2126 - // Still needed as a net: a shortcode can set java=1 for its own
2127 - // instance while the global setting says java=0, since "java" is
2128 - // not in NOT_SETTABLE_BY_SHORTCODE. loadJavascriptFiles() only
2129 - // sees the global setting, so this is what catches that case. A
2130 - // second wp_enqueue_script() on an already-enqueued handle is a
2131 - // no-op.
2132 - wp_enqueue_script('cryptx-js');
2133 - }
2134 -
2135 - // wp_register_style() has no footer flag to lean on the way the
2136 - // script does, and enqueuing the stylesheet at wp_enqueue_scripts
2137 - // would move it from the footer to <head> -- a behaviour change for
2138 - // classic navigation, which is exactly what must not happen. So the
2139 - // same client-side-navigation gap for a configured picture variant
2140 - // (opt_linktext 2/3/5, the only settings that need img.cryptxImage
2141 - // { height: 1em }) is closed here instead, gated on the setting
2142 - // alone rather than on whether THIS request's content produced one.
2143 - if (self::$styleNeeded || self::isPictureLinktext((int) (self::$cryptXOptions['opt_linktext'] ?? 0))) {
2144 - wp_enqueue_style('cryptx-styles');
2145 - }
2146 - }
2147 -
2148 - /**
2149 - * Whether an opt_linktext setting renders links as pictures.
2150 - *
2151 - * Same known gap as the script branch in loadJavascriptFiles(), and purely
2152 - * cosmetic here: a page whose first load carries no picture-producing
2153 - * shortcode override, under a global opt_linktext that is not 2/3/5, still
2154 - * skips the stylesheet -- a later client-side navigation to a page that
2155 - * DOES render a picture link can arrive without img.cryptxImage. See
2156 - * docs/entscheidungen/2026-09-11-assets-bei-clientseitiger-navigation.md.
2157 - *
2158 - * @param int $optLinktext The opt_linktext setting value.
2159 - *
2160 - * @return bool
2161 - */
2162 - private static function isPictureLinktext(int $optLinktext): bool
2163 - {
2164 - return in_array($optLinktext, [2, 3, 5], true);
2165 - }
2166 -
2167 - /**
2168 1082 * Updates the CryptX settings.
2169 1083 *
2170 1084 * This method retrieves the current CryptX options from the database and checks if the version of CryptX
2171 1085 * stored in the options is less than the current version of CryptX. If the version is outdated, the method
@@ -2175,74 +1089,27 @@
2175 1089 */
2176 1090 private function updateCryptXSettings(): void
2177 1091 {
2178 1092 self::$cryptXOptions = get_option('cryptX');
2179 -
2180 - $storedVersion = self::$cryptXOptions['version'] ?? null;
2181 -
2182 - if ($storedVersion === null || version_compare(CRYPTX_VERSION, $storedVersion) <= 0) {
2183 - return;
2184 - }
2185 -
2186 - // Every step below used to run on EVERY version bump, although each was
2187 - // written for one particular upgrade. Measured on an installation
2188 - // carrying 4.1.0: the chosen font fell back to the first available one,
2189 - // the colour "#3366ff" became "##3366ff" -- gaining another "#" with
2190 - // every future update -- and the encryption secret was thrown away, so
2191 - // every link on an already cached page stopped resolving. None of that
2192 - // was intended, and none of it was visible to the site owner.
2193 - //
2194 - // Each migration is now tied to the version it belongs to, or written
2195 - // so that repeating it changes nothing.
2196 -
2197 - // Up to 4.0.11 the password was derived from AUTH_KEY and
2198 - // SECURE_AUTH_KEY, and that value is published in the markup of every
2199 - // page. Since site_url is public, an attacker could test candidate keys
2200 - // offline -- above all the placeholders from wp-config-sample.php that
2201 - // unattended installations still carry. Dropping it lets
2202 - // Config::getEncryptionPassword() mint a random one. The price is that
2203 - // links on pages already sitting in a cache stop resolving until that
2204 - // cache turns over, which is why it must happen exactly once.
2205 - if (version_compare($storedVersion, '4.0.12', '<')) {
2206 - unset(self::$cryptXOptions['encryption_password']);
2207 -
2208 - // 4.0.12 replaced the bundled Arial, Times New Roman and Verdana
2209 - // with freely licensed faces. A stored name from the old set no
2210 - // longer exists on disk, so the choice has to be made again.
2211 - unset(self::$cryptXOptions['c2i_font']);
2212 - }
2213 -
2214 - // Value-based rather than version-based, and therefore harmless to
2215 - // repeat: colours were stored without the leading "#" before 4.0.
2216 - if (!empty(self::$cryptXOptions['c2i_fontRGB'])
2217 - && strpos((string) self::$cryptXOptions['c2i_fontRGB'], '#') !== 0) {
2218 - self::$cryptXOptions['c2i_fontRGB'] = '#' . self::$cryptXOptions['c2i_fontRGB'];
2219 - }
2220 -
2221 - // Also value-based: an attachment id that is not an id is unusable, no
2222 - // matter which version wrote it.
2223 - if (isset(self::$cryptXOptions['alt_uploadedimage'])
2224 - && !is_int(self::$cryptXOptions['alt_uploadedimage'])
2225 - && !ctype_digit((string) self::$cryptXOptions['alt_uploadedimage'])) {
2226 - unset(self::$cryptXOptions['alt_uploadedimage']);
2227 -
2228 - if ((int) (self::$cryptXOptions['opt_linktext'] ?? 0) === 3) {
2229 - unset(self::$cryptXOptions['opt_linktext']);
1093 + if (isset(self::$cryptXOptions['version']) && version_compare(CRYPTX_VERSION, self::$cryptXOptions['version']) > 0) {
1094 + if (isset(self::$cryptXOptions['version'])) {
1095 + unset(self::$cryptXOptions['version']);
2230 1096 }
1097 + if (isset(self::$cryptXOptions['c2i_font'])) {
1098 + unset(self::$cryptXOptions['c2i_font']);
1099 + }
1100 + if (isset(self::$cryptXOptions['c2i_fontRGB'])) {
1101 + self::$cryptXOptions['c2i_fontRGB'] = "#" . self::$cryptXOptions['c2i_fontRGB'];
1102 + }
1103 + if (isset(self::$cryptXOptions['alt_uploadedimage']) && !is_int(self::$cryptXOptions['alt_uploadedimage'])) {
1104 + unset(self::$cryptXOptions['alt_uploadedimage']);
1105 + if (self::$cryptXOptions['opt_linktext'] == 3) {
1106 + unset(self::$cryptXOptions['opt_linktext']);
1107 + }
1108 + }
1109 + self::$cryptXOptions = wp_parse_args(self::$cryptXOptions, $this->getCryptXOptionsDefaults());
1110 + update_option('cryptX', self::$cryptXOptions);
2231 1111 }
2232 -
2233 - // Only a feature update earns a review prompt, and only in a fortnight
2234 - // -- Admin\ReviewNotice decides both, because that is where the rule
2235 - // can be read next to the reason for it. This is the only place that
2236 - // still knows which version was installed before.
2237 - self::$cryptXOptions = Admin\ReviewNotice::scheduleAfterUpdate(
2238 - self::$cryptXOptions,
2239 - (string) $storedVersion
2240 - );
2241 -
2242 - self::$cryptXOptions['version'] = CRYPTX_VERSION;
2243 - self::$cryptXOptions = wp_parse_args(self::$cryptXOptions, $this->getCryptXOptionsDefaults());
2244 - update_option('cryptX', self::$cryptXOptions);
2245 1112 }
2246 1113
2247 1114 /**
2248 1115 * Encodes a string by replacing special characters with their corresponding HTML entities.
@@ -2284,9 +1151,9 @@
2284 1151 {
2285 1152 $string = "";
2286 1153 if (!empty($args)) {
2287 1154 foreach ($args as $key => $value) {
2288 - $string .= sprintf(" %s=\"%s\"", $key, esc_attr($value));
1155 + $string .= sprintf(" %s=\"%s\"", $key, $this->encodeString($value));
2289 1156 }
2290 1157 $string .= " encoded=\"true\"";
2291 1158 }
2292 1159
@@ -2330,16 +1197,12 @@
2330 1197 * @return string The HTML link to the settings page.
2331 1198 */
2332 1199 private function create_settings_link(): string
2333 1200 {
2334 - // Admin\SettingsPage::MENU_SLUG und nicht CRYPTX_BASEFOLDER: die
2335 - // Seite haengt am Slug, nicht am Verzeichnisnamen. Auf wordpress.org
2336 - // sind beide 'cryptx', nach einem Umbenennen des Ordners zeigte der
2337 - // Link ins Leere.
2338 1201 return sprintf(
2339 - '<a href="%s">%s</a>',
2340 - esc_url(admin_url('options-general.php?page=' . Admin\SettingsPage::MENU_SLUG)),
2341 - esc_html__('Settings', 'cryptx')
1202 + '<a href="options-general.php?page=%s">%s</a>',
1203 + CRYPTX_BASEFOLDER,
1204 + __('Settings')
2342 1205 );
2343 1206 }
2344 1207
2345 1208 /**
@@ -2350,10 +1213,10 @@
2350 1213 private function create_donation_link(): string
2351 1214 {
2352 1215 return sprintf(
2353 1216 '<a href="%s">%s</a>',
2354 - esc_url(self::PAYPAL_DONATION_URL),
2355 - esc_html__('Donate', 'cryptx')
1217 + self::PAYPAL_DONATION_URL,
1218 + __('Donate', 'cryptx')
2356 1219 );
2357 1220 }
2358 1221
2359 1222 /**
@@ -2367,23 +1230,18 @@
2367 1230 add_filter('widget_display_callback', [$this, 'processWidgetContent'], 10, 3);
2368 1231 }
2369 1232
2370 1233 /**
2371 - * Processes widget content to handle email addresses by adding links, identifying occurrences,
2372 - * and replacing them based on predefined rules.
1234 + * Processes the widget content to detect and modify email addresses.
2373 1235 *
2374 - * @param array|false $instance An array containing widget instance data, or false if no instance was provided.
2375 - * @param object $widget The widget object whose content is being processed.
2376 - * @param array $args Additional arguments provided to the widget.
1236 + * @param array $instance The current widget instance settings.
1237 + * @param object $widget The widget object being processed.
1238 + * @param array $args Additional arguments passed by the widget function.
2377 1239 *
2378 - * @return array|false Modified widget instance data as an array, or false if processing was not applicable.
1240 + * @return array The modified widget instance with updated content.
2379 1241 */
2380 - public function processWidgetContent(array|false $instance, $widget, $args): array|false
1242 + public function processWidgetContent($instance, $widget, $args)
2381 1243 {
2382 - if ($instance === false) {
2383 - return false;
2384 - }
2385 -
2386 1244 // Only process if widget_text option is enabled
2387 1245 if (!(self::$cryptXOptions['widget_text'] ?? false)) {
2388 1246 return $instance;
2389 1247 }
@@ -2405,206 +1263,54 @@
2405 1263 return $instance;
2406 1264 }
2407 1265
2408 1266 /**
2409 - * Enhanced email encryption with security validation
1267 + * Generates hash using secure or legacy encryption based on settings
2410 1268 *
2411 - * @param array $searchResults
1269 + * @param string $inputString
2412 1270 * @return string
2413 1271 */
2414 - /**
2415 - * Cleans the query of a mailto link -- the "?subject=..." part.
2416 - *
2417 - * A positive list, not an exclusion list, because this value ends up
2418 - * decrypted in the browser and handed to window.location. RFC 6068 defines
2419 - * exactly these four headers as safe to accept from a link; everything else
2420 - * is dropped rather than escaped, because there is no legitimate reason for
2421 - * it to be there and no way to be sure what a mail client would do with it.
2422 - *
2423 - * Values are decoded and re-encoded rather than passed through: an incoming
2424 - * "Hallo%20Welt" must not become "Hallo%2520Welt", and a raw space must not
2425 - * stay a raw space.
2426 - *
2427 - * @param string $rawQuery The query as written in the href, without the "?".
2428 - * @param int $budget How many characters the finished query may occupy.
2429 - *
2430 - * @return string The cleaned query, or an empty string if nothing survives.
2431 - */
2432 - private function sanitizeMailtoQuery(string $rawQuery, int $budget = PHP_INT_MAX): string
1272 + private function generateSecureHashFromString(string $inputString): string
2433 1273 {
2434 - if ($rawQuery === '' || $budget <= 0) {
2435 - return '';
2436 - }
2437 -
2438 - // "&amp;" is how a second parameter is spelled in valid HTML, and that
2439 - // is what the regular expression handed us.
2440 - $rawQuery = html_entity_decode($rawQuery, ENT_QUOTES, 'UTF-8');
2441 -
2442 - $allowed = ['subject', 'body', 'cc', 'bcc'];
2443 - $parts = [];
2444 -
2445 - foreach (explode('&', $rawQuery) as $pair) {
2446 - if ($pair === '' || strpos($pair, '=') === false) {
2447 - continue;
1274 + if ($this->config->isSecureEncryptionEnabled()) {
1275 + try {
1276 + $password = $this->config->getEncryptionPassword();
1277 + return SecureEncryption::encrypt($inputString, $password);
1278 + } catch (\Exception $e) {
1279 + error_log('CryptX Secure Encryption failed: ' . $e->getMessage());
1280 + // Fallback to legacy encryption
1281 + return $this->generateHashFromString($inputString);
2448 1282 }
2449 -
2450 - [$key, $value] = explode('=', $pair, 2);
2451 - $key = strtolower(trim($key));
2452 -
2453 - if (!in_array($key, $allowed, true) || isset($parts[$key])) {
2454 - continue;
2455 - }
2456 -
2457 - $value = rawurldecode($value);
2458 -
2459 - // A recipient list is still a list of addresses, and an invalid one
2460 - // has no business being carried into a mail client.
2461 - if ($key === 'cc' || $key === 'bcc') {
2462 - $addresses = array_filter(array_map(
2463 - static fn($address) => sanitize_email(trim($address)),
2464 - explode(',', $value)
2465 - ));
2466 -
2467 - if ($addresses === []) {
2468 - continue;
2469 - }
2470 -
2471 - $value = implode(',', $addresses);
2472 - } else {
2473 - // Control characters would let a payload break out of the
2474 - // header it is written into.
2475 - $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? '';
2476 -
2477 - if (trim($value) === '') {
2478 - continue;
2479 - }
2480 -
2481 - $value = mb_substr($value, 0, self::MAX_MAILTO_VALUE_LENGTH);
2482 - }
2483 -
2484 - $pair = $this->fitPairToBudget(
2485 - $key,
2486 - $value,
2487 - // What is left once the pairs already collected, and the "&"
2488 - // that would join this one, are accounted for.
2489 - $budget - strlen(implode('&', $parts)) - ($parts === [] ? 0 : 1),
2490 - ($key === 'cc' || $key === 'bcc') ? ',' : ''
2491 - );
2492 -
2493 - if ($pair === '') {
2494 - continue;
2495 - }
2496 -
2497 - $parts[$key] = $pair;
2498 1283 }
2499 1284
2500 - return implode('&', $parts);
1285 + return $this->generateHashFromString($inputString);
2501 1286 }
2502 1287
2503 1288 /**
2504 - * Encodes one header and shortens it until it fits the space left.
1289 + * Enhanced email encryption with security validation
2505 1290 *
2506 - * The value is cut before encoding, never after: percent encoding turns one
2507 - * character into up to twelve, and a cut through "%C3%A4" leaves a sequence
2508 - * no client can read.
2509 - *
2510 - * Why there is a budget at all: cryptx.js refuses to navigate to a URL
2511 - * longer than 2048 characters, and so does SecureEncryption::validateUrl().
2512 - * Counting the value in characters before encoding is not the same measure
2513 - * -- 512 characters of Japanese become over 4000 once encoded. The link
2514 - * then did nothing at all, with nothing on the page to say why.
2515 - *
2516 - * @param string $key The header name.
2517 - * @param string $value The decoded value.
2518 - * @param int $available Characters left for the encoded pair.
2519 - * @param string $separator Set for list values: whole entries are dropped
2520 - * instead of characters.
2521 - *
2522 - * @return string The encoded pair, or an empty string if it cannot fit.
1291 + * @param array $searchResults
1292 + * @return string
2523 1293 */
2524 - private function fitPairToBudget(
2525 - string $key,
2526 - string $value,
2527 - int $available,
2528 - string $separator = ''
2529 - ): string {
2530 - $encodedKey = rawurlencode($key);
2531 -
2532 - // The shortest useful pair is "key=" plus one character.
2533 - if ($available < strlen($encodedKey) + 2) {
2534 - return '';
2535 - }
2536 -
2537 - $pair = $encodedKey . '=' . rawurlencode($value);
2538 -
2539 - // A recipient list is not free text. Cutting it by characters leaves a
2540 - // fragment like "chef@examp" in a header a mail client will act on --
2541 - // either bouncing or, worse, delivering somewhere unintended. Whole
2542 - // addresses go, or the header goes.
2543 - if ($separator !== '') {
2544 - $items = explode($separator, $value);
2545 -
2546 - while (strlen($pair) > $available && count($items) > 1) {
2547 - array_pop($items);
2548 - $pair = $encodedKey . '=' . rawurlencode(implode($separator, $items));
2549 - }
2550 -
2551 - return strlen($pair) > $available ? '' : $pair;
2552 - }
2553 -
2554 - while (strlen($pair) > $available && $value !== '') {
2555 - $value = mb_substr($value, 0, mb_strlen($value) - 1);
2556 - $pair = $encodedKey . '=' . rawurlencode($value);
2557 - }
2558 -
2559 - return $value === '' ? '' : $pair;
2560 - }
2561 -
2562 1294 private function encryptEmailAddressSecure(array $searchResults): string
2563 1295 {
2564 1296 $originalValue = $searchResults[0]; // Full match
2565 - $rawTarget = $searchResults[2]; // Everything after "mailto:", verbatim
1297 + $emailAddress = $searchResults[2]; // Email address
1298 + $linkText = $searchResults[3]; // Link text
2566 1299
2567 - // Address and query are separated BEFORE sanitising. sanitize_email()
2568 - // used to run over the whole target, and it strips "?" and "=" -- so
2569 - // "sales@example.com?subject=Hello" became
2570 - // "sales@example.comsubjectHello". Two things followed from that, both
2571 - // reported in the support forum and neither obvious: the payload
2572 - // carried a broken address, and the str_replace() below could no longer
2573 - // find its needle, so the untouched "mailto:" href stayed in the page.
2574 - $queryPosition = strpos($rawTarget, '?');
2575 - $rawAddress = $queryPosition === false ? $rawTarget : substr($rawTarget, 0, $queryPosition);
2576 - $rawQuery = $queryPosition === false ? '' : substr($rawTarget, $queryPosition + 1);
2577 -
2578 - $emailAddress = sanitize_email($rawAddress);
2579 -
2580 1300 if (strpos($emailAddress, '@') === self::NOT_FOUND) {
2581 1301 return $originalValue;
2582 1302 }
2583 1303
2584 - // Left exactly as written, link and all. "Leave this address alone"
2585 - // has to mean all three stages, not just the visible text -- an
2586 - // address that keeps its readable form but loses its working mailto is
2587 - // neither protected nor usable.
2588 - if ($this->isAddressExempt($emailAddress)) {
1304 + if (str_starts_with($emailAddress, self::SUBJECT_IDENTIFIER)) {
2589 1305 return $originalValue;
2590 1306 }
2591 1307
2592 - // The budget is what the browser will still accept once "mailto:",
2593 - // the address and the "?" are in place.
2594 - $query = $this->sanitizeMailtoQuery(
2595 - $rawQuery,
2596 - self::MAX_MAILTO_URL_LENGTH - strlen('mailto:' . $emailAddress . '?')
2597 - );
2598 - $mailtoTarget = $emailAddress . ($query === '' ? '' : '?' . $query);
2599 -
2600 1308 $return = $originalValue;
2601 1309
2602 1310 // Apply JavaScript handler if enabled
2603 1311 if (!empty(self::$cryptXOptions['java'])) {
2604 1312 $encryptionMode = $this->config->getEncryptionMode();
2605 - $payloadMode = 'legacy';
2606 - $password = '';
2607 1313
2608 1314 // Determine which encryption method to use
2609 1315 if ($encryptionMode === 'secure' &&
2610 1316 $this->config->isSecureEncryptionEnabled() &&
@@ -2612,89 +1318,42 @@
2612 1318
2613 1319 // Use modern AES-256-GCM encryption
2614 1320 try {
2615 1321 $password = $this->config->getEncryptionPassword();
2616 - $mailtoUrl = 'mailto:' . $mailtoTarget;
1322 + $mailtoUrl = 'mailto:' . $emailAddress;
2617 1323 $encryptedEmail = SecureEncryption::encrypt($mailtoUrl, $password);
2618 - $payloadMode = 'secure';
1324 +
1325 + $javaHandler = "javascript:secureDecryptAndNavigate('" .
1326 + $this->escapeJavaScript($encryptedEmail) . "', '" .
1327 + $this->escapeJavaScript($password) . "')";
2619 1328 } catch (\Exception $e) {
2620 1329 // Fallback to legacy if secure encryption fails
2621 - $encryptedEmail = $this->generateHashFromString($mailtoTarget);
2622 - $password = '';
1330 + error_log('CryptX Secure Encryption failed, falling back to legacy: ' . $e->getMessage());
1331 + $encryptedEmail = $this->generateHashFromString($emailAddress);
1332 + $javaHandler = "javascript:DeCryptX('" . $this->escapeJavaScript($encryptedEmail) . "')";
2623 1333 }
2624 1334 } else {
2625 - // Use legacy encryption (original algorithm). cryptx.js puts
2626 - // "mailto:" in front of whatever comes out, so the query rides
2627 - // along here as well.
2628 - $encryptedEmail = $this->generateHashFromString($mailtoTarget);
1335 + // Use legacy encryption (original algorithm)
1336 + $encryptedEmail = $this->generateHashFromString($emailAddress);
1337 + $javaHandler = "javascript:DeCryptX('" . $this->escapeJavaScript($encryptedEmail) . "')";
2629 1338 }
2630 1339
2631 - self::$scriptNeeded = true;
2632 -
2633 - if ($this->getLinkMode() === 'data') {
2634 - // Preferred form: the payload travels in data attributes and a
2635 - // delegated click handler in cryptx.js does the work. A
2636 - // "javascript:" URI would be blocked outright by any halfway
2637 - // strict Content-Security-Policy, taking every CryptX link on
2638 - // the page with it -- silently.
2639 - $attributes = sprintf(
2640 - ' data-cx="%s" data-cxm="%s"',
2641 - esc_attr($encryptedEmail),
2642 - esc_attr($payloadMode)
2643 - );
2644 - if ($payloadMode === 'secure') {
2645 - // The key travels with the link, so changing the secret
2646 - // never breaks one that is already out there. The iteration
2647 - // count did not, and that was the real dead-link problem:
2648 - // it was read from the global cryptxConfig at click time,
2649 - // so raising it in the settings silently killed every link
2650 - // in every cached page and in every browser tab still open.
2651 - // Now each link says how it was made.
2652 - $attributes .= sprintf(
2653 - ' data-cxk="%s" data-cxi="%d"',
2654 - esc_attr($password),
2655 - SecureEncryption::getIterations()
2656 - );
2657 - }
2658 -
2659 - // The raw target, not the sanitised address: they differ as
2660 - // soon as a query is present, and a needle that is not in the
2661 - // haystack leaves the plain "mailto:" href untouched.
2662 - //
2663 - // str_ireplace, because the pattern above matches case
2664 - // insensitively: an href written "MAILTO:" was found, but a
2665 - // lower-case needle then missed it -- same failure, reached
2666 - // through the spelling of the scheme instead of the query.
2667 - $return = str_ireplace('mailto:' . $rawTarget, '#', $originalValue);
2668 - $return = $this->addAttributesToAnchor($return, $attributes);
2669 - $return = $this->addClassToAnchor($return, self::LINK_CLASS);
2670 - } else {
2671 - // Legacy form, kept for installations that depend on it.
2672 - // The iteration count is passed here too, as a third argument.
2673 - // Older pages call the function with two, which still works --
2674 - // it then falls back to the configured value, exactly as before.
2675 - $javaHandler = $payloadMode === 'secure'
2676 - ? "javascript:secureDecryptAndNavigate('" . esc_js($encryptedEmail) . "', '"
2677 - . esc_js($password) . "', " . SecureEncryption::getIterations() . ")"
2678 - : "javascript:DeCryptX('" . esc_js($encryptedEmail) . "')";
2679 -
2680 - $return = str_ireplace('mailto:' . $rawTarget, $javaHandler, $originalValue);
2681 - }
1340 + $return = str_replace('mailto:' . $emailAddress, $javaHandler, $originalValue);
2682 1341 } else {
2683 1342 // Fallback to antispambot if JavaScript is not enabled
2684 - $return = str_ireplace('mailto:' . $rawTarget,
2685 - antispambot('mailto:' . $mailtoTarget), $return);
1343 + $return = str_replace('mailto:' . $emailAddress,
1344 + antispambot('mailto:' . $emailAddress), $return);
2686 1345 }
2687 1346
2688 1347 // Add CSS attributes if specified
2689 1348 if (!empty(self::$cryptXOptions['css_id'])) {
2690 - // Guarded like every other preg_* call site in this class: a PCRE
2691 - // error yields null, and $return is declared string.
2692 - $return = $this->addIdToAnchor($return, self::$cryptXOptions['css_id']);
1349 + $return = preg_replace('/(<a\s+[^>]*)(>)/i',
1350 + '$1 id="' . self::$cryptXOptions['css_id'] . '"$2', $return);
2693 1351 }
2694 1352
2695 1353 if (!empty(self::$cryptXOptions['css_class'])) {
2696 - $return = $this->addClassToAnchor($return, self::$cryptXOptions['css_class']);
1354 + $return = preg_replace('/(<a\s+[^>]*)(>)/i',
1355 + '$1 class="' . self::$cryptXOptions['css_class'] . '"$2', $return);
2697 1356 }
2698 1357
2699 1358 return $return;
2700 1359 }
@@ -2699,214 +1358,30 @@
2699 1358 return $return;
2700 1359 }
2701 1360
2702 1361 /**
2703 - * Runs a sample through the real processing chain for the settings preview.
1362 + * Escapes string for safe JavaScript usage
2704 1363 *
2705 - * Deliberately not a reimplementation: the preview calls the same three
2706 - * filters the front end calls, with the same encryption. A separate
2707 - * "preview renderer" would drift away from the truth sooner or later, and
2708 - * a preview that lies is worse than none.
2709 - *
2710 - * Nothing is written. Both the static option list and the Config instance
2711 - * are swapped for the duration and restored in a finally block -- Config
2712 - * matters because the encryption path reads its mode and password from
2713 - * there, not from the static list.
2714 - *
2715 - * @param array $overrides Option values as they stand in the unsaved form.
2716 - * @param string $content The sample content.
2717 - *
2718 - * @return string The processed markup.
1364 + * @param string $string
1365 + * @return string
2719 1366 */
2720 - public function renderPreviewMarkup(array $overrides, string $content): string
1367 + private function escapeJavaScript(string $string): string
2721 1368 {
2722 - $previousOptions = self::$cryptXOptions;
2723 - $previousConfig = $this->config;
2724 -
2725 - // Make sure a secret exists before the swap, and mint it through the
2726 - // REAL Config if it does not.
2727 - //
2728 - // Config::getEncryptionPassword() writes when it has to mint, and
2729 - // Config::save() stores the whole option array -- which, on the
2730 - // throwaway Config below, is the administrator's unsaved form state.
2731 - // A preview would then silently persist settings that were only being
2732 - // tried out. The window is real: updateCryptXSettings() drops the
2733 - // secret on every version bump, and the settings screen is the first
2734 - // place an administrator goes after an update.
2735 - $stored = $this->loadCryptXOptionsWithDefaults();
2736 -
2737 - if (empty($stored['encryption_password'])) {
2738 - // Mint through a Config built from the STORED options, and carry the
2739 - // result into $merged by hand.
2740 - //
2741 - // Doing it through the live Config instead was not enough: that one
2742 - // holds an in-memory copy taken at startup, so it can believe it has
2743 - // a password while the row no longer does. It then writes nothing,
2744 - // $merged is still without a secret, and the throwaway Config below
2745 - // mints -- persisting the unsaved form along with it. A test that
2746 - // watches pre_update_option_cryptX found exactly that.
2747 - $stored['encryption_password'] = (new Config($stored))->getEncryptionPassword();
2748 - }
2749 -
2750 - // Same reasoning, same trap, second secret. The image variant mints one
2751 - // of its own the first time an address is drawn as a picture -- and the
2752 - // first time that happens is usually in this very preview, the moment
2753 - // an administrator picks "image" from the list. Minting it through the
2754 - // throwaway Config below would save the unsaved form along with it.
2755 - if (empty($stored['image_token_secret'])) {
2756 - $stored['image_token_secret'] = (new Config($stored))->getImageTokenSecret();
2757 - }
2758 -
2759 - $merged = wp_parse_args($overrides, $stored);
2760 -
2761 - self::$cryptXOptions = $merged;
2762 - $this->config = new Config($merged);
2763 - self::resetOptionCaches();
2764 -
2765 - try {
2766 - if (!empty(self::$cryptXOptions['autolink'])) {
2767 - $content = $this->addLinkToEmailAddresses($content, true);
2768 - }
2769 -
2770 - $content = $this->findEmailAddressesInContent($content, true);
2771 -
2772 - return (string) $this->replaceEmailInContent($content, true);
2773 - } finally {
2774 - self::$cryptXOptions = $previousOptions;
2775 - $this->config = $previousConfig;
2776 - self::resetOptionCaches();
2777 - }
2778 - }
2779 -
2780 - /**
2781 - * Which link form the encrypted address is delivered in.
2782 - *
2783 - * 'data' puts the payload into data attributes and lets a delegated click
2784 - * handler take over -- the only form that survives a Content-Security-Policy.
2785 - * 'js' is the historical "javascript:" URI, offered under Advanced for
2786 - * installations that depend on the old behaviour.
2787 - *
2788 - * @return string Either 'data' or 'js'.
2789 - */
2790 - private function getLinkMode(): string
2791 - {
2792 - $mode = (string) (self::$cryptXOptions['link_mode'] ?? 'data');
2793 -
2794 - return $mode === 'js' ? 'js' : 'data';
2795 - }
2796 -
2797 - /**
2798 - * Inserts additional attributes into the opening tag of an anchor.
2799 - *
2800 - * @param string $html The anchor markup.
2801 - * @param string $attributes Attribute string, starting with a space.
2802 - *
2803 - * @return string The markup with the attributes added.
2804 - */
2805 - private function addAttributesToAnchor(string $html, string $attributes): string
2806 - {
2807 - return $this->rewriteOpeningAnchorTag(
2808 - $html,
2809 - static fn(string $tag): string => preg_replace('/(\s*\/?>)$/', $attributes . '$1', $tag, 1) ?? $tag
1369 + return str_replace(
1370 + ['\\', "'", '"', "\n", "\r", "\t"],
1371 + ['\\\\', "\\'", '\\"', '\\n', '\\r', '\\t'],
1372 + $string
2810 1373 );
2811 1374 }
2812 1375
2813 1376 /**
2814 - * Adds a class to an anchor, keeping any class that is already there.
1377 + * Secure URL validation
2815 1378 *
2816 - * @param string $html The anchor markup.
2817 - * @param string $class The class to add.
2818 - *
2819 - * @return string The markup with the class added.
1379 + * @param string $url
1380 + * @return bool
2820 1381 */
2821 - private function addClassToAnchor(string $html, string $class): string
1382 + private function isValidUrl(string $url): bool
2822 1383 {
2823 - $class = esc_attr($class);
2824 -
2825 - return $this->rewriteOpeningAnchorTag(
2826 - $html,
2827 - function (string $tag) use ($class): string {
2828 - // (?:^|\s) rather than \b: a word boundary also sits
2829 - // between the quote and the "c" of an attribute value such
2830 - // as data-x="class='y'", so \bclass would bind to the text
2831 - // inside that value. Requiring whitespace before the name
2832 - // makes this an attribute rather than any occurrence of the
2833 - // word -- and it holds no matter which attribute comes
2834 - // first, which the greedy and the lazy variant each got
2835 - // wrong in one of the two orders.
2836 - if (preg_match('/(?:^|\s)class\s*=\s*(["\'])(.*?)\1/i', $tag)) {
2837 - return preg_replace(
2838 - '/((?:^|\s)class\s*=\s*(["\']))(.*?)\2/i',
2839 - '$1$3 ' . $class . '$2',
2840 - $tag,
2841 - 1
2842 - ) ?? $tag;
2843 - }
2844 -
2845 - return preg_replace('/(\s*\/?>)$/', ' class="' . $class . '"$1', $tag, 1) ?? $tag;
2846 - }
2847 - );
2848 - }
2849 -
2850 - /**
2851 - * Applies a rewrite to the opening tag of the first anchor only.
2852 - *
2853 - * Regular expressions on HTML are a poor tool, and this is the narrow case
2854 - * where it is still defensible: the markup comes from CryptX's own mailto
2855 - * pattern, so there is exactly one anchor and the payload is escaped before
2856 - * it gets here. Isolating the opening tag keeps the rewrite from reaching
2857 - * into attribute values or into the link text.
2858 - *
2859 - * @param string $html The anchor markup.
2860 - * @param callable $rewrite Receives the opening tag, returns the new one.
2861 - *
2862 - * @return string The markup with the rewritten opening tag.
2863 - */
2864 - private function rewriteOpeningAnchorTag(string $html, callable $rewrite): string
2865 - {
2866 - // Quoted attribute values may legitimately contain ">", so a plain
2867 - // [^>]* would end the tag too early and splice the new attribute into
2868 - // the middle of somebody else's title.
2869 - $openingTag = '/<a\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/i';
2870 -
2871 - if (!preg_match($openingTag, $html, $matches, PREG_OFFSET_CAPTURE)) {
2872 - return $html;
2873 - }
2874 -
2875 - $tag = $matches[0][0];
2876 - $offset = $matches[0][1];
2877 - $rewritten = $rewrite($tag);
2878 -
2879 - return substr($html, 0, $offset) . $rewritten . substr($html, $offset + strlen($tag));
2880 - }
2881 -
2882 - /**
2883 - * Adds an id to an anchor, keeping any id that is already there.
2884 - *
2885 - * @param string $html The anchor markup.
2886 - * @param string $id The id to add.
2887 - *
2888 - * @return string The markup with the id added.
2889 - */
2890 - private function addIdToAnchor(string $html, string $id): string
2891 - {
2892 - $id = esc_attr($id);
2893 -
2894 - return $this->rewriteOpeningAnchorTag(
2895 - $html,
2896 - function (string $tag) use ($id): string {
2897 - // Same reasoning as in addClassToAnchor().
2898 - if (preg_match('/(?:^|\s)id\s*=\s*(["\'])(.*?)\1/i', $tag)) {
2899 - return preg_replace(
2900 - '/((?:^|\s)id\s*=\s*(["\']))(.*?)\2/i',
2901 - '$1$3 ' . $id . '$2',
2902 - $tag,
2903 - 1
2904 - ) ?? $tag;
2905 - }
2906 -
2907 - return preg_replace('/(\s*\/?>)$/', ' id="' . $id . '"$1', $tag, 1) ?? $tag;
2908 - }
2909 - );
1384 + return SecureEncryption::validateUrl($url);
2910 1385 }
2911 1386
2912 1387 }