PluginProbe
CryptX / 4.2.0
CryptX v4.2.0
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
cryptx / classes / Config.php

Config.php in CryptX 4.2.0, at classes/Config.php

485 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX;
4
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 private const DEFAULT_OPTIONS = [
57 'version' => null,
58 'at' => ' [at] ',
59 'dot' => ' [dot] ',
60 'css_id' => '',
61 'css_class' => '',
62 'the_content' => 1,
63 'the_meta_key' => 1,
64 'the_excerpt' => 1,
65 'comment_text' => 1,
66 'widget_text' => 1,
67 'java' => 1,
68 'load_java' => 1,
69 'opt_linktext' => 0,
70 'autolink' => 1,
71 'alt_linktext' => '',
72 'alt_linkimage' => '',
73 'http_linkimage_title' => '',
74 'alt_linkimage_title' => '',
75 'excludedIDs' => '',
76 'metaBox' => 1,
77 'alt_uploadedimage' => '0',
78 'c2i_font' => null,
79 'c2i_fontSize' => 10,
80 'c2i_fontRGB' => '#000000',
81 'echo' => 1,
82 'exemptAddresses' => '',
83 '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',
94 ];
95
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 // Define the actual widget filters that will be used when widget_text is enabled
104 private const WIDGET_FILTERS = [
105 'widget_text', // Legacy text widget (pre-4.9)
106 'widget_text_content', // Modern text widget (4.9+)
107 'widget_custom_html_content' // Custom HTML widget (4.8.1+)
108 ];
109
110 private array $options;
111
112 public function __construct(array $options = []) {
113 $this->options = array_merge(self::DEFAULT_OPTIONS, $options);
114 }
115
116 public function getActiveFilters(): array {
117 return array_filter(self::FILTERS, fn($filter) =>
118 isset($this->options[$filter]) && $this->options[$filter]
119 );
120 }
121
122 public function isMetaBoxEnabled(): bool {
123 return (bool) ($this->options['metaBox'] ?? false);
124 }
125
126 public function isAutolinkEnabled(): bool {
127 return (bool) ($this->options['autolink'] ?? false);
128 }
129
130 public function getLinkTextOption(): int {
131 return (int) ($this->options['opt_linktext'] ?? 0);
132 }
133
134 public function getFontSettings(): array {
135 return [
136 'font' => $this->options['c2i_font'],
137 'size' => (int) $this->options['c2i_fontSize'],
138 'color' => $this->options['c2i_fontRGB']
139 ];
140 }
141
142 public function getCssSettings(): array {
143 return [
144 'id' => $this->options['css_id'],
145 'class' => $this->options['css_class']
146 ];
147 }
148
149 public function getEmailReplacements(): array {
150 return [
151 'at' => $this->options['at'],
152 'dot' => $this->options['dot']
153 ];
154 }
155
156 public function getImageSettings(): array {
157 return [
158 'url' => $this->options['alt_linkimage'],
159 'title' => $this->options['http_linkimage_title'],
160 'uploaded_id' => $this->options['alt_uploadedimage'],
161 'uploaded_title' => $this->options['alt_linkimage_title']
162 ];
163 }
164
165 public function getExcludedIds(): array {
166 $ids = $this->options['excludedIDs'];
167 return $ids ? array_map('trim', explode(',', $ids)) : [];
168 }
169
170 public function getVersion(): ?string {
171 return $this->options['version'];
172 }
173
174 /**
175 * Get the actual widget filters to be applied
176 *
177 * @return array
178 */
179 public function getWidgetFilters(): array {
180 return self::WIDGET_FILTERS;
181 }
182
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.
193
194 public function save(): void {
195 update_option('cryptX', $this->options);
196 }
197
198 public function update(array $newOptions): void
199 {
200 // Convert checkbox values to integers
201 foreach (['the_content', 'the_meta_key', 'the_excerpt', 'comment_text',
202 'widget_text', 'autolink', 'metaBox', 'disable_rss', 'use_secure_encryption'] as $key) {
203 if (isset($newOptions[$key])) {
204 $newOptions[$key] = (int)$newOptions[$key];
205 }
206 }
207
208 $this->options = array_merge($this->options, $newOptions);
209 $this->save(); // Save immediately after update
210 }
211
212 public function get(string $key, $default = null) {
213 return $this->options[$key] ?? $default;
214 }
215
216 public function has(string $key): bool {
217 return isset($this->options[$key]);
218 }
219
220 public function getAll(): array {
221 return $this->options;
222 }
223
224 /**
225 * Resets all options to their default values.
226 *
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 * @return void
237 */
238 public function reset(): void
239 {
240 $this->options = self::DEFAULT_OPTIONS;
241
242 // Set version and default font
243 $this->options['version'] = CRYPTX_VERSION;
244
245 // Set default font if available
246 $fontFiles = glob(CRYPTX_DIR_PATH . 'fonts/*.ttf');
247 if (!empty($fontFiles)) {
248 $this->options['c2i_font'] = basename($fontFiles[0]);
249 }
250
251 $this->save();
252 }
253
254 /**
255 * Retrieves the encryption mode configured in the options.
256 *
257 * @return string Returns the encryption mode as a string. Defaults to 'secure' if not set.
258 */
259 public function getEncryptionMode(): string
260 {
261 return $this->options['encryption_mode'] ?? 'secure';
262 }
263
264 /**
265 * Checks if secure encryption is enabled in the options.
266 *
267 * @return bool Returns true if secure encryption is enabled, false otherwise.
268 */
269 public function isSecureEncryptionEnabled(): bool
270 {
271 return (bool) ($this->options['use_secure_encryption'] ?? true);
272 }
273
274 /**
275 * Retrieves the encryption password configured in the options or generates a secure password if not set.
276 *
277 * @return string Returns the encryption password as a string.
278 */
279 public function getEncryptionPassword(): string
280 {
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 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 }
309 $this->save();
310 }
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'] ?? '');
484 }
485 }