PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / trunk
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar vtrunk
3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 All 156 releases
← All changes | includes/Core/Rest/Integration.php +153 -2 3.2.8trunk View file →
@@ -27,8 +27,19 @@
27 27 public $namespace;
28 28 public $rest_base;
29 29
30 30 /**
31 + * Grace window during which the legacy md5(home_url()) key is still accepted
32 + * after upgrade, so existing Zapier zaps keep firing while customers rotate.
33 + */
34 + const LEGACY_KEY_GRACE_DAYS = 14;
35 +
36 + const OPT_API_KEY = 'nx_integration_api_key';
37 + const OPT_GRACE_STARTED_AT = 'nx_integration_api_key_grace_started_at';
38 + const OPT_LEGACY_KEY_LAST_USED = 'nx_integration_legacy_key_last_used_at';
39 + const OPT_SEEDED_VERSION = 'nx_integration_key_seeded_version';
40 +
41 + /**
31 42 * Constructor.
32 43 *
33 44 * @since 4.7.0
34 45 *
@@ -37,8 +48,13 @@
37 48 public function __construct() {
38 49 $this->namespace = 'notificationx/v1';
39 50 $this->rest_base = 'notification';
40 51 add_action('rest_api_init', [$this, 'register_routes']);
52 + add_action('admin_notices', [$this, 'legacy_api_key_notice']);
53 + // Runs once per install/upgrade to seed the API key and, only when
54 + // needed, open the legacy-key grace window. Gated by a version marker
55 + // so it never retriggers on the same version.
56 + add_action('plugins_loaded', [__CLASS__, 'maybe_seed_from_upgrade'], 20);
41 57 }
42 58
43 59 /**
44 60 * Registers the routes for the objects of the controller.
@@ -138,18 +154,146 @@
138 154 )
139 155 );
140 156 }
141 157
158 + /**
159 + * Returns the site's integration API key, generating and persisting one if it doesn't exist yet.
160 + * Key generation has no side effects on the legacy-key grace window — that is opened only by
161 + * {@see self::maybe_seed_from_upgrade()} at plugin bootstrap, so an unauthenticated attacker
162 + * probing the endpoint can never lazy-open a grace window.
163 + */
164 + public static function get_api_key() {
165 + $key = get_option( self::OPT_API_KEY );
166 + if ( empty( $key ) ) {
167 + $key = wp_generate_password( 32, false );
168 + update_option( self::OPT_API_KEY, $key, false );
169 + }
170 + return $key;
171 + }
172 +
173 + /**
174 + * Runs once per plugin version at bootstrap. Ensures the API key exists and opens the
175 + * legacy-key grace window ONLY when the site actually had legacy integrations configured
176 + * before the upgrade — never on a fresh install and never on customer sites that never
177 + * used Zapier/IFTTT. Gated by a version marker so it does not retrigger.
178 + */
179 + public static function maybe_seed_from_upgrade() {
180 + $seeded = get_option( self::OPT_SEEDED_VERSION );
181 + if ( $seeded === NOTIFICATIONX_VERSION ) {
182 + return;
183 + }
184 + update_option( self::OPT_SEEDED_VERSION, NOTIFICATIONX_VERSION, false );
185 +
186 + // Ensure the new key exists (idempotent — no grace side effect).
187 + self::get_api_key();
188 +
189 + // Open the grace window only for sites that had legacy integrations.
190 + if ( ! get_option( self::OPT_GRACE_STARTED_AT ) && self::has_legacy_integrations() ) {
191 + update_option( self::OPT_GRACE_STARTED_AT, time(), false );
192 + }
193 + }
194 +
195 + /**
196 + * Detects whether the site has (or had) legacy Zapier/IFTTT integrations that would have
197 + * been configured with the legacy md5(home_url()) key. Used to decide whether the upgrade
198 + * seeder should open a grace window.
199 + */
200 + protected static function has_legacy_integrations(): bool {
201 + global $wpdb;
202 + $table = $wpdb->prefix . 'nx_posts';
203 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- False positive: the query is prepared via $this->wpdb->prepare(), which this sniff does not recognise, and only $wpdb->prefix table names are interpolated. Audited 2026-07-16.
204 + $count = $wpdb->get_var(
205 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- False positive: the query is prepared via $this->wpdb->prepare(), which this sniff does not recognise, and only $wpdb->prefix table names are interpolated. Audited 2026-07-16.
206 + "SELECT COUNT(*) FROM {$table}
207 + WHERE source LIKE 'zapier%%'
208 + OR source LIKE 'ifttt%%'"
209 + );
210 + return (int) $count > 0;
211 + }
212 +
213 + /**
214 + * Unix timestamp at which the legacy md5(home_url()) key stops being accepted.
215 + */
216 + public static function legacy_key_grace_ends_at(): int {
217 + $started = (int) get_option( self::OPT_GRACE_STARTED_AT );
218 + if ( ! $started ) {
219 + return 0;
220 + }
221 + return $started + ( self::LEGACY_KEY_GRACE_DAYS * DAY_IN_SECONDS );
222 + }
223 +
224 + /**
225 + * Validates an incoming API key.
226 + * The new random key is always accepted. The legacy md5(home_url()) key is accepted only
227 + * during the post-upgrade grace window; we record every accepted legacy use so the admin
228 + * notice can prompt the customer to rotate. After the grace window closes, the legacy key
229 + * is rejected. Wrong keys never write to the DB — so a probing attacker can never open a
230 + * grace window or flip the admin notice on.
231 + */
232 + public static function is_valid_api_key( string $api_key ): bool {
233 + $stored = (string) get_option( self::OPT_API_KEY );
234 + if ( $stored !== '' && hash_equals( $stored, $api_key ) ) {
235 + return true;
236 + }
237 + $is_legacy = hash_equals( md5( home_url( '', 'http' ) ), $api_key )
238 + || hash_equals( md5( home_url( '', 'https' ) ), $api_key );
239 + if ( ! $is_legacy ) {
240 + return false;
241 + }
242 + $grace_ends_at = self::legacy_key_grace_ends_at();
243 + if ( $grace_ends_at === 0 || time() >= $grace_ends_at ) {
244 + return false;
245 + }
246 + update_option( self::OPT_LEGACY_KEY_LAST_USED, time(), false );
247 + return true;
248 + }
249 +
250 + /**
251 + * Persistent dashboard notice — surfaced whenever the legacy key has been used recently
252 + * so the customer can rotate before (or after) the grace window closes.
253 + */
254 + public function legacy_api_key_notice() {
255 + if ( ! current_user_can( 'edit_notificationx_settings' ) ) {
256 + return;
257 + }
258 + $last_used = (int) get_option( self::OPT_LEGACY_KEY_LAST_USED );
259 + if ( ! $last_used ) {
260 + return;
261 + }
262 + $grace_ends_at = self::legacy_key_grace_ends_at();
263 + $settings_url = admin_url( 'admin.php?page=nx-settings' );
264 + if ( $grace_ends_at > 0 && time() < $grace_ends_at ) {
265 + $deadline = wp_date( get_option( 'date_format' ), $grace_ends_at );
266 + $message = sprintf(
267 + /* translators: %s: rotation deadline date. */
268 + __( 'A Zapier (or other webhook) integration is still calling NotificationX with the legacy API key. Rotate it to the new key before %s — after that, requests using the old key will be rejected.', 'notificationx' ),
269 + '<strong>' . esc_html( $deadline ) . '</strong>'
270 + );
271 + $class = 'notice notice-warning';
272 + } else {
273 + $message = __( 'A Zapier (or other webhook) integration is still calling NotificationX with the legacy API key. Those calls are now being rejected — update the integration with the new key to restore it.', 'notificationx' );
274 + $class = 'notice notice-error';
275 + }
276 + printf(
277 + '<div class="%1$s"><p>%2$s <a href="%3$s">%4$s</a></p></div>',
278 + esc_attr( $class ),
279 + wp_kses_post( $message ),
280 + esc_url( $settings_url ),
281 + esc_html__( 'Get the new key', 'notificationx' )
282 + );
283 + }
284 +
142 285 public function get_response( \WP_REST_Request $request ){
143 286 $id = $request['id'];
144 287 $api_key = $request['api_key'];
145 288 $error = [];
146 289
147 - if( $api_key === md5( home_url( '', 'http' ) ) || $api_key === md5( home_url( '', 'https' ) ) ) {
290 + if( self::is_valid_api_key( (string) $api_key ) ) {
148 291 $notificationx = PostType::get_instance()->get_post( $id );
149 292 if( $notificationx ) {
150 293 return wp_send_json( true );
151 294 }
295 + /* translators: %s: notification ID */
152 296 $error['message'] = sprintf( __( 'There is no notification created with this id: %s', 'notificationx' ), $id );
153 297 return wp_send_json_error( $error, 401 );
154 298 } else {
155 299 $error['message'] = __( 'Error: API Key Invalid!', 'notificationx' );
@@ -171,9 +315,9 @@
171 315
172 316 if ( ! isset( $request['api_key'] ) ) {
173 317 $response_data['error'] = __('Error: You should provide an API key.', 'notificationx');
174 318 } else {
175 - if( md5( home_url( '', 'http' ) ) != $request['api_key'] && md5( home_url( '', 'https' ) ) != $request['api_key'] ) {
319 + if ( ! self::is_valid_api_key( (string) $request['api_key'] ) ) {
176 320 $response_data['error'] = __('Error: Invalid API key.', 'notificationx');
177 321 }
178 322 }
179 323
@@ -181,17 +325,23 @@
181 325 $response_data['data'] = $request->get_params();
182 326 if ( isset( $response_data['data']['api_key'] ) ) {
183 327 unset( $response_data['data']['api_key'] );
184 328 }
329 + array_walk_recursive( $response_data['data'], function( &$val ) {
330 + $val = sanitize_text_field( (string) $val );
331 + } );
185 332 if (isset($response_data['data']['id'])){
186 333 $post = PostType::get_instance()->get_post($response_data['data']['id']);
187 334 if($post['source']){
335 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
188 336 do_action( "nx_api_response_success_{$post['source']}", $response_data['data'] );
189 337 }
190 338 }
339 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
191 340 do_action( 'nx_api_response_success', $response_data['data'] );
192 341 }
193 342
343 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
194 344 return apply_filters( 'nx_api_response', $response_data );
195 345 }
196 346
197 347 /**
@@ -210,8 +360,9 @@
210 360 if($ext && method_exists($ext, 'connect')){
211 361 return $ext->connect($params);
212 362 }
213 363 else{
364 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
214 365 $result = apply_filters("nx_api_connect_$source", null, $params);
215 366 if($result){
216 367 return $result;
217 368 }