From f17f7d8439bca1fff73133fda3e94b284af9afc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:54:19 +0000 Subject: [PATCH 01/41] chore(deps-dev)(deps-dev): bump yoast/phpunit-polyfills Bumps [yoast/phpunit-polyfills](https://github.com/Yoast/PHPUnit-Polyfills) from 2.0.5 to 4.0.0. - [Release notes](https://github.com/Yoast/PHPUnit-Polyfills/releases) - [Changelog](https://github.com/Yoast/PHPUnit-Polyfills/blob/4.x/CHANGELOG.md) - [Commits](https://github.com/Yoast/PHPUnit-Polyfills/compare/2.0.5...4.0.0) --- updated-dependencies: - dependency-name: yoast/phpunit-polyfills dependency-version: 4.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- composer.json | 2 +- composer.lock | 31 +++++++++++++++---------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/composer.json b/composer.json index d0114048a..9a5f5f02d 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "mockery/mockery": "^1.6.12", "brain/monkey": "^2.6.2", "phpcompatibility/phpcompatibility-wp": "^2.1.7", - "yoast/phpunit-polyfills": "^2.0" + "yoast/phpunit-polyfills": "^4.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 7805269c3..9c097bc82 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f1fbf9993b749aa5804d6468583b1b6c", + "content-hash": "aedee167bad634dbfd805dfac453dc88", "packages": [ { "name": "code-atlantic/prerequisite-checks", @@ -815,20 +815,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -867,9 +866,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -3230,26 +3229,26 @@ }, { "name": "yoast/phpunit-polyfills", - "version": "2.0.5", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", - "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27" + "reference": "134921bfca9b02d8f374c48381451da1d98402f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", - "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/134921bfca9b02d8f374c48381451da1d98402f9", + "reference": "134921bfca9b02d8f374c48381451da1d98402f9", "shasum": "" }, "require": { - "php": ">=5.6", - "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0" + "php": ">=7.1", + "phpunit/phpunit": "^7.5 || ^8.0 || ^9.0 || ^11.0 || ^12.0" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "yoast/yoastcs": "^3.2.0" + "yoast/yoastcs": "^3.1.0" }, "type": "library", "extra": { @@ -3289,7 +3288,7 @@ "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", "source": "https://github.com/Yoast/PHPUnit-Polyfills" }, - "time": "2025-08-10T05:13:49+00:00" + "time": "2025-02-09T18:58:54+00:00" } ], "aliases": [], From 2ed2ec54a04da69db12bc44f7bb6ab4199fa8291 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:08:42 -0400 Subject: [PATCH 02/41] fix(triggers): allow Extra Selectors starting with a number The selector validation added for issue #993 used native querySelector, which rejects digit-leading class names (.2026-selector) per the CSS spec. jQuery's engine performs the actual click-trigger matching and has always accepted them, so fall back to it before rejecting a selector. Malformed selectors (unbalanced quotes/brackets) still fail both checks and are skipped with the console warning. Ref: https://wordpress.org/support/topic/extra-selectors-starting-with-a-number-no-longer-work/ --- CHANGELOG.md | 1 + assets/js/src/site/plugins/pum.js | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae295d954..207362e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Fixes** +- Fixed Click Open "Extra Selectors" starting with a number (e.g. `.2026-selector`) being rejected as invalid. Selectors are now validated against jQuery's engine, which handles the actual matching. - Fixed a bug where a popup could unexpectedly lose its triggers, conditions, and display settings — leaving only its theme — after saving, a plugin update, or when another plugin or page builder saved the popup. Saves that would erase an existing popup's settings are now prevented and report an error instead of silently discarding your configuration. ## v1.23.0 - 2026-06-28 diff --git a/assets/js/src/site/plugins/pum.js b/assets/js/src/site/plugins/pum.js index 218d4b2a8..f29bf1db8 100644 --- a/assets/js/src/site/plugins/pum.js +++ b/assets/js/src/site/plugins/pum.js @@ -188,7 +188,17 @@ document.createDocumentFragment().querySelector( selector ); return true; } catch ( e ) { - return false; + // Some selectors are invalid CSS but accepted by jQuery's + // engine (e.g. classes starting with a digit like + // `.2026-selector`) and have always worked as click triggers. + // jQuery does the actual matching, so defer to it before + // rejecting; truly malformed selectors still throw here. + try { + $( document.createDocumentFragment() ).find( selector ); + return true; + } catch ( err ) { + return false; + } } }, getClickTriggerSelector: function ( el, trigger_settings ) { From dd3f4a16b6ef45b83c677b32f54e70b0ed4d4ad4 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:08:59 -0400 Subject: [PATCH 03/41] fix(i18n): correct plural of Call to Action to "Calls to Action" Admin menu, page titles, list UI, and bulk-action notices used "Call to Actions"; the grammatically correct plural is "Calls to Action" (CTAs for the abbreviation). Ref: https://wordpress.org/support/topic/plural-of-call-to-action/ --- CHANGELOG.md | 4 ++++ classes/Controllers/Admin.php | 2 +- classes/Controllers/Admin/CallToActions.php | 4 ++-- classes/Controllers/PostTypes.php | 4 ++-- packages/cta-admin/src/App.tsx | 6 +++--- packages/cta-admin/src/components/list-view/header.tsx | 2 +- packages/cta-admin/src/components/list-view/index.tsx | 2 +- packages/cta-admin/src/components/list/index.tsx | 2 +- .../cta-admin/src/registries/list-bulk-actions/delete.tsx | 2 +- .../cta-admin/src/registries/list-bulk-actions/disable.tsx | 2 +- .../cta-admin/src/registries/list-bulk-actions/enable.tsx | 2 +- .../cta-admin/src/registries/list-bulk-actions/export.tsx | 2 +- .../cta-admin/src/registries/list-bulk-actions/trash.tsx | 2 +- packages/cta-admin/src/registries/list-options/export.tsx | 2 +- packages/cta-admin/src/registries/list-options/import.tsx | 2 +- .../cta-admin/src/registries/list-quick-actions/export.tsx | 2 +- 16 files changed, 23 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 207362e34..ba6da32c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +**Improvements** + +- Corrected the plural of "Call to Action" to "Calls to Action" across the admin. Thanks @swinggraphics. + **Fixes** - Fixed Click Open "Extra Selectors" starting with a number (e.g. `.2026-selector`) being rejected as invalid. Selectors are now validated against jQuery's engine, which handles the actual matching. diff --git a/classes/Controllers/Admin.php b/classes/Controllers/Admin.php index dda861723..7c9da229a 100644 --- a/classes/Controllers/Admin.php +++ b/classes/Controllers/Admin.php @@ -58,7 +58,7 @@ public function filter_layout_vars( $vars ) { if ( is_string( $edit_ctas_cap ) && $edit_ctas_cap && current_user_can( $edit_ctas_cap ) ) { $vars['navTabs'][] = [ 'id' => 'call-to-actions', - 'title' => __( 'Call to Actions', 'popup-maker' ), + 'title' => __( 'Calls to Action', 'popup-maker' ), 'href' => admin_url( 'edit.php?post_type=popup&page=popup-maker-call-to-actions' ), ]; } diff --git a/classes/Controllers/Admin/CallToActions.php b/classes/Controllers/Admin/CallToActions.php index a2c0cd01c..5a66866f9 100644 --- a/classes/Controllers/Admin/CallToActions.php +++ b/classes/Controllers/Admin/CallToActions.php @@ -40,8 +40,8 @@ public function init() { public function register_page() { add_submenu_page( 'edit.php?post_type=popup', - __( 'Call to Actions', 'popup-maker' ), - __( 'Call to Actions', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), $this->container->get_permission( 'edit_ctas' ), 'popup-maker-call-to-actions', [ $this, 'render_page' ] diff --git a/classes/Controllers/PostTypes.php b/classes/Controllers/PostTypes.php index 6d7c6b1c6..88341a924 100644 --- a/classes/Controllers/PostTypes.php +++ b/classes/Controllers/PostTypes.php @@ -264,14 +264,14 @@ public function register_cta_post_type() { $cta_labels = $this->post_type_labels( __( 'Call to Action', 'popup-maker' ), - __( 'Call to Actions', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), $post_type_key ); $cta_args = [ 'label' => __( 'Call to Action', 'popup-maker' ), 'labels' => array_merge( $cta_labels, [ - 'all_items' => __( 'Call to Actions', 'popup-maker' ), + 'all_items' => __( 'Calls to Action', 'popup-maker' ), ] ), 'description' => '', // Basic. diff --git a/packages/cta-admin/src/App.tsx b/packages/cta-admin/src/App.tsx index 98618c0b2..dbf29387a 100644 --- a/packages/cta-admin/src/App.tsx +++ b/packages/cta-admin/src/App.tsx @@ -27,10 +27,10 @@ const App = (): JSX.Element => { if ( userCanEditCallToActions ) { _views.push( { name: 'call-to-actions', - title: __( 'Call to Actions', 'popup-maker' ), + title: __( 'Calls to Action', 'popup-maker' ), className: 'call-to-actions', - pageTitle: __( 'Popup Maker - Call to Actions', 'popup-maker' ), - heading: __( 'Popup Maker - Call to Actions', 'popup-maker' ), + pageTitle: __( 'Popup Maker - Calls to Action', 'popup-maker' ), + heading: __( 'Popup Maker - Calls to Action', 'popup-maker' ), comp: CallToActionsView, } ); } diff --git a/packages/cta-admin/src/components/list-view/header.tsx b/packages/cta-admin/src/components/list-view/header.tsx index e7bc68407..b1c7096ad 100644 --- a/packages/cta-admin/src/components/list-view/header.tsx +++ b/packages/cta-admin/src/components/list-view/header.tsx @@ -25,7 +25,7 @@ const Header = (): JSX.Element => { return (

- { __( 'Call to Actions', 'popup-maker' ) } + { __( 'Calls to Action', 'popup-maker' ) }

{ isLoading ? ( diff --git a/packages/cta-admin/src/components/list-view/index.tsx b/packages/cta-admin/src/components/list-view/index.tsx index d99e82f7e..835b4efe7 100644 --- a/packages/cta-admin/src/components/list-view/index.tsx +++ b/packages/cta-admin/src/components/list-view/index.tsx @@ -33,7 +33,7 @@ const CallToActionsView = (): JSX.Element => {

{ __( - 'You do not have permission to manage Call To Actions.', + 'You do not have permission to manage Calls to Action.', 'popup-maker' ) } diff --git a/packages/cta-admin/src/components/list/index.tsx b/packages/cta-admin/src/components/list/index.tsx index ad7cf4777..254815ca6 100644 --- a/packages/cta-admin/src/components/list/index.tsx +++ b/packages/cta-admin/src/components/list/index.tsx @@ -72,7 +72,7 @@ const List = (): JSX.Element => { { // translators: %d: number of items. _n( '%d call to action deleted.', - '%d call to actions deleted.', + '%d calls to action deleted.', count, 'popup-maker' ), diff --git a/packages/cta-admin/src/registries/list-bulk-actions/disable.tsx b/packages/cta-admin/src/registries/list-bulk-actions/disable.tsx index 3444893f5..2a5c0cdb9 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/disable.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/disable.tsx @@ -69,7 +69,7 @@ const DisableBulkAction = (): JSX.Element | null => { // translators: %d: number of items. _n( '%d call to action disabled.', - '%d call to actions disabled.', + '%d calls to action disabled.', count, 'popup-maker' ), diff --git a/packages/cta-admin/src/registries/list-bulk-actions/enable.tsx b/packages/cta-admin/src/registries/list-bulk-actions/enable.tsx index c97de6e5f..c99cc542e 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/enable.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/enable.tsx @@ -66,7 +66,7 @@ export const EnableBulkAction = (): JSX.Element | null => { // translators: %d: number of items. _n( '%d call to action enabled.', - '%d call to actions enabled.', + '%d calls to action enabled.', count, 'popup-maker' ), diff --git a/packages/cta-admin/src/registries/list-bulk-actions/export.tsx b/packages/cta-admin/src/registries/list-bulk-actions/export.tsx index 242d93968..837951477 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/export.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/export.tsx @@ -35,7 +35,7 @@ export const ExportBulkAction = (): JSX.Element => {

{ __( - 'Popup Maker Pro gives you the power to import & export your call to actions to a JSON file in seconds.', + 'Popup Maker Pro gives you the power to import & export your calls to action to a JSON file in seconds.', 'popup-maker' ) }

diff --git a/packages/cta-admin/src/registries/list-bulk-actions/trash.tsx b/packages/cta-admin/src/registries/list-bulk-actions/trash.tsx index a528777d5..8f04a22f8 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/trash.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/trash.tsx @@ -85,7 +85,7 @@ const TrashBulkAction = (): JSX.Element | null => { // translators: %d: number of items. _n( '%d call to action moved to trash.', - '%d call to actions moved to trash.', + '%d calls to action moved to trash.', count, 'popup-maker' ), diff --git a/packages/cta-admin/src/registries/list-options/export.tsx b/packages/cta-admin/src/registries/list-options/export.tsx index cfeb3ae16..31b99ea6a 100644 --- a/packages/cta-admin/src/registries/list-options/export.tsx +++ b/packages/cta-admin/src/registries/list-options/export.tsx @@ -35,7 +35,7 @@ export const ExportListOption = (): JSX.Element => {

{ __( - 'Popup Maker Pro gives you the power to import & export your call to actions to a JSON file in seconds.', + 'Popup Maker Pro gives you the power to import & export your calls to action to a JSON file in seconds.', 'popup-maker' ) }

diff --git a/packages/cta-admin/src/registries/list-options/import.tsx b/packages/cta-admin/src/registries/list-options/import.tsx index 4bac12487..7467b724f 100644 --- a/packages/cta-admin/src/registries/list-options/import.tsx +++ b/packages/cta-admin/src/registries/list-options/import.tsx @@ -34,7 +34,7 @@ export const ImportListOption = (): JSX.Element => {

{ __( - 'Popup Maker Pro gives you the power to import your call to actions from a JSON file in seconds.', + 'Popup Maker Pro gives you the power to import your calls to action from a JSON file in seconds.', 'popup-maker' ) }

diff --git a/packages/cta-admin/src/registries/list-quick-actions/export.tsx b/packages/cta-admin/src/registries/list-quick-actions/export.tsx index ac5c4d1b2..580e636e7 100644 --- a/packages/cta-admin/src/registries/list-quick-actions/export.tsx +++ b/packages/cta-admin/src/registries/list-quick-actions/export.tsx @@ -51,7 +51,7 @@ export const ExportQuickAction = (): JSX.Element => {

{ __( - 'Popup Maker Pro gives you the power to import & export your call to actions to a JSON file in seconds.', + 'Popup Maker Pro gives you the power to import & export your calls to action to a JSON file in seconds.', 'popup-maker' ) }

From 80c0120dd6d9967ddd080c6fc0712119842cbb89 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:09:21 -0400 Subject: [PATCH 04/41] fix(a11y): explicitly move focus to body when closing a popup with no restorable focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit previouslyFocused is captured via $( ':focus' ), which never matches (jQuery's matcher requires type/href/non-negative tabindex), so for auto-open popups the stored set was empty and pumAfterClose left post-close focus to browser defaults. Browsers generally drop focus to themselves when the focused popup hides — verified in Chromium, where Tab already resumed from the top — so this usually behaved correctly by accident, but the outcome was implicit and environment-dependent rather than guaranteed. Per WCAG 2.4.3, focus still returns to the previously focused element when one exists and is visible. Otherwise it is now explicitly set on via a temporary tabindex, making Tab-from-the-top deterministic, including when the prior element was removed or hidden while the popup was open. A tabindex already present on is left untouched; the temporary attribute is only added and removed when body had none. Ref: https://wordpress.org/support/topic/closing-a-popup-should-move-focus-to-the-top-of-the-page/ --- CHANGELOG.md | 1 + .../js/src/site/plugins/pum-accessibility.js | 25 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6da32c8..14fdc6c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ **Fixes** +- Improved keyboard focus when closing popups: focus returns to the previously focused element, or to the top of the document when there wasn't one (auto-open popups) or it no longer exists. Thanks @swinggraphics. - Fixed Click Open "Extra Selectors" starting with a number (e.g. `.2026-selector`) being rejected as invalid. Selectors are now validated against jQuery's engine, which handles the actual matching. - Fixed a bug where a popup could unexpectedly lose its triggers, conditions, and display settings — leaving only its theme — after saving, a plugin update, or when another plugin or page builder saved the popup. Saves that would erase an existing popup's settings are now prevented and report an error instead of silently discarding your configuration. diff --git a/assets/js/src/site/plugins/pum-accessibility.js b/assets/js/src/site/plugins/pum-accessibility.js index ef6f48edb..def972ac4 100644 --- a/assets/js/src/site/plugins/pum-accessibility.js +++ b/assets/js/src/site/plugins/pum-accessibility.js @@ -125,8 +125,31 @@ var PUM_Accessibility; .attr( 'aria-modal', 'false' ); // Accessibility: Focus back on the previously focused element. - if ( previouslyFocused !== undefined && previouslyFocused.length ) { + if ( + previouslyFocused !== undefined && + previouslyFocused.length && + previouslyFocused.is( ':visible' ) + ) { previouslyFocused.trigger( 'focus' ); + } else { + // Nothing had focus before the popup opened (ex. auto-open + // triggers), or that element is gone. Move focus to + // so the next Tab starts from the top of the document + // instead of the popup's position at the end of the page. + var $body = $( 'body' ), + hadTabindex = undefined !== $body.attr( 'tabindex' ); + + if ( ! hadTabindex ) { + $body.attr( 'tabindex', '-1' ); + } + + $body.trigger( 'focus' ); + + // Only remove the attribute we added; leave any + // pre-existing tabindex untouched. + if ( ! hadTabindex ) { + $body.removeAttr( 'tabindex' ); + } } // Accessibility: Clears the currentModal var. From fe2960d89d1e62a85109d14861a0cc941e66853d Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:09:42 -0400 Subject: [PATCH 05/41] fix(integrations): restore forced AJAX for Gravity Forms in popups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit force_ajax() attached the shortcode_atts_gravityforms filter on the popmake_popup_before_inner/after_inner template hooks, but since popup content preloading (1.21.0) the GF shortcode renders during preload_popup() at wp_enqueue_scripts:11 and the footer template only echoes the cached string — so the filter wrapped nothing and forms in popups rendered without AJAX. Full-page POSTs broke text confirmations and Form Submission triggers (thank-you popups never opened), while preload() still enqueued GF's ajax spinner scripts targeting an iframe that never existed. Sandwich the pum_popup_content pipeline (blocks at 9, shortcodes at 11) with the same filter at priorities 5/99 instead. Verified against GF on a local site: popup form gains target='gform_ajax_frame_N' + iframe, on-page instances of the same form remain non-AJAX. Ref: https://wordpress.org/support/topic/form-submission-thank-you-doesnt-appear-on-popup/ --- CHANGELOG.md | 1 + .../integrations/class-pum-gravity-forms.php | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14fdc6c5e..8e251498e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ **Fixes** +- Fixed Gravity Forms inside popups no longer submitting via AJAX (regression since 1.21.0), which broke text confirmations and Form Submission triggers after submit. - Improved keyboard focus when closing popups: focus returns to the previously focused element, or to the top of the document when there wasn't one (auto-open popups) or it no longer exists. Thanks @swinggraphics. - Fixed Click Open "Extra Selectors" starting with a number (e.g. `.2026-selector`) being rejected as invalid. Selectors are now validated against jQuery's engine, which handles the actual matching. - Fixed a bug where a popup could unexpectedly lose its triggers, conditions, and display settings — leaving only its theme — after saving, a plugin update, or when another plugin or page builder saved the popup. Saves that would erase an existing popup's settings are now prevented and report an error instead of silently discarding your configuration. diff --git a/includes/integrations/class-pum-gravity-forms.php b/includes/integrations/class-pum-gravity-forms.php index 77e6b761a..d36d8dbcc 100644 --- a/includes/integrations/class-pum-gravity-forms.php +++ b/includes/integrations/class-pum-gravity-forms.php @@ -16,6 +16,12 @@ public static function init() { add_action( 'popmake_preload_popup', [ __CLASS__, 'preload' ] ); add_action( 'popmake_popup_before_inner', [ __CLASS__, 'force_ajax' ] ); add_action( 'popmake_popup_after_inner', [ __CLASS__, 'force_ajax' ] ); + + // Popup content is pre-rendered & cached (blocks at priority 9, + // shortcodes at 11), so the template hooks above fire after the + // form has already rendered. Sandwich the content pipeline too. + add_filter( 'pum_popup_content', [ __CLASS__, 'begin_force_ajax' ], 5 ); + add_filter( 'pum_popup_content', [ __CLASS__, 'end_force_ajax' ], 99 ); } public static function force_ajax() { @@ -27,6 +33,32 @@ public static function force_ajax() { } } + /** + * Force AJAX on Gravity Forms rendered within popup content. + * + * @param string $content Popup content. + * + * @return string + */ + public static function begin_force_ajax( $content ) { + add_filter( 'shortcode_atts_gravityforms', [ __CLASS__, 'gfrorms_shortcode_atts' ] ); + + return $content; + } + + /** + * Stop forcing AJAX once popup content has rendered. + * + * @param string $content Popup content. + * + * @return string + */ + public static function end_force_ajax( $content ) { + remove_filter( 'shortcode_atts_gravityforms', [ __CLASS__, 'gfrorms_shortcode_atts' ] ); + + return $content; + } + public static function gfrorms_shortcode_atts( $out ) { $out['ajax'] = 'true'; From 2ca4bce9f5aa02fa908577eeefb4143b4d360e9c Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:19:47 -0400 Subject: [PATCH 06/41] fix(security): prevent XSS and license-key exposure in admin surfaces - Gravity Forms: esc_attr() the JSON settings attribute, store only whitelisted keys, and intersect known keys on read so poisoned option data cannot reach the render sink. - Assets: strip all *_license_key values from the currentSettings JS localization exposed to block-editor users. - Admin bar: scope the toolbar-action handler to #wpadminbar, use currentTarget, and require a strictly numeric popup ID. --- classes/Controllers/Assets.php | 15 ++++++++++++- .../integrations/class-pum-gravity-forms.php | 21 ++++++++++++------- packages/admin-bar/src/AdminBar.ts | 14 ++++++++++--- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/classes/Controllers/Assets.php b/classes/Controllers/Assets.php index 58d38ab14..6e6c57021 100644 --- a/classes/Controllers/Assets.php +++ b/classes/Controllers/Assets.php @@ -137,9 +137,22 @@ public function get_packages() { ], 'varsName' => 'popupMakerCoreData', 'vars' => function () { + $settings = \pum_get_options(); + + // Never expose raw license keys in page JS. This covers the Pro key + // (popup_maker_pro_license_key) and legacy addon keys (*_license_key). + // The license UIs have their own masked source of truth. + if ( is_array( $settings ) ) { + foreach ( array_keys( $settings ) as $setting_key ) { + if ( is_string( $setting_key ) && '_license_key' === substr( $setting_key, -12 ) ) { + unset( $settings[ $setting_key ] ); + } + } + } + return [ // TODO Migrate to use plugin('options')->get_all(); - 'currentSettings' => \pum_get_options(), + 'currentSettings' => $settings, ]; }, ], diff --git a/includes/integrations/class-pum-gravity-forms.php b/includes/integrations/class-pum-gravity-forms.php index d36d8dbcc..2864c09be 100644 --- a/includes/integrations/class-pum-gravity-forms.php +++ b/includes/integrations/class-pum-gravity-forms.php @@ -95,7 +95,7 @@ public static function settings_menu( $setting_tabs ) { public static function get_form( $form_string, $form ) { $settings = wp_json_encode( self::form_options( $form['id'] ) ); - $field = ""; + $field = ''; $form_string = preg_replace( '/()/', "$1 \r\n " . $field, $form_string ); return $form_string; @@ -124,8 +124,10 @@ public static function defaults() { */ public static function form_options( $id ) { $settings = get_option( 'gforms_pum_' . $id, self::defaults() ); + $settings = wp_parse_args( $settings, self::defaults() ); - return wp_parse_args( $settings, self::defaults() ); + // Restrict to known keys so legacy/poisoned option data cannot reach the render sink. + return array_intersect_key( $settings, self::defaults() ); } /** @@ -305,12 +307,15 @@ public static function save() { // Check if JSON decode was successful. if ( is_array( $settings ) ) { - $settings['openpopup'] = ! empty( $settings['openpopup'] ); - $settings['openpopup_id'] = ! empty( $settings['openpopup_id'] ) ? absint( $settings['openpopup_id'] ) : 0; - $settings['closepopup'] = ! empty( $settings['closepopup'] ); - $settings['closedelay'] = ! empty( $settings['closedelay'] ) ? absint( $settings['closedelay'] ) : 0; - - update_option( 'gforms_pum_' . $form_id, $settings ); + // Only persist the known keys. Discard any attacker-supplied extras. + $clean = [ + 'openpopup' => ! empty( $settings['openpopup'] ), + 'openpopup_id' => ! empty( $settings['openpopup_id'] ) ? absint( $settings['openpopup_id'] ) : 0, + 'closepopup' => ! empty( $settings['closepopup'] ), + 'closedelay' => ! empty( $settings['closedelay'] ) ? absint( $settings['closedelay'] ) : 0, + ]; + + update_option( 'gforms_pum_' . $form_id, $clean ); } } else { delete_option( 'gforms_pum_' . $form_id ); diff --git a/packages/admin-bar/src/AdminBar.ts b/packages/admin-bar/src/AdminBar.ts index d2a0453e6..66581f0ce 100644 --- a/packages/admin-bar/src/AdminBar.ts +++ b/packages/admin-bar/src/AdminBar.ts @@ -280,23 +280,31 @@ export class AdminBar { } ); - $( document ).on( + // Scope to the admin bar only so page content cannot inject matching anchors. + $( '#wpadminbar' ).on( 'click', '.pum-toolbar-action', ( event: JQuery.ClickEvent ) => { event.preventDefault(); event.stopPropagation(); - const href = $( event.target ).attr( 'href' ); + const href = $( event.currentTarget ).attr( 'href' ); if ( ! href ) { return; } - const [ action, popupId ] = href + const [ action, rawPopupId ] = href .split( '__' )[ 1 ] .split( '--' ); + // Require a strictly numeric popup ID before handing it to PUM APIs. + if ( ! /^\d+$/.test( rawPopupId ?? '' ) ) { + return; + } + + const popupId = rawPopupId; + switch ( action ) { case 'open': PUM.open( popupId ); From 3b01c607b092ff3f313d11be1641c00d850fea3e Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:19:55 -0400 Subject: [PATCH 07/41] fix(security): default TLS verification on for cached asset fetches Flip the pum_asset_cache_sslverify default to true so server-side asset fetches validate certificates. Still filterable for self-signed hosts. --- classes/AssetCache.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/AssetCache.php b/classes/AssetCache.php index 8a7a827a4..c2f3ebd5e 100644 --- a/classes/AssetCache.php +++ b/classes/AssetCache.php @@ -834,7 +834,7 @@ private static function get_asset_contents( $src ) { $response = wp_remote_get( $src, [ - 'sslverify' => apply_filters( 'pum_asset_cache_sslverify', false, $src ), + 'sslverify' => apply_filters( 'pum_asset_cache_sslverify', true, $src ), 'timeout' => 15, ] ); From e94711f98116bd0dfd94c0bf7b7e228be457b363 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:19:56 -0400 Subject: [PATCH 08/41] fix(security): harden build/release supply chain - Pin only-allow to 1.2.2 in the preinstall guard. - Verify the strauss PHAR sha256 before executing it. - build-release.js: copy the versioned zip via fs (not a shell) and validate the tag-derived zip name, closing a command-injection sink. --- bin/build-release.js | 21 +++++++++++++++++---- composer.json | 3 ++- package.json | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/bin/build-release.js b/bin/build-release.js index c7af705d3..e79a3de32 100755 --- a/bin/build-release.js +++ b/bin/build-release.js @@ -286,6 +286,15 @@ class PluginReleaseBuilder { const zipName = this.options.zipFileName || `${ this.pluginName }_${ this.version }.zip`; + + // Reject anything but a plain zip filename. The name can originate from a + // git tag (via --zip-name) and is used in shell/path operations below. + if ( ! /^[A-Za-z0-9._-]+\.zip$/.test( zipName ) ) { + throw new Error( + `Refusing unsafe zip name: "${ zipName }". Expected [A-Za-z0-9._-] and a .zip extension.` + ); + } + const zipPath = path.join( this.outputDir, zipName ); // Move build directory to plugin name @@ -301,10 +310,14 @@ class PluginReleaseBuilder { `Creating latest zip file` ); - // Copy (cp) to versioned zip file - this.executeCommand( - `cp "${ this.pluginName }-latest.zip" "${ zipName }"`, - `Creating versioned zip file` + // Copy to versioned zip file. Use fs (not a shell) so a crafted zip name + // cannot break out of the command. + if ( ! this.options.quiet ) { + console.log( 'Creating versioned zip file...' ); + } + fs.copyFileSync( + path.join( this.projectRoot, `${ this.pluginName }-latest.zip` ), + path.join( this.projectRoot, zipName ) ); // Move zip to output directory if different from project root diff --git a/composer.json b/composer.json index d0114048a..1e012620c 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,8 @@ "lint:changed": "vendor/bin/phpcs --standard=.phpcs.xml.dist --report-full --report-checkstyle=./phpcs-report.xml --file-list=.changed-files.txt", "generate-stubs": "./bin/generate-stubs.sh", "install-strauss": [ - "test -f strauss.phar || curl -o strauss.phar -L -C - https://github.com/BrianHenryIE/strauss/releases/download/0.22.4/strauss.phar" + "test -f strauss.phar || curl -o strauss.phar -L -C - https://github.com/BrianHenryIE/strauss/releases/download/0.22.4/strauss.phar", + "echo 'bced6c576608ab67247c4c06d24e40fcdfd6ed3104347d3a4b8c451cacbe4c1f strauss.phar' | shasum -a 256 -c -" ], "clean-vendor-prefix-folder": [ "rm -rf vendor-prefixed/**/*" diff --git a/package.json b/package.json index 7b43e52cb..7dba04118 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "webpack-bundle-analyzer": "^4.10.2" }, "scripts": { - "preinstall": "npx -y only-allow pnpm", + "preinstall": "npx -y only-allow@1.2.2 pnpm", "build": "wp-scripts build --config webpack.config.js --config webpack.old.config.js", "build:production": "NODE_ENV=production wp-scripts build --mode production --config webpack.config.js --config webpack.old.config.js", "build:tsc": "pnpm -r --sort --if-present run build:tsc", From a2b72b75cec1b57a1e5414fa03021c76e6514234 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:20:02 -0400 Subject: [PATCH 09/41] fix(ci): close WordPress.org deploy and workflow injection paths - deploy-to-wordpress: always build from checked-out source instead of a mutable release zip, and force dry-run on any non-master ref. - deploy-readme-assets: gate to refs/heads/master with a pinned checkout. - build: pass user-controlled dispatch inputs via env instead of interpolating them into run scripts. --- .github/workflows/build.yml | 20 +++++--- .github/workflows/deploy-readme-assets.yml | 6 +++ .github/workflows/deploy-to-wordpress.yml | 57 ++++++---------------- 3 files changed, 36 insertions(+), 47 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a869df5d4..03f17ed56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,9 +66,12 @@ jobs: - name: Generate build version id: version + env: + # Passed via env (not interpolated into the script) so crafted input + # values cannot break out of the assignment and run shell commands. + REF_NAME: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'develop' }} + VERSION_SUFFIX: ${{ github.event.inputs.version_suffix }} run: | - REF_NAME="${{ github.event.inputs.ref || github.event.client_payload.ref || 'develop' }}" - VERSION_SUFFIX="${{ github.event.inputs.version_suffix }}" TIMESTAMP=$(date +%Y%m%d-%H%M%S) if [ -n "$VERSION_SUFFIX" ]; then @@ -556,6 +559,11 @@ jobs: # We'll provide clear instructions to the artifacts section instead - name: Generate summary + env: + # User-controlled dispatch input — pass via env so it cannot break out + # of the summary shell script and execute commands. + SOURCE_REF: ${{ github.event.inputs.ref || 'develop' }} + CHANGELOG_CONTENT: ${{ needs.build.outputs.changelog_content }} run: | BUILD_VERSION="${{ needs.validate.outputs.build_version }}" PLUGIN_INFO='${{ needs.validate.outputs.plugin_info }}' @@ -564,7 +572,7 @@ jobs: echo "# 🔨 Test Build Summary for ${PLUGIN_NAME}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${BUILD_VERSION}" >> $GITHUB_STEP_SUMMARY - echo "**Source:** ${{ github.event.inputs.ref || 'develop' }}" >> $GITHUB_STEP_SUMMARY + echo "**Source:** ${SOURCE_REF}" >> $GITHUB_STEP_SUMMARY echo "**Requested by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -627,13 +635,13 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "## 📝 Build Information" >> $GITHUB_STEP_SUMMARY - echo "**Source:** [\`${{ github.event.inputs.ref || 'develop' }}\`](https://github.com/${{ github.repository }}/tree/${{ github.event.inputs.ref || 'develop' }})" >> $GITHUB_STEP_SUMMARY + echo "**Source:** [\`${SOURCE_REF}\`](https://github.com/${{ github.repository }}/tree/${SOURCE_REF})" >> $GITHUB_STEP_SUMMARY echo "**Commit:** [\`${{ needs.build.outputs.commit_short }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.build.outputs.commit_hash }})" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Recent Changes:**" >> $GITHUB_STEP_SUMMARY - echo "${{ needs.build.outputs.changelog_content }}" >> $GITHUB_STEP_SUMMARY + echo "${CHANGELOG_CONTENT}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "📖 **[View Full Changelog](https://github.com/${{ github.repository }}/blob/${{ github.event.inputs.ref || 'develop' }}/CHANGELOG.md)**" >> $GITHUB_STEP_SUMMARY + echo "📖 **[View Full Changelog](https://github.com/${{ github.repository }}/blob/${SOURCE_REF}/CHANGELOG.md)**" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "## ℹ️ Note" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/deploy-readme-assets.yml b/.github/workflows/deploy-readme-assets.yml index 445c8c891..f974018e7 100644 --- a/.github/workflows/deploy-readme-assets.yml +++ b/.github/workflows/deploy-readme-assets.yml @@ -7,9 +7,15 @@ jobs: update: name: Update WordPress.org readme & assets runs-on: ubuntu-latest + # Only run against the protected master branch. A manual dispatch on any + # other ref must not be able to sync unreviewed content — or a malicious + # branch copy of this workflow — using the WordPress.org SVN credentials. + if: github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v6 + with: + ref: master - uses: 10up/action-wordpress-plugin-asset-update@stable env: diff --git a/.github/workflows/deploy-to-wordpress.yml b/.github/workflows/deploy-to-wordpress.yml index 3e64a00a3..c43bd5251 100644 --- a/.github/workflows/deploy-to-wordpress.yml +++ b/.github/workflows/deploy-to-wordpress.yml @@ -33,8 +33,18 @@ jobs: id: flags env: DRY_RUN_INPUT: ${{ inputs.dry_run }} + GITHUB_REF: ${{ github.ref }} run: | - echo "dry_run=${DRY_RUN_INPUT:-false}" >> $GITHUB_OUTPUT + DRY_RUN="${DRY_RUN_INPUT:-false}" + + # Only the protected master branch may perform a real deploy. + # Any other ref (e.g. a manually dispatched branch) is forced to dry-run. + if [ "${GITHUB_REF}" != "refs/heads/master" ]; then + echo "⚠️ Ref is ${GITHUB_REF}, not refs/heads/master — forcing dry-run." + DRY_RUN="true" + fi + + echo "dry_run=${DRY_RUN}" >> $GITHUB_OUTPUT - name: Extract version from plugin header id: version @@ -43,43 +53,12 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "📦 Version: ${VERSION}" - - name: Try downloading release zip - id: download - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} - REPO: ${{ github.repository }} - run: | - ZIP_NAME="${{ env.PLUGIN_SLUG }}_${VERSION}.zip" - - gh release download "${VERSION}" \ - --pattern "${ZIP_NAME}" \ - --dir . \ - --repo "${REPO}" 2>/dev/null || true - - if [ -f "${ZIP_NAME}" ]; then - echo "✅ Downloaded ${ZIP_NAME}" - echo "found=true" >> $GITHUB_OUTPUT - echo "zip_name=${ZIP_NAME}" >> $GITHUB_OUTPUT - else - echo "⚠️ Release zip not found, will build from source" - echo "found=false" >> $GITHUB_OUTPUT - fi - - # --- Path A: Extract release zip into BUILD_DIR --- - - name: Extract release zip - if: steps.download.outputs.found == 'true' - env: - ZIP_NAME: ${{ steps.download.outputs.zip_name }} - run: | - mkdir -p "${{ env.BUILD_DIR }}" - unzip -o "${ZIP_NAME}" -d /tmp/svn-source - echo "✅ Extracted to ${{ env.BUILD_DIR }}" - ls -la "${{ env.BUILD_DIR }}" - - # --- Path B: Build from source into BUILD_DIR --- + # Deploy always builds from the checked-out source that triggered this + # run. We intentionally do NOT download a prebuilt GitHub release asset: + # release assets are mutable, so trusting one would let anyone who can + # edit an asset publish arbitrary code to WordPress.org. The source tree + # is the trust boundary. - name: Setup PHP - if: steps.download.outputs.found != 'true' uses: shivammathur/setup-php@v2 with: php-version: '8.0' @@ -87,24 +66,20 @@ jobs: coverage: none - name: Setup pnpm - if: steps.download.outputs.found != 'true' uses: pnpm/action-setup@v4 - name: Setup Node.js - if: steps.download.outputs.found != 'true' uses: actions/setup-node@v6 with: node-version: '20' cache: 'pnpm' - name: Install dependencies - if: steps.download.outputs.found != 'true' run: | composer install --no-dev --optimize-autoloader --no-interaction --no-progress pnpm install --frozen-lockfile - name: Build into BUILD_DIR - if: steps.download.outputs.found != 'true' run: | pnpm run build:production From 8de53c6899fe80409b70a7af482fb032551c9763 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 06:23:36 -0400 Subject: [PATCH 10/41] docs(changelog): note security and resilience hardening --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e251498e..9a416c5ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +**Security** + +- Security and resilience hardening based on continuous AI scanning. + **Improvements** - Corrected the plural of "Call to Action" to "Calls to Action" across the admin. Thanks @swinggraphics. From 8378bbe61210e2c8db79c4896368b8a20ffe2793 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Sat, 11 Jul 2026 15:57:41 -0400 Subject: [PATCH 11/41] fix(security): escape untrusted output in admin surfaces - Licensing: kses the remote license API error_message before it reaches the unescaped triple-mustache license template (stored XSS). - AdminBar: HTML-escape the computed selector before innerHTML in the Get Selector modal (DOM XSS from attacker-influenced page markup). --- classes/Licensing.php | 16 +++++++++++++++- packages/admin-bar/src/AdminBar.ts | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/classes/Licensing.php b/classes/Licensing.php index 6dbade8a2..e57de1646 100644 --- a/classes/Licensing.php +++ b/classes/Licensing.php @@ -92,7 +92,21 @@ public static function get_status_messages( $license = null, $key = '', $product // Prefer a stored error message from a recent API response. if ( false === $license->success && ! empty( $license->error_message ) ) { - $messages[] = $license->error_message; + // error_message is untrusted (remote API) and the template renders + // messages unescaped; allow only safe inline markup. + $messages[] = wp_kses( + $license->error_message, + [ + 'a' => [ + 'href' => true, + 'target' => true, + 'rel' => true, + ], + 'strong' => [], + 'em' => [], + 'br' => [], + ] + ); return $messages; } diff --git a/packages/admin-bar/src/AdminBar.ts b/packages/admin-bar/src/AdminBar.ts index 66581f0ce..43040543c 100644 --- a/packages/admin-bar/src/AdminBar.ts +++ b/packages/admin-bar/src/AdminBar.ts @@ -9,6 +9,21 @@ declare const popupMakerAdminBar: } | undefined; +/** + * Escape a string for safe interpolation into innerHTML. + * + * @param {string} value Untrusted string. + * @return {string} HTML-escaped string. + */ +function escapeHtml( value: string ): string { + return String( value ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); +} + interface ModalOptions { title: string; content: string; @@ -225,7 +240,7 @@ export class AdminBar { title: this.text.results, content: `
-

${ selector }

+

${ escapeHtml( selector ) }