| @@ -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 | } |