| @@ -37,16 +37,47 @@ | ||
| 37 | 37 | */ |
| 38 | 38 | private const MAILTO_ATTRIBUTES = ['subject', 'body', 'cc', 'bcc']; |
| 39 | 39 | |
| 40 | 40 | /** |
| 41 | - * Filters after which WordPress expands shortcodes. | |
| 41 | + * The filters whose content a visitor wrote, not the site owner. | |
| 42 | 42 | * |
| 43 | - * Measured, not assumed: has_filter($name, 'do_shortcode') is 11 for these | |
| 44 | - * four and false for the other five CryptX hangs on. Only here may an | |
| 45 | - * unexpanded [cryptx] be set aside, because only here does something come | |
| 46 | - * along afterwards to deal with it. | |
| 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. | |
| 47 | 52 | */ |
| 53 | + private const VISITOR_WRITTEN_FILTERS = ['comment_text', 'comment_text_rss']; | |
| 54 | + | |
| 48 | 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 | + /** | |
| 49 | 80 | * The feed counterpart of each content filter. |
| 50 | 81 | * |
| 51 | 82 | * WordPress builds a feed from its own filters, not from the ones that |
| 52 | 83 | * render a page: <description> comes from 'the_excerpt_rss', |
| @@ -57,8 +88,16 @@ | ||
| 57 | 88 | 'the_excerpt' => 'the_excerpt_rss', |
| 58 | 89 | 'comment_text' => 'comment_text_rss', |
| 59 | 90 | ]; |
| 60 | 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 | + */ | |
| 61 | 100 | private const SHORTCODE_EXPANDED_AFTER = [ |
| 62 | 101 | 'the_content', |
| 63 | 102 | 'render_block', |
| 64 | 103 | 'widget_text_content', |
| @@ -66,8 +105,18 @@ | ||
| 66 | 105 | ]; |
| 67 | 106 | const ASCII_VALUES_BLACKLIST = ['32', '34', '39', '60', '62', '63', '92', '94', '96', '127']; |
| 68 | 107 | /** Upper bound for the text rendered into a PNG, see cryptXtinyUrl(). */ |
| 69 | 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; | |
| 70 | 119 | private static ?self $instance = null; |
| 71 | 120 | private static array $cryptXOptions = []; |
| 72 | 121 | private static int $imageCounter = 0; |
| 73 | 122 | |
| @@ -93,17 +142,35 @@ | ||
| 93 | 142 | * the whitelist, for every single address found. |
| 94 | 143 | */ |
| 95 | 144 | private static ?array $excludedIdCache = null; |
| 96 | 145 | private static ?array $whiteListCache = null; |
| 146 | + private static ?array $exemptAddressCache = null; | |
| 97 | 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 | + | |
| 98 | 159 | private const FONT_EXTENSION = 'ttf'; |
| 99 | 160 | private const PAYPAL_DONATION_URL = 'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=4026696'; |
| 100 | 161 | private Admin\SettingsPage $settingsPage; |
| 162 | + private Admin\SiteHealth $siteHealth; | |
| 163 | + private Admin\ReviewNotice $reviewNotice; | |
| 164 | + private Block $block; | |
| 101 | 165 | private Config $config; |
| 102 | 166 | |
| 103 | 167 | private function __construct() |
| 104 | 168 | { |
| 105 | 169 | $this->settingsPage = new Admin\SettingsPage(); |
| 170 | + $this->siteHealth = new Admin\SiteHealth(); | |
| 171 | + $this->reviewNotice = new Admin\ReviewNotice(); | |
| 172 | + $this->block = new Block(); | |
| 106 | 173 | $this->config = new Config(get_option('cryptX', [])); |
| 107 | 174 | self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); |
| 108 | 175 | } |
| 109 | 176 | |
| @@ -142,8 +209,11 @@ | ||
| 142 | 209 | // The settings screen registers its own menu entry and REST routes. |
| 143 | 210 | // Doing it here rather than in the constructor keeps the hooks out of |
| 144 | 211 | // object construction, where they are easy to trigger by accident. |
| 145 | 212 | $this->settingsPage->register(); |
| 213 | + $this->siteHealth->register(); | |
| 214 | + $this->reviewNotice->register(); | |
| 215 | + $this->block->register(); | |
| 146 | 216 | |
| 147 | 217 | $this->checkAndUpdateVersion(); |
| 148 | 218 | $this->addUniversalWidgetFilters(); // Add this line |
| 149 | 219 | $this->initializePluginFilters(); |
| @@ -203,11 +273,39 @@ | ||
| 203 | 273 | */ |
| 204 | 274 | private function checkAndUpdateVersion(): void |
| 205 | 275 | { |
| 206 | 276 | $currentVersion = self::$cryptXOptions['version'] ?? null; |
| 277 | + | |
| 207 | 278 | if ($currentVersion && version_compare(CRYPTX_VERSION, $currentVersion) > 0) { |
| 208 | 279 | $this->updateCryptXSettings(); |
| 280 | + | |
| 281 | + return; | |
| 209 | 282 | } |
| 283 | + | |
| 284 | + if ($currentVersion) { | |
| 285 | + return; | |
| 286 | + } | |
| 287 | + | |
| 288 | + // No stamp at all, and the option nevertheless exists. That is not the | |
| 289 | + // fresh install it looks like: Config::save() writes the whole option | |
| 290 | + // array whenever it has to mint a secret, and on a front-end request | |
| 291 | + // that happens without installCryptX() ever running -- so the row is | |
| 292 | + // created with 'version' => null, from Config::DEFAULT_OPTIONS. | |
| 293 | + // | |
| 294 | + // Left alone, that state is permanent. updateCryptXSettings() treats a | |
| 295 | + // null version as "nothing to migrate" and returns, and nothing else | |
| 296 | + // ever writes the stamp, so every future migration is skipped in | |
| 297 | + // silence. Stamping it here costs one write, once. | |
| 298 | + // | |
| 299 | + // Stamping rather than migrating is the right half: a null version | |
| 300 | + // means there is no earlier CryptX data to bring forward, which is | |
| 301 | + // exactly the case the migrations already decline to handle. | |
| 302 | + if (get_option('cryptX', null) === null) { | |
| 303 | + return; | |
| 304 | + } | |
| 305 | + | |
| 306 | + self::$cryptXOptions['version'] = CRYPTX_VERSION; | |
| 307 | + update_option('cryptX', self::$cryptXOptions); | |
| 210 | 308 | } |
| 211 | 309 | |
| 212 | 310 | /** |
| 213 | 311 | * Initializes and registers plugin filters based on the configuration settings. |
| @@ -455,14 +553,39 @@ | ||
| 455 | 553 | // Where the shortcode is not going to be expanded, the literal |
| 456 | 554 | // "[cryptx]" stays visible in the output and the address inside it is |
| 457 | 555 | // obfuscated like any other. Ugly, and the same as before -- but the |
| 458 | 556 | // address is covered. |
| 459 | - if (stripos($content, '[cryptx') === false | |
| 460 | - || !in_array(current_filter(), self::SHORTCODE_EXPANDED_AFTER, true)) { | |
| 557 | + if (stripos($content, '[cryptx') === false) { | |
| 461 | 558 | return $process($content); |
| 462 | 559 | } |
| 463 | 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 | + | |
| 464 | 586 | $store = []; |
| 587 | + $prefix = $this->maskingPrefix('sc'); | |
| 465 | 588 | |
| 466 | 589 | // WordPress' own idea of what a shortcode looks like, rather than a |
| 467 | 590 | // hand-rolled one: it knows the self-closing form, the enclosing form |
| 468 | 591 | // and -- the reason this matters below -- the escaped form. |
| @@ -469,9 +592,9 @@ | ||
| 469 | 592 | $pattern = '/' . get_shortcode_regex(['cryptx']) . '/s'; |
| 470 | 593 | |
| 471 | 594 | $masked = preg_replace_callback( |
| 472 | 595 | $pattern, |
| 473 | - static function (array $match) use (&$store): string { | |
| 596 | + static function (array $match) use (&$store, $prefix): string { | |
| 474 | 597 | // "[[cryptx]...[/cryptx]]" is how a page shows a shortcode |
| 475 | 598 | // instead of running it -- an instructions page explaining |
| 476 | 599 | // CryptX, typically. do_shortcode() deliberately leaves it as |
| 477 | 600 | // text, so masking it would carry the address straight through |
| @@ -483,9 +606,9 @@ | ||
| 483 | 606 | } |
| 484 | 607 | |
| 485 | 608 | $store[] = $match[0]; |
| 486 | 609 | |
| 487 | - return sprintf('<!--cryptx:%d-->', count($store) - 1); | |
| 610 | + return sprintf('<!--%s:%d-->', $prefix, count($store) - 1); | |
| 488 | 611 | }, |
| 489 | 612 | $content |
| 490 | 613 | ); |
| 491 | 614 | |
| @@ -511,9 +634,9 @@ | ||
| 511 | 634 | $store = array_map('do_shortcode', $store); |
| 512 | 635 | } |
| 513 | 636 | |
| 514 | 637 | $tokens = array_map( |
| 515 | - static fn(int $index): string => sprintf('<!--cryptx:%d-->', $index), | |
| 638 | + static fn(int $index): string => sprintf('<!--%s:%d-->', $prefix, $index), | |
| 516 | 639 | array_keys($store) |
| 517 | 640 | ); |
| 518 | 641 | |
| 519 | 642 | return str_replace($tokens, $store, $result); |
| @@ -618,16 +741,35 @@ | ||
| 618 | 741 | $attributes = array_diff_key($attributes, array_flip(self::MAILTO_ATTRIBUTES)); |
| 619 | 742 | |
| 620 | 743 | // Update options if attributes provided |
| 621 | 744 | if (!empty($attributes)) { |
| 622 | - self::$cryptXOptions = shortcode_atts( | |
| 745 | + // shortcode_atts() keeps whatever is in the defaults it is given, | |
| 746 | + // so the option array decides which attribute names have an effect | |
| 747 | + // -- and the option array holds the two secrets. Nothing reads them | |
| 748 | + // from here today (both go through Config), so this changes no | |
| 749 | + // behaviour; it is here so that the next person to reach for | |
| 750 | + // self::$cryptXOptions cannot accidentally make key material | |
| 751 | + // settable by anyone who may write a post. | |
| 752 | + $overridable = array_diff_key( | |
| 753 | + $this->loadCryptXOptionsWithDefaults(), | |
| 754 | + array_flip(self::NOT_SETTABLE_BY_SHORTCODE) | |
| 755 | + ); | |
| 756 | + | |
| 757 | + self::$cryptXOptions = array_merge( | |
| 758 | + array_intersect_key( | |
| 623 | 759 | $this->loadCryptXOptionsWithDefaults(), |
| 624 | - $attributes, | |
| 625 | - $tag | |
| 760 | + array_flip(self::NOT_SETTABLE_BY_SHORTCODE) | |
| 761 | + ), | |
| 762 | + shortcode_atts($overridable, $attributes, $tag) | |
| 626 | 763 | ); |
| 627 | 764 | self::resetOptionCaches(); |
| 628 | 765 | } |
| 629 | 766 | |
| 767 | + // Saved and restored rather than set to false at the end: should this | |
| 768 | + // ever run nested, the outer shortcode must keep its own state. | |
| 769 | + $wasInShortcode = $this->inShortcode; | |
| 770 | + $this->inShortcode = true; | |
| 771 | + | |
| 630 | 772 | try { |
| 631 | 773 | // Process content (inline the encryptAndLinkContent logic) |
| 632 | 774 | if (self::$cryptXOptions['autolink'] ?? false) { |
| 633 | 775 | $content = $this->addLinkToEmailAddresses($content, true); |
| @@ -645,8 +787,9 @@ | ||
| 645 | 787 | } finally { |
| 646 | 788 | // Restored in a finally block: self::$cryptXOptions is static, so |
| 647 | 789 | // an exception escaping from here would leave the shortcode's |
| 648 | 790 | // values in place for the rest of the request. |
| 791 | + $this->inShortcode = $wasInShortcode; | |
| 649 | 792 | self::$cryptXOptions = $this->loadCryptXOptionsWithDefaults(); |
| 650 | 793 | self::resetOptionCaches(); |
| 651 | 794 | } |
| 652 | 795 | |
| @@ -710,16 +853,58 @@ | ||
| 710 | 853 | if (!is_file($font) || !is_readable($font)) { |
| 711 | 854 | return; |
| 712 | 855 | } |
| 713 | 856 | |
| 714 | - // The text comes straight from the URL. Without a bound, a long request | |
| 715 | - // would size the canvas up accordingly and exhaust the memory limit -- | |
| 716 | - // a cheap denial of service. No address is anywhere near this long. | |
| 717 | - $msg = substr(rawurldecode($params[count($params) - 1]), 0, self::MAX_IMAGE_TEXT_LENGTH); | |
| 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 | + | |
| 718 | 873 | if ($msg === '') { |
| 719 | - return; | |
| 874 | + // No token: either an URL from a page cached before the update, or | |
| 875 | + // somebody asking for arbitrary text to be drawn. Pages cached | |
| 876 | + // before the update carry the address entity-encoded, and dropping | |
| 877 | + // them would leave a broken image where an address should be for as | |
| 878 | + // long as the cache lives -- so they are still served. | |
| 879 | + // | |
| 880 | + // But only if what they ask for really is an address. That is the | |
| 881 | + // difference to before: this endpoint used to draw whatever text a | |
| 882 | + // request named, which made it a picture generator for anyone who | |
| 883 | + // found it. The old form stays workable, the abuse does not. | |
| 884 | + // | |
| 885 | + // The entity decoding is belt and braces with no path to it, and | |
| 886 | + // that is worth saying so nobody later mistakes it for a tested | |
| 887 | + // guarantee: a browser resolves the entities before it makes the | |
| 888 | + // request, so what arrives here is the plain address. The encoded | |
| 889 | + // form cannot even reach this line -- sanitize_text_field() above | |
| 890 | + // strips percent sequences, and an unencoded "#" is cut off as a | |
| 891 | + // fragment. It stays because it costs nothing and would carry a | |
| 892 | + // proxy that did deliver the encoded form. | |
| 893 | + $decoded = html_entity_decode($requested, ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 894 | + | |
| 895 | + if (!Exposure::isAddress($decoded)) { | |
| 896 | + return; | |
| 897 | + } | |
| 898 | + | |
| 899 | + $msg = $decoded; | |
| 720 | 900 | } |
| 721 | 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 | + | |
| 722 | 907 | $size = (int) (self::$cryptXOptions['c2i_fontSize'] ?? 10); |
| 723 | 908 | $size = max(1, min(96, $size)); |
| 724 | 909 | |
| 725 | 910 | $rgb = ltrim((string) (self::$cryptXOptions['c2i_fontRGB'] ?? '#000000'), '#'); |
| @@ -828,8 +1013,20 @@ | ||
| 828 | 1013 | { |
| 829 | 1014 | $widgetFilters = $this->config->getWidgetFilters(); |
| 830 | 1015 | |
| 831 | 1016 | foreach ($widgetFilters as $widgetFilter) { |
| 1017 | + // No isAutolinkEnabled() check here, unlike the branch above that | |
| 1018 | + // handles every other filter -- so switching autolink off leaves it | |
| 1019 | + // on in widgets. Measured, not assumed: with autolink=0, | |
| 1020 | + // has_filter() is false for the_content, the_excerpt and | |
| 1021 | + // comment_text and true for all three widget filters. | |
| 1022 | + // | |
| 1023 | + // Left as it is on purpose. The difference errs towards protection: | |
| 1024 | + // a plain address in a sidebar is linked and encrypted rather than | |
| 1025 | + // left readable. Honouring the setting here would mean an update | |
| 1026 | + // that makes addresses readable on sites that never asked for that, | |
| 1027 | + // which is the one direction this plugin must not move in silently. | |
| 1028 | + // The setting's help text says so instead. | |
| 832 | 1029 | $this->addAutoLinkFilters($widgetFilter, 11); |
| 833 | 1030 | $this->addOtherFilters($widgetFilter); |
| 834 | 1031 | } |
| 835 | 1032 | } |
| @@ -866,8 +1063,9 @@ | ||
| 866 | 1063 | private static function resetOptionCaches(): void |
| 867 | 1064 | { |
| 868 | 1065 | self::$excludedIdCache = null; |
| 869 | 1066 | self::$whiteListCache = null; |
| 1067 | + self::$exemptAddressCache = null; | |
| 870 | 1068 | } |
| 871 | 1069 | |
| 872 | 1070 | /** |
| 873 | 1071 | * Replaces email addresses in content with link texts. |
| @@ -935,9 +1133,9 @@ | ||
| 935 | 1133 | * @return string The encoded link text. |
| 936 | 1134 | */ |
| 937 | 1135 | private function encodeEmailToLinkText(array $Match): string |
| 938 | 1136 | { |
| 939 | - if ($this->inWhiteList($Match)) { | |
| 1137 | + if ($this->inWhiteList($Match) || $this->isAddressExempt($Match[1])) { | |
| 940 | 1138 | return $Match[1]; |
| 941 | 1139 | } |
| 942 | 1140 | switch (self::$cryptXOptions['opt_linktext']) { |
| 943 | 1141 | case 1: |
| @@ -967,8 +1165,165 @@ | ||
| 967 | 1165 | return $text; |
| 968 | 1166 | } |
| 969 | 1167 | |
| 970 | 1168 | /** |
| 1169 | + * A placeholder that content cannot forge. | |
| 1170 | + * | |
| 1171 | + * The masking steps set a piece of content aside, run something over the | |
| 1172 | + * rest, and put it back by searching for the placeholder they left. With a | |
| 1173 | + * fixed placeholder that search cannot tell its own marker from one an | |
| 1174 | + * author typed: a post explaining CryptX, a code example, or a comment | |
| 1175 | + * written by a stranger. Whoever wrote it got the stored value substituted | |
| 1176 | + * into their text -- an address they never wrote, appearing in their post. | |
| 1177 | + * | |
| 1178 | + * Nothing could be injected that way, because the store only ever holds | |
| 1179 | + * matches of the address pattern and those cannot contain a markup | |
| 1180 | + * character. But it altered content, and content nobody typed is a bug | |
| 1181 | + * whatever its contents. The random part per call closes it: the author | |
| 1182 | + * cannot write a placeholder that this call will look for. | |
| 1183 | + * | |
| 1184 | + * @param string $kind Distinguishes the two masking steps. | |
| 1185 | + * | |
| 1186 | + * @return string The prefix, unique to this call. | |
| 1187 | + */ | |
| 1188 | + private function maskingPrefix(string $kind): string | |
| 1189 | + { | |
| 1190 | + try { | |
| 1191 | + $nonce = bin2hex(random_bytes(8)); | |
| 1192 | + } catch (\Exception $e) { | |
| 1193 | + // Only reachable when the platform has no source of randomness at | |
| 1194 | + // all. Falling back keeps the page rendering; wp_rand() is seeded | |
| 1195 | + // well enough for a marker that lives for one request. | |
| 1196 | + $nonce = dechex(wp_rand(0, PHP_INT_MAX)) . dechex(wp_rand(0, PHP_INT_MAX)); | |
| 1197 | + } | |
| 1198 | + | |
| 1199 | + return 'cryptx-' . $kind . '-' . $nonce; | |
| 1200 | + } | |
| 1201 | + | |
| 1202 | + /** | |
| 1203 | + * Runs a step with the exempt addresses masked out of the content. | |
| 1204 | + * | |
| 1205 | + * @param string $content The content. | |
| 1206 | + * @param callable $process Receives the masked content, returns the result. | |
| 1207 | + * | |
| 1208 | + * @return string The processed content, addresses back in place. | |
| 1209 | + */ | |
| 1210 | + private function withExemptAddressesProtected(string $content, callable $process): string | |
| 1211 | + { | |
| 1212 | + if (strpos($content, '@') === false) { | |
| 1213 | + return $process($content); | |
| 1214 | + } | |
| 1215 | + | |
| 1216 | + $store = []; | |
| 1217 | + $prefix = $this->maskingPrefix('keep'); | |
| 1218 | + | |
| 1219 | + $masked = preg_replace_callback( | |
| 1220 | + '/[_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,})/', | |
| 1221 | + function (array $match) use (&$store, $prefix): string { | |
| 1222 | + if (!$this->isAddressExempt($match[0])) { | |
| 1223 | + return $match[0]; | |
| 1224 | + } | |
| 1225 | + | |
| 1226 | + $store[] = $match[0]; | |
| 1227 | + | |
| 1228 | + // No "@" in the token, so none of the address patterns can see | |
| 1229 | + // it, and no square brackets, so a shortcode cannot be torn | |
| 1230 | + // apart by it either. | |
| 1231 | + return sprintf('<!--%s:%d-->', $prefix, count($store) - 1); | |
| 1232 | + }, | |
| 1233 | + $content | |
| 1234 | + ); | |
| 1235 | + | |
| 1236 | + if ($masked === null) { | |
| 1237 | + return $process($content); | |
| 1238 | + } | |
| 1239 | + | |
| 1240 | + $result = $process($masked); | |
| 1241 | + | |
| 1242 | + $tokens = array_map( | |
| 1243 | + static fn(int $index): string => sprintf('<!--%s:%d-->', $prefix, $index), | |
| 1244 | + array_keys($store) | |
| 1245 | + ); | |
| 1246 | + | |
| 1247 | + return str_replace($tokens, $store, $result); | |
| 1248 | + } | |
| 1249 | + | |
| 1250 | + /** | |
| 1251 | + * Whether an address is one the site owner asked CryptX to leave alone. | |
| 1252 | + * | |
| 1253 | + * The endings list next door answers a different question -- it keeps | |
| 1254 | + * "logo@2x.png" from being mistaken for an address at all. This one is | |
| 1255 | + * about real addresses that are meant to stay readable: a support address | |
| 1256 | + * a helpdesk parses out of the page, an address in a code example, an | |
| 1257 | + * address a partner site scrapes on purpose. Until now the answer was "not | |
| 1258 | + * possible", and the FAQ said so. | |
| 1259 | + * | |
| 1260 | + * An entry is either a whole address, or "@example.com" for every address | |
| 1261 | + * at that domain. The domain form is the common case: a site tends to want | |
| 1262 | + * its own addresses treated alike. | |
| 1263 | + * | |
| 1264 | + * Two places deliberately do not honour the list, both for the same | |
| 1265 | + * reason -- a blanket setting must not overrule a narrower instruction: | |
| 1266 | + * | |
| 1267 | + * Inside "[cryptx]...[/cryptx]" nothing is exempt. The shortcode is | |
| 1268 | + * somebody writing "protect this one, here"; a list entry set months ago on | |
| 1269 | + * another screen is not an answer to that. The reverse order let a site | |
| 1270 | + * that had exempted its own domain publish, in the clear, exactly the | |
| 1271 | + * address it had wrapped in a shortcode to protect. | |
| 1272 | + * | |
| 1273 | + * In comments only the whole-address form counts. Comments are written by | |
| 1274 | + * strangers, and "@example.com" is a statement about the site's own | |
| 1275 | + * addresses, not about every address at that domain a visitor might leave | |
| 1276 | + * behind. Honouring the domain form there turned the comment section into a | |
| 1277 | + * harvest for anyone who could guess the domain. A whole address is | |
| 1278 | + * different: the site owner named that one address exactly, and a visitor | |
| 1279 | + * quoting it is quoting the site's own. | |
| 1280 | + * | |
| 1281 | + * @param string $address The address, as written in the content. | |
| 1282 | + * | |
| 1283 | + * @return bool True when CryptX must not touch it. | |
| 1284 | + */ | |
| 1285 | + private function isAddressExempt(string $address): bool | |
| 1286 | + { | |
| 1287 | + if ($this->inShortcode) { | |
| 1288 | + return false; | |
| 1289 | + } | |
| 1290 | + | |
| 1291 | + if (self::$exemptAddressCache === null) { | |
| 1292 | + $raw = (string) (self::$cryptXOptions['exemptAddresses'] ?? ''); | |
| 1293 | + self::$exemptAddressCache = array_filter( | |
| 1294 | + array_map( | |
| 1295 | + static fn(string $entry): string => strtolower(trim($entry)), | |
| 1296 | + explode(',', $raw) | |
| 1297 | + ), | |
| 1298 | + 'strlen' | |
| 1299 | + ); | |
| 1300 | + } | |
| 1301 | + | |
| 1302 | + if (self::$exemptAddressCache === []) { | |
| 1303 | + return false; | |
| 1304 | + } | |
| 1305 | + | |
| 1306 | + $address = strtolower(trim($address)); | |
| 1307 | + | |
| 1308 | + if (in_array($address, self::$exemptAddressCache, true)) { | |
| 1309 | + return true; | |
| 1310 | + } | |
| 1311 | + | |
| 1312 | + if (in_array(current_filter(), self::VISITOR_WRITTEN_FILTERS, true)) { | |
| 1313 | + return false; | |
| 1314 | + } | |
| 1315 | + | |
| 1316 | + $at = strrpos($address, '@'); | |
| 1317 | + | |
| 1318 | + if ($at === false) { | |
| 1319 | + return false; | |
| 1320 | + } | |
| 1321 | + | |
| 1322 | + return in_array(substr($address, $at), self::$exemptAddressCache, true); | |
| 1323 | + } | |
| 1324 | + | |
| 1325 | + /** | |
| 971 | 1326 | * Check if the given match is in the whitelist. |
| 972 | 1327 | * |
| 973 | 1328 | * @param array $Match The match to check against the whitelist. |
| 974 | 1329 | * |
| @@ -1060,16 +1415,66 @@ | ||
| 1060 | 1415 | */ |
| 1061 | 1416 | private function getImageFromText(array $Match): string |
| 1062 | 1417 | { |
| 1063 | 1418 | self::$styleNeeded = true; |
| 1064 | - $scrambled = antispambot($Match[1]); | |
| 1065 | 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. | |
| 1066 | 1472 | return sprintf( |
| 1067 | - '<img src="%s" class="cryptxImage cryptxImage_%d" alt="%s" title="%s" />', | |
| 1068 | - esc_url(get_bloginfo('url') . '/' . md5(get_bloginfo('url')) . '/' . $scrambled), | |
| 1473 | + '<img src="%s" class="cryptxImage cryptxImage_%d" alt="%s" />', | |
| 1474 | + esc_url(get_bloginfo('url') . '/' . md5(get_bloginfo('url')) . '/' . $token), | |
| 1069 | 1475 | self::$imageCounter, |
| 1070 | - esc_attr($scrambled), | |
| 1071 | - esc_attr($scrambled) | |
| 1476 | + esc_attr($label) | |
| 1072 | 1477 | ); |
| 1073 | 1478 | } |
| 1074 | 1479 | |
| 1075 | 1480 | /** |
| @@ -1301,14 +1706,23 @@ | ||
| 1301 | 1706 | "\\1", |
| 1302 | 1707 | "\\1" |
| 1303 | 1708 | ]; |
| 1304 | 1709 | |
| 1305 | - return $this->withShortcodesProtected($content, static function (string $masked) use ($src, $tar): string { | |
| 1306 | - $result = preg_replace($src, $tar, $masked); | |
| 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); | |
| 1307 | 1719 | |
| 1308 | - // Same reasoning as elsewhere: a PCRE failure yields null, and | |
| 1309 | - // handing that on would silently empty the page. | |
| 1310 | - return $result ?? $masked; | |
| 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 | + ); | |
| 1311 | 1725 | }); |
| 1312 | 1726 | } |
| 1313 | 1727 | |
| 1314 | 1728 | /** |
| @@ -1328,8 +1742,36 @@ | ||
| 1328 | 1742 | // secret, link text and exclusion list -- which is how the bug was |
| 1329 | 1743 | // found in the first place. |
| 1330 | 1744 | $this->refreshForCurrentSite(); |
| 1331 | 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 | + | |
| 1332 | 1774 | self::$cryptXOptions['admin_notices_deprecated'] = true; |
| 1333 | 1775 | if (self::$cryptXOptions['excludedIDs'] == "") { |
| 1334 | 1776 | $tmp = array(); |
| 1335 | 1777 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| @@ -1532,8 +1974,18 @@ | ||
| 1532 | 1974 | * @return array The updated array of excluded IDs. |
| 1533 | 1975 | */ |
| 1534 | 1976 | private function addPostIdToExcludedIdsIfNecessary(array $excludedIds, int $postId): array |
| 1535 | 1977 | { |
| 1978 | + // The only caller, addPostIdToExcludedList(), checks the nonce field, | |
| 1979 | + // then wp_verify_nonce(), then current_user_can('edit_post', $postId) | |
| 1980 | + // before reaching this method. The scanner cannot follow three call | |
| 1981 | + // levels and sees only the superglobal. Read as presence or absence of | |
| 1982 | + // a checkbox; the value is never used. | |
| 1983 | + // | |
| 1984 | + // The annotation has to sit on the line directly above the statement -- | |
| 1985 | + // with the explanation above it, it silenced the next comment line and | |
| 1986 | + // the warning stayed. | |
| 1987 | + // phpcs:ignore WordPress.Security.NonceVerification.Missing | |
| 1536 | 1988 | if (isset($_POST['disable_cryptx_pageid'])) { |
| 1537 | 1989 | $excludedIds[] = $postId; |
| 1538 | 1990 | } |
| 1539 | 1991 | |
| @@ -1613,8 +2065,15 @@ | ||
| 1613 | 2065 | * One exception: with the script placed in the head (load_java = 0) that |
| 1614 | 2066 | * decision cannot be deferred -- the head is sent before the content runs. |
| 1615 | 2067 | * In that configuration the script is enqueued unconditionally, as before. |
| 1616 | 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 | + * | |
| 1617 | 2076 | * @return void |
| 1618 | 2077 | */ |
| 1619 | 2078 | public function loadJavascriptFiles(): void |
| 1620 | 2079 | { |
| @@ -1626,8 +2085,32 @@ | ||
| 1626 | 2085 | |
| 1627 | 2086 | if (!$inFooter) { |
| 1628 | 2087 | wp_enqueue_script('cryptx-js'); |
| 1629 | 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'); | |
| 1630 | 2113 | } |
| 1631 | 2114 | } |
| 1632 | 2115 | |
| 1633 | 2116 | /** |
| @@ -1639,17 +2122,50 @@ | ||
| 1639 | 2122 | */ |
| 1640 | 2123 | public function enqueueAssetsIfNeeded(): void |
| 1641 | 2124 | { |
| 1642 | 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. | |
| 1643 | 2132 | wp_enqueue_script('cryptx-js'); |
| 1644 | 2133 | } |
| 1645 | 2134 | |
| 1646 | - if (self::$styleNeeded) { | |
| 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))) { | |
| 1647 | 2144 | wp_enqueue_style('cryptx-styles'); |
| 1648 | 2145 | } |
| 1649 | 2146 | } |
| 1650 | 2147 | |
| 1651 | 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 | + /** | |
| 1652 | 2168 | * Updates the CryptX settings. |
| 1653 | 2169 | * |
| 1654 | 2170 | * This method retrieves the current CryptX options from the database and checks if the version of CryptX |
| 1655 | 2171 | * stored in the options is less than the current version of CryptX. If the version is outdated, the method |
| @@ -1713,8 +2229,17 @@ | ||
| 1713 | 2229 | unset(self::$cryptXOptions['opt_linktext']); |
| 1714 | 2230 | } |
| 1715 | 2231 | } |
| 1716 | 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 | + | |
| 1717 | 2242 | self::$cryptXOptions['version'] = CRYPTX_VERSION; |
| 1718 | 2243 | self::$cryptXOptions = wp_parse_args(self::$cryptXOptions, $this->getCryptXOptionsDefaults()); |
| 1719 | 2244 | update_option('cryptX', self::$cryptXOptions); |
| 1720 | 2245 | } |
| @@ -2055,8 +2580,16 @@ | ||
| 2055 | 2580 | if (strpos($emailAddress, '@') === self::NOT_FOUND) { |
| 2056 | 2581 | return $originalValue; |
| 2057 | 2582 | } |
| 2058 | 2583 | |
| 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)) { | |
| 2589 | + return $originalValue; | |
| 2590 | + } | |
| 2591 | + | |
| 2059 | 2592 | // The budget is what the browser will still accept once "mailto:", |
| 2060 | 2593 | // the address and the "?" are in place. |
| 2061 | 2594 | $query = $this->sanitizeMailtoQuery( |
| 2062 | 2595 | $rawQuery, |
| @@ -2108,9 +2641,20 @@ | ||
| 2108 | 2641 | esc_attr($encryptedEmail), |
| 2109 | 2642 | esc_attr($payloadMode) |
| 2110 | 2643 | ); |
| 2111 | 2644 | if ($payloadMode === 'secure') { |
| 2112 | - $attributes .= sprintf(' data-cxk="%s"', esc_attr($password)); | |
| 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 | + ); | |
| 2113 | 2657 | } |
| 2114 | 2658 | |
| 2115 | 2659 | // The raw target, not the sanitised address: they differ as |
| 2116 | 2660 | // soon as a query is present, and a needle that is not in the |
| @@ -2124,10 +2668,14 @@ | ||
| 2124 | 2668 | $return = $this->addAttributesToAnchor($return, $attributes); |
| 2125 | 2669 | $return = $this->addClassToAnchor($return, self::LINK_CLASS); |
| 2126 | 2670 | } else { |
| 2127 | 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. | |
| 2128 | 2675 | $javaHandler = $payloadMode === 'secure' |
| 2129 | - ? "javascript:secureDecryptAndNavigate('" . esc_js($encryptedEmail) . "', '" . esc_js($password) . "')" | |
| 2676 | + ? "javascript:secureDecryptAndNavigate('" . esc_js($encryptedEmail) . "', '" | |
| 2677 | + . esc_js($password) . "', " . SecureEncryption::getIterations() . ")" | |
| 2130 | 2678 | : "javascript:DeCryptX('" . esc_js($encryptedEmail) . "')"; |
| 2131 | 2679 | |
| 2132 | 2680 | $return = str_ireplace('mailto:' . $rawTarget, $javaHandler, $originalValue); |
| 2133 | 2681 | } |
| @@ -2196,8 +2744,17 @@ | ||
| 2196 | 2744 | // $merged is still without a secret, and the throwaway Config below |
| 2197 | 2745 | // mints -- persisting the unsaved form along with it. A test that |
| 2198 | 2746 | // watches pre_update_option_cryptX found exactly that. |
| 2199 | 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(); | |
| 2200 | 2757 | } |
| 2201 | 2758 | |
| 2202 | 2759 | $merged = wp_parse_args($overrides, $stored); |
| 2203 | 2760 | |