PluginProbe
CryptX / 4.0.1
CryptX v4.0.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 2.4.5 All 92 releases
← All changes | classes/Config.php +28 -288 trunk4.0.1 View file →
@@ -2,58 +2,8 @@
2 2
3 3 namespace CryptX;
4 4
5 5 final class Config {
6 - /**
7 - * Default configuration options for the application.
8 - *
9 - * This array provides the default settings and values for various
10 - * features and behaviors of the application. These options can be
11 - * customized to suit specific implementation requirements.
12 - *
13 - * Keys and their purposes:
14 - * - 'version': The version of the application (default: null).
15 - * - 'at': Replacement string for the "@" symbol (default: ' [at] ').
16 - * - 'dot': Replacement string for the "." symbol (default: ' [dot] ').
17 - * - 'css_id': CSS ID to use for specific elements (default: '').
18 - * - 'css_class': CSS class to use for specific elements (default: '').
19 - * - 'the_content': Flag to enable processing on content (default: 1).
20 - * On block themes this filter is swapped for 'render_block'
21 - * at runtime, see CryptX::initializePluginFilters(). There is
22 - * no separate option for it.
23 - * - 'the_meta_key': Flag to enable processing on meta keys (default: 1).
24 - * - 'the_excerpt': Flag to enable processing on excerpts (default: 1).
25 - * - 'comment_text': Flag to enable processing on comments (default: 1).
26 - * - 'widget_text': Flag to enable processing in widgets (default: 1).
27 - * - 'java': Flag indicating JavaScript-related configurations (default: 1).
28 - * - 'load_java': Flag to enable JavaScript loading (default: 1).
29 - * - 'opt_linktext': Option for link text settings (default: 0).
30 - * - 'autolink': Flag to enable auto-linking of content (default: 1).
31 - * - 'alt_linktext': Alternative text for links (default: '').
32 - * - 'alt_linkimage': Alternative image for links (default: '').
33 - * - 'http_linkimage_title': Link image title with HTTP reference (default: '').
34 - * - 'alt_linkimage_title': Alternative title for the link image (default: '').
35 - * - 'excludedIDs': IDs to exclude from processing (default: '').
36 - * - 'metaBox': Flag to enable or disable meta box features (default: 1).
37 - * - 'alt_uploadedimage': Alternative uploaded image setting (default: '0').
38 - * - 'c2i_font': Custom font setting (default: null).
39 - * - 'c2i_fontSize': Font size for configuration (default: 10).
40 - * - 'c2i_fontRGB': Font color in RGB format (default: '#000000').
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: '').
44 - * - 'whiteList': Comma-separated string of allowed file extensions (default: 'jpeg,jpg,png,gif').
45 - * - 'disable_rss': Flag to disable CryptX in RSS feeds by default (default: 1).
46 - * - 'encryption_mode': Encryption mode setting (default: 'secure').
47 - * - 'encryption_password': Password for encryption; a random secret is generated
48 - * on first use if null and then kept forever (default: null).
49 - * - 'use_secure_encryption': Flag to enable secure encryption by default (default: 1).
50 - * - 'iterations': PBKDF2 iteration count for the secure mode (default: 10000).
51 - * - 'link_mode': How the encrypted link is delivered -- 'data' puts the payload
52 - * into data attributes and lets a delegated click handler take
53 - * over (survives a Content-Security-Policy), 'js' is the historical
54 - * "javascript:" URI (default: 'data').
55 - */
56 6 private const DEFAULT_OPTIONS = [
57 7 'version' => null,
58 8 'at' => ' [at] ',
59 9 'dot' => ' [dot] ',
@@ -78,29 +28,16 @@
78 28 'c2i_font' => null,
79 29 'c2i_fontSize' => 10,
80 30 'c2i_fontRGB' => '#000000',
81 31 'echo' => 1,
82 - 'exemptAddresses' => '',
32 + 'filter' => ['the_content', 'the_meta_key', 'the_excerpt', 'comment_text', 'widget_text'],
83 33 'whiteList' => 'jpeg,jpg,png,gif',
84 - 'disable_rss' => 1,
85 - 'encryption_mode' => 'secure',
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,
91 - 'use_secure_encryption' => 1,
92 - 'iterations' => 10000,
93 - 'link_mode' => 'data',
34 + 'disable_rss' => 1, // Disable CryptX in RSS feeds by default
35 + 'encryption_mode' => 'secure', // Changed to 'secure' by default
36 + 'encryption_password' => null, // Will be auto-generated if null
37 + 'use_secure_encryption' => 1, // Enable secure encryption by default
94 38 ];
95 39
96 - /**
97 - * An array of filter names used within the application.
98 - * These filters are commonly applied to various types of content,
99 - * including posts, comments, and widgets.
100 - */
101 - private const FILTERS = ['the_content', 'the_meta_key', 'the_excerpt', 'comment_text', 'widget_text'];
102 -
103 40 // Define the actual widget filters that will be used when widget_text is enabled
104 41 private const WIDGET_FILTERS = [
105 42 'widget_text', // Legacy text widget (pre-4.9)
106 43 'widget_text_content', // Modern text widget (4.9+)
@@ -107,15 +44,17 @@
107 44 'widget_custom_html_content' // Custom HTML widget (4.8.1+)
108 45 ];
109 46
110 47 private array $options;
48 + private array $originalOptions;
111 49
112 50 public function __construct(array $options = []) {
113 51 $this->options = array_merge(self::DEFAULT_OPTIONS, $options);
52 + $this->originalOptions = $this->options;
114 53 }
115 54
116 55 public function getActiveFilters(): array {
117 - return array_filter(self::FILTERS, fn($filter) =>
56 + return array_filter($this->options['filter'], fn($filter) =>
118 57 isset($this->options[$filter]) && $this->options[$filter]
119 58 );
120 59 }
121 60
@@ -179,19 +118,22 @@
179 118 public function getWidgetFilters(): array {
180 119 return self::WIDGET_FILTERS;
181 120 }
182 121
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.
122 + public function updateFromShortcode(array $attributes, string $tag): void {
123 + $this->originalOptions = $this->options;
124 + $shortcodeOptions = shortcode_atts(
125 + $this->options,
126 + array_change_key_case($attributes, CASE_LOWER),
127 + $tag
128 + );
129 + $this->options = array_merge($this->options, $shortcodeOptions);
130 + }
193 131
132 + public function restoreOriginalOptions(): void {
133 + $this->options = $this->originalOptions;
134 + }
135 +
194 136 public function save(): void {
195 137 update_option('cryptX', $this->options);
196 138 }
197 139
@@ -221,19 +163,10 @@
221 163 return $this->options;
222 164 }
223 165
224 166 /**
225 - * Resets all options to their default values.
167 + * Resets all options to their default values
226 168 *
227 - * Careful before wiring this up again: it has had no caller since 4.1.0,
228 - * because the settings screen resets through SettingsSchema instead. This
229 - * method sets the whole array to DEFAULT_OPTIONS, in which
230 - * encryption_password is null -- and then saves. That discards the secret
231 - * every already delivered link was encrypted with, so those links stop
232 - * resolving until the pages are regenerated. SettingsSchema::defaults()
233 - * covers only the editable options and leaves the secret alone, which is
234 - * why the REST route uses it.
235 - *
236 169 * @return void
237 170 */
238 171 public function reset(): void
239 172 {
@@ -277,209 +210,16 @@
277 210 * @return string Returns the encryption password as a string.
278 211 */
279 212 public function getEncryptionPassword(): string
280 213 {
281 - // A stored password is returned untouched and is NEVER regenerated.
282 - // The password is baked into every link this plugin has ever emitted,
283 - // so a new one would turn all already delivered and cached pages into
284 - // undecryptable garbage. Only a missing (or empty) value is filled in.
285 214 if (empty($this->options['encryption_password'])) {
286 - // Random secret instead of a value derived from the WordPress keys.
287 - // Rationale: this password is published. It is handed to the browser
288 - // as the second argument of the generated
289 - // javascript:secureDecryptAndNavigate(...) link and therefore sits
290 - // in the HTML of every page in clear text. Deriving it from AUTH_KEY
291 - // and SECURE_AUTH_KEY was not reversible, but there is no reason to
292 - // publish anything at all that is a function of the site's secrets.
293 - //
294 - // bin2hex(random_bytes(32)) is the choice because random_bytes() is
295 - // the platform CSPRNG (always available on the required PHP 8.1+,
296 - // not filterable by other plugins) and hex output is pure [0-9a-f]:
297 - // it survives every escaping stage on the way into the JavaScript
298 - // string literal and into the option row unchanged. 32 bytes = 256
299 - // bits, matching the AES-256 key later derived from it via PBKDF2.
300 - try {
301 - $this->options['encryption_password'] = bin2hex(random_bytes(32));
302 - } catch (\Throwable $e) {
303 - // random_bytes() throws when the system has no usable source of
304 - // randomness. wp_generate_password() then provides the fallback;
305 - // 64 chars without special characters keeps the value safe to
306 - // embed unescaped.
307 - $this->options['encryption_password'] = wp_generate_password(64, false, false);
308 - }
215 + // Generate a secure password based on WordPress keys
216 + $this->options['encryption_password'] = hash('sha256',
217 + (defined('AUTH_KEY') ? AUTH_KEY : '') .
218 + (defined('SECURE_AUTH_KEY') ? SECURE_AUTH_KEY : '') .
219 + get_site_url()
220 + );
309 221 $this->save();
310 222 }
311 223 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'] ?? '');
484 224 }
485 225 }