PluginProbe
CryptX / trunk
CryptX vtrunk
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.1trunk 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 }