PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
← All changes | app/Services/Integrations/GlobalIntegrationService.php +71 -2 6.2.56.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 }