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/Config.php +189 -15 4.1.14.2.1 View file →
@@ -38,8 +38,10 @@
38 38 * - 'c2i_font': Custom font setting (default: null).
39 39 * - 'c2i_fontSize': Font size for configuration (default: 10).
40 40 * - 'c2i_fontRGB': Font color in RGB format (default: '#000000').
41 41 * - 'echo': Flag to enable output directly to the browser (default: 1).
42 + * - 'exemptAddresses': Comma-separated addresses CryptX leaves alone; an entry
43 + * may also be a bare domain such as '@example.com' (default: '').
42 44 * - 'whiteList': Comma-separated string of allowed file extensions (default: 'jpeg,jpg,png,gif').
43 45 * - 'disable_rss': Flag to disable CryptX in RSS feeds by default (default: 1).
44 46 * - 'encryption_mode': Encryption mode setting (default: 'secure').
45 47 * - 'encryption_password': Password for encryption; a random secret is generated
@@ -76,12 +78,17 @@
76 78 'c2i_font' => null,
77 79 'c2i_fontSize' => 10,
78 80 'c2i_fontRGB' => '#000000',
79 81 'echo' => 1,
82 + 'exemptAddresses' => '',
80 83 'whiteList' => 'jpeg,jpg,png,gif',
81 84 'disable_rss' => 1,
82 85 'encryption_mode' => 'secure',
83 86 'encryption_password' => null,
87 + 'image_token_secret' => null,
88 + 'image_token_secret_previous' => null,
89 + 'image_token_secret_previous_until' => 0,
90 + 'secrets_rotated_at' => 0,
84 91 'use_secure_encryption' => 1,
85 92 'iterations' => 10000,
86 93 'link_mode' => 'data',
87 94 ];
@@ -100,13 +107,11 @@
100 107 'widget_custom_html_content' // Custom HTML widget (4.8.1+)
101 108 ];
102 109
103 110 private array $options;
104 - private array $originalOptions;
105 111
106 112 public function __construct(array $options = []) {
107 113 $this->options = array_merge(self::DEFAULT_OPTIONS, $options);
108 - $this->originalOptions = $this->options;
109 114 }
110 115
111 116 public function getActiveFilters(): array {
112 117 return array_filter(self::FILTERS, fn($filter) =>
@@ -174,22 +179,19 @@
174 179 public function getWidgetFilters(): array {
175 180 return self::WIDGET_FILTERS;
176 181 }
177 182
178 - public function updateFromShortcode(array $attributes, string $tag): void {
179 - $this->originalOptions = $this->options;
180 - $shortcodeOptions = shortcode_atts(
181 - $this->options,
182 - array_change_key_case($attributes, CASE_LOWER),
183 - $tag
184 - );
185 - $this->options = array_merge($this->options, $shortcodeOptions);
186 - }
183 + // updateFromShortcode() and restoreOriginalOptions() used to sit here. They
184 + // had no caller anywhere in the plugin -- CryptX::cryptXShortcode() does
185 + // that job on the static option list -- and they were the more dangerous of
186 + // the two implementations: they merged shortcode attributes straight into
187 + // $this->options, which is what getEncryptionPassword() and
188 + // getImageTokenSecret() read from and what save() writes to the database.
189 + // Whoever revived them would have made key material settable by anyone
190 + // allowed to write a post, and the guard added to cryptXShortcode() would
191 + // not have covered it. Dead code that only waits for someone to call it is
192 + // worse than no code.
187 193
188 - public function restoreOriginalOptions(): void {
189 - $this->options = $this->originalOptions;
190 - }
191 -
192 194 public function save(): void {
193 195 update_option('cryptX', $this->options);
194 196 }
195 197
@@ -306,6 +308,178 @@
306 308 }
307 309 $this->save();
308 310 }
309 311 return $this->options['encryption_password'];
312 + }
313 +
314 + /**
315 + * The secret behind the image tokens -- and the one that is never published.
316 + *
317 + * getEncryptionPassword() above is handed to the browser with every link;
318 + * it has to be, because the visitor's browser does the decrypting. Anything
319 + * keyed with it is therefore readable by whoever reads the page, which is
320 + * exactly the audience the image variant is hiding from. So the tokens in
321 + * the image URLs get their own secret, and this one stays on the server.
322 + *
323 + * Stored rather than derived from the WordPress salts: rotating those --
324 + * which an administrator may do at any time, and which only logs everyone
325 + * out -- would invalidate every image URL in every cached page at once.
326 + *
327 + * @return string The secret, minted on first use and then kept.
328 + */
329 + public function getImageTokenSecret(): string
330 + {
331 + if (empty($this->options['image_token_secret'])) {
332 + // No fallback to wp_generate_password() here, deliberately -- and
333 + // that is the difference to getEncryptionPassword() above, which
334 + // has one. If random_bytes() throws, random_int() throws too, and
335 + // wp_rand() then falls back to a source that is not cryptographic.
336 + // For the link password that costs nothing, because the value is
337 + // published in every link anyway. For this one it is the whole
338 + // protection: a guessable secret would let anybody rebuild the
339 + // tokens and read the addresses back out of an access log, while
340 + // the site went on reporting itself as protected.
341 + //
342 + // Returning nothing instead means ImageToken mints nothing, and
343 + // getImageFromText() renders no picture. The link around it still
344 + // works and the address is still hidden. Missing a picture is the
345 + // better failure.
346 + try {
347 + $this->options['image_token_secret'] = bin2hex(random_bytes(32));
348 + } catch (\Throwable $e) {
349 + return '';
350 + }
351 +
352 + $this->save();
353 + }
354 +
355 + return (string) $this->options['image_token_secret'];
356 + }
357 +
358 + /**
359 + * How long a replaced image secret keeps working.
360 + *
361 + * Long enough to outlive any ordinary page cache, short enough that a
362 + * secret somebody wanted rid of does not stay usable indefinitely.
363 + */
364 + private const IMAGE_SECRET_GRACE = 30 * DAY_IN_SECONDS;
365 +
366 + /**
367 + * Replaces both secrets with fresh ones.
368 + *
369 + * The two behave completely differently under a change, and that is the
370 + * whole reason this method exists rather than a line of code somewhere:
371 + *
372 + * The link password can be replaced at any moment with no consequence at
373 + * all. It travels inside every link -- data-cxk, or the second argument of
374 + * the javascript: call -- so a link already sitting in a cache carries the
375 + * password it was made with and goes on working for ever. Measured, not
376 + * assumed: the note in updateCryptXSettings() claiming that a new password
377 + * kills cached links describes a format that no longer exists.
378 + *
379 + * The image secret is the opposite: it never leaves the server, so a token
380 + * in a cached page can only be read while the secret that made it is still
381 + * known. Replacing it therefore keeps the old one for a grace period, and
382 + * ImageToken::read() falls back to it.
383 + *
384 + * @return void
385 + */
386 + public function rotateSecrets(): void
387 + {
388 + $previous = $this->peekImageTokenSecret();
389 +
390 + unset($this->options['encryption_password'], $this->options['image_token_secret']);
391 +
392 + if ($previous !== '') {
393 + $this->options['image_token_secret_previous'] = $previous;
394 + $this->options['image_token_secret_previous_until'] = time() + self::IMAGE_SECRET_GRACE;
395 + }
396 +
397 + // Minted here rather than left to the next page view, so that a failure
398 + // is visible while an administrator is looking at the screen.
399 + $this->getEncryptionPassword();
400 + $this->getImageTokenSecret();
401 +
402 + $this->options['secrets_rotated_at'] = time();
403 +
404 + $this->save();
405 + }
406 +
407 + /**
408 + * The replaced image secret, while it is still within its grace period.
409 + *
410 + * @return string The previous secret, or an empty string.
411 + */
412 + public function previousImageTokenSecret(): string
413 + {
414 + $until = (int) ($this->options['image_token_secret_previous_until'] ?? 0);
415 +
416 + if ($until < time()) {
417 + return '';
418 + }
419 +
420 + return (string) ($this->options['image_token_secret_previous'] ?? '');
421 + }
422 +
423 + /**
424 + * Drops a replaced image secret once its grace period is over.
425 + *
426 + * Separate from the getter above, and called only from the settings screen,
427 + * because the getter runs on the image endpoint -- on an unauthenticated
428 + * request from a stranger. Writing an option there is the same mistake that
429 + * was taken out of ImageToken::read() one round earlier: a stranger should
430 + * not decide when this site writes to its own database.
431 + *
432 + * Returning '' is already enough to stop using the value. This is about not
433 + * leaving a retired secret sitting in wp_options for ever next to the one
434 + * that replaced it -- hygiene, not a hole: whoever can read that table has
435 + * the current secret anyway.
436 + *
437 + * @return void
438 + */
439 + public function forgetExpiredImageTokenSecret(): void
440 + {
441 + $until = (int) ($this->options['image_token_secret_previous_until'] ?? 0);
442 +
443 + if ($until >= time() || empty($this->options['image_token_secret_previous'])) {
444 + return;
445 + }
446 +
447 + $this->options['image_token_secret_previous'] = null;
448 + $this->options['image_token_secret_previous_until'] = 0;
449 + $this->save();
450 + }
451 +
452 + /**
453 + * When the secrets were last replaced, if ever.
454 + *
455 + * @return int A Unix timestamp, or 0.
456 + */
457 + public function secretsRotatedAt(): int
458 + {
459 + return (int) ($this->options['secrets_rotated_at'] ?? 0);
460 + }
461 +
462 + /**
463 + * The image secret if there is one, without minting.
464 + *
465 + * Reading a token never needs one to exist: if there is no secret, no token
466 + * was ever made and nothing can decode. Minting on the read path would let
467 + * a stranger who calls the image endpoint decide the moment the secret
468 + * comes into being -- and two such calls arriving together can each mint
469 + * one, after which whichever loses has published image URLs that will never
470 + * resolve again. Narrow, but the consequence outlives the request: those
471 + * URLs sit in caches.
472 + *
473 + * To be precise about what this does and does not fix: it takes the timing
474 + * away from a stranger. Two ordinary first page views arriving together can
475 + * still each mint, with the same consequence -- the same race the link
476 + * password has always had. That window closes at the first uncached render;
477 + * it is not worth an option row of its own.
478 + *
479 + * @return string The stored secret, or an empty string.
480 + */
481 + public function peekImageTokenSecret(): string
482 + {
483 + return (string) ($this->options['image_token_secret'] ?? '');
310 484 }
311 485 }