← All changes
|
app/Services/Integrations/GlobalIntegrationService.php
+71
-2
6.2.5
→
6.2.14
View file →
| @@ -2,12 +2,16 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace FluentForm\App\Services\Integrations; |
| 4 | 4 | |
| 5 | 5 | use Exception; |
| 6 | +use FluentForm\App\Modules\AddOnModule; | |
| 6 | 7 | use FluentForm\Framework\Support\Arr; |
| 7 | 8 | class GlobalIntegrationService |
| 8 | 9 | { |
| 9 | - | |
| 10 | + // Sentinel written over connected credential values on read (FINDING-16) and | |
| 11 | + // detected again on save so a re-posted mask never overwrites a live secret. | |
| 12 | + const REDACTION_MASK = '********'; | |
| 13 | + | |
| 10 | 14 | public function get($attr) |
| 11 | 15 | { |
| 12 | 16 | $settingsKey = sanitize_text_field(Arr::get($attr, 'settings_key')); |
| 13 | 17 | $settings = apply_filters_deprecated( |
| @@ -50,8 +54,26 @@ | ||
| 50 | 54 | |
| 51 | 55 | if (!Arr::exists($fieldSettings,'invalid_message')) { |
| 52 | 56 | $fieldSettings['invalid_message'] = __('Your API Key is not valid', 'fluentform'); |
| 53 | 57 | } |
| 58 | + | |
| 59 | + // SECURITY (FINDING-16): the stored settings carry long-lived third-party credentials | |
| 60 | + // (API keys, tokens). This read endpoint is reachable by delegated roles and the payload | |
| 61 | + // returned them verbatim — hide_on_valid only hid the field in the Vue UI, not in the REST | |
| 62 | + // response. When the integration is connected and its credential fields are hidden anyway | |
| 63 | + // (hide_on_valid), redact the declared field values so the browser never receives the | |
| 64 | + // secret. The save path restores any field posted back still masked (see | |
| 65 | + // unmaskCredentials), so a re-saved connected integration — e.g. the "Verify Connection | |
| 66 | + // Again" button, which re-POSTs the loaded payload — can never overwrite a live credential | |
| 67 | + // with the mask. | |
| 68 | + if (is_array($settings) && !empty($fieldSettings['hide_on_valid']) && !empty($settings['status'])) { | |
| 69 | + foreach (array_keys((array) Arr::get($fieldSettings, 'fields', [])) as $credentialKey) { | |
| 70 | + if (!empty($settings[$credentialKey]) && is_string($settings[$credentialKey])) { | |
| 71 | + $settings[$credentialKey] = self::REDACTION_MASK; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 54 | 76 | return [ |
| 55 | 77 | 'status' => true, |
| 56 | 78 | 'integration' => $settings, |
| 57 | 79 | 'settings' => $fieldSettings, |
| @@ -57,8 +79,36 @@ | ||
| 57 | 79 | 'settings' => $fieldSettings, |
| 58 | 80 | ]; |
| 59 | 81 | } |
| 60 | 82 | |
| 83 | + /** | |
| 84 | + * Restore any credential the browser posted back still masked (REDACTION_MASK) | |
| 85 | + * to its real stored value before it is persisted. get() redacts connected | |
| 86 | + * credentials on read; without this, re-saving a connected integration — the | |
| 87 | + * "Verify Connection Again" button re-POSTs the loaded payload verbatim, and | |
| 88 | + * several handlers persist BEFORE their auth test — would overwrite a live key | |
| 89 | + * with '********'. Stored settings come from the same filter get() reads. | |
| 90 | + */ | |
| 91 | + public function unmaskCredentials($settingsKey, $integration) | |
| 92 | + { | |
| 93 | + if (!is_array($integration) || !in_array(self::REDACTION_MASK, $integration, true)) { | |
| 94 | + return $integration; | |
| 95 | + } | |
| 96 | + | |
| 97 | + $stored = apply_filters('fluentform/global_integration_settings_' . $settingsKey, []); | |
| 98 | + if (!is_array($stored)) { | |
| 99 | + return $integration; | |
| 100 | + } | |
| 101 | + | |
| 102 | + foreach ($integration as $key => $value) { | |
| 103 | + if (self::REDACTION_MASK === $value && isset($stored[$key]) && is_string($stored[$key])) { | |
| 104 | + $integration[$key] = $stored[$key]; | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + return $integration; | |
| 109 | + } | |
| 110 | + | |
| 61 | 111 | public function isEnabled($integrationKey) |
| 62 | 112 | { |
| 63 | 113 | $globalModules = get_option('fluentform_global_modules_status'); |
| 64 | 114 | $isEnabled = $globalModules && isset($globalModules[$integrationKey]) && 'yes' == $globalModules[$integrationKey]; |
| @@ -77,10 +127,16 @@ | ||
| 77 | 127 | if (!$moduleKey || !in_array($moduleStatus, ['yes', 'no'])) { |
| 78 | 128 | // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output |
| 79 | 129 | throw new Exception(__('Status update failed. Not valid module or status', 'fluentform')); |
| 80 | 130 | } |
| 131 | + $modules = (array)get_option('fluentform_global_modules_status'); | |
| 132 | + | |
| 133 | + if (!$this->isTogglableModuleKey($moduleKey, $modules)) { | |
| 134 | + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output | |
| 135 | + throw new Exception(__('Status update failed. Not valid module or status', 'fluentform')); | |
| 136 | + } | |
| 137 | + | |
| 81 | 138 | try { |
| 82 | - $modules = (array)get_option('fluentform_global_modules_status'); | |
| 83 | 139 | $modules[$moduleKey] = $moduleStatus; |
| 84 | 140 | update_option('fluentform_global_modules_status', $modules, 'no'); |
| 85 | 141 | } catch (Exception $e) { |
| 86 | 142 | // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output |
| @@ -85,6 +141,19 @@ | ||
| 85 | 141 | } catch (Exception $e) { |
| 86 | 142 | // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output |
| 87 | 143 | throw new Exception($e->getMessage()); |
| 88 | 144 | } |
| 145 | + } | |
| 146 | + | |
| 147 | + /** | |
| 148 | + * Read from the live registry, never a second list here, so a newly registered add-on stays togglable. | |
| 149 | + * Administrators bypass it: this REST request misses add-ons that register only in wp-admin context. | |
| 150 | + */ | |
| 151 | + private function isTogglableModuleKey($moduleKey, array $storedModules) | |
| 152 | + { | |
| 153 | + if (current_user_can('manage_options') || array_key_exists($moduleKey, $storedModules)) { | |
| 154 | + return true; | |
| 155 | + } | |
| 156 | + | |
| 157 | + return array_key_exists($moduleKey, (array) (new AddOnModule())->getRegisteredAddOns()); | |
| 89 | 158 | } |
| 90 | 159 | } |