PluginProbe
CryptX / 4.2.1
CryptX v4.2.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 +589 -32 4.1.14.2.1 View file →
@@ -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