From 61f85bf2d31bbe6038a2ec48e6bd593273bd63b5 Mon Sep 17 00:00:00 2001 From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:15:20 -0400 Subject: [PATCH 1/9] fix: wrap action link hrefs with esc_url() and link text with esc_html__() --- modules/calendar/calendar.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/calendar/calendar.php b/modules/calendar/calendar.php index fa4eab0b..11fdebcb 100644 --- a/modules/calendar/calendar.php +++ b/modules/calendar/calendar.php @@ -1201,20 +1201,20 @@ public function get_inner_information( $ef_calendar_item_information_fields, $po $item_actions = array(); if ( $this->current_user_can_modify_post( $post ) ) { // Edit this post. - $item_actions['edit'] = '' . __( 'Edit', 'edit-flow' ) . ''; + $item_actions['edit'] = '' . esc_html__( 'Edit', 'edit-flow' ) . ''; // Trash this post. - $item_actions['trash'] = '' . __( 'Trash', 'edit-flow' ) . ''; + $item_actions['trash'] = '' . esc_html__( 'Trash', 'edit-flow' ) . ''; // Preview/view this post. if ( ! in_array( $post->post_status, $this->published_statuses ) ) { /* translators: %s: post title */ - $item_actions['view'] = '' . __( 'Preview', 'edit-flow' ) . ''; + $item_actions['view'] = '' . esc_html__( 'Preview', 'edit-flow' ) . ''; } elseif ( 'trash' != $post->post_status ) { /* translators: %s: post title */ - $item_actions['view'] = '' . __( 'View', 'edit-flow' ) . ''; + $item_actions['view'] = '' . esc_html__( 'View', 'edit-flow' ) . ''; } // Save metadata. /* translators: %s: post title */ - $item_actions['save hidden'] = '' . __( 'Save', 'edit-flow' ) . ''; + $item_actions['save hidden'] = '' . esc_html__( 'Save', 'edit-flow' ) . ''; } // Allow other plugins to add actions. $item_actions = apply_filters( 'ef_calendar_item_actions', $item_actions, $post->ID ); From d9396ddc7c4ea4915300b0d12d4d631acf898596 Mon Sep 17 00:00:00 2001 From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:15:29 -0400 Subject: [PATCH 2/9] test: verify calendar action link hrefs pass through esc_url() --- tests/Integration/CalendarEscapingTest.php | 176 +++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/Integration/CalendarEscapingTest.php diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php new file mode 100644 index 00000000..f0f8421e --- /dev/null +++ b/tests/Integration/CalendarEscapingTest.php @@ -0,0 +1,176 @@ +user->create( array( 'role' => 'administrator' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_user_id ); + } + + protected function setUp(): void { + parent::setUp(); + wp_set_current_user( self::$admin_user_id ); + } + + /** + * Edit and trash link hrefs must be wrapped in esc_url(). + * + * A raw & in a URL breaks HTML attribute syntax; esc_url() converts it to + * &. We inject a URL with a bare & via a filter so the test fails on + * the pre-fix code (where esc_url() was absent) and passes after the fix. + * + * Delete-the-fix test: remove esc_url() from either href and this assertion + * fails because 'href="…&action=edit"' appears instead of '&action=edit'. + */ + public function test_calendar_edit_link_href_is_url_escaped() { + global $edit_flow; + + $post = self::factory()->post->create_and_get( + array( + 'post_status' => 'draft', + 'post_author' => self::$admin_user_id, + ) + ); + + // Override the edit link to contain a bare & so we can detect whether + // esc_url() is applied (it would encode & as &). + add_filter( + 'get_edit_post_link', + static function () { + return 'http://example.com/wp-admin/post.php?post=1&action=edit'; + } + ); + + ob_start(); + $edit_flow->calendar->get_inner_information( + $edit_flow->calendar->get_post_information_fields( $post ), + $post + ); + $html = ob_get_clean(); + + remove_all_filters( 'get_edit_post_link' ); + + $this->assertStringContainsString( + 'href="http://example.com/wp-admin/post.php?post=1&action=edit"', + $html, + 'Edit link href must pass through esc_url() — bare & is invalid in an HTML attribute.' + ); + $this->assertStringNotContainsString( + 'href="http://example.com/wp-admin/post.php?post=1&action=edit"', + $html, + 'Unescaped & must not appear in the edit href.' + ); + } + + /** + * Trash link href must be wrapped in esc_url(). + * + * Same pattern as the edit link: bare & in the URL should be encoded. + * + * Delete-the-fix test: remove esc_url() from the trash href and the + * first assertion fails. + */ + public function test_calendar_trash_link_href_is_url_escaped() { + global $edit_flow; + + $post = self::factory()->post->create_and_get( + array( + 'post_status' => 'draft', + 'post_author' => self::$admin_user_id, + ) + ); + + add_filter( + 'post_delete_link', + static function () { + return 'http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc'; + } + ); + + ob_start(); + $edit_flow->calendar->get_inner_information( + $edit_flow->calendar->get_post_information_fields( $post ), + $post + ); + $html = ob_get_clean(); + + remove_all_filters( 'post_delete_link' ); + + // The trash link is built with get_delete_post_link(); confirm that + // the output contains a properly-formed href (no raw &). + $this->assertStringNotContainsString( + 'href="http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc"', + $html, + 'Trash href must not contain unescaped &.' + ); + } + + /** + * Published-post view link href must be wrapped in esc_url(). + * + * get_permalink() returns a plain URL (no HTML encoding). Without esc_url() + * a permalink containing & (e.g. from a query string) would produce invalid + * HTML. We use the post_link filter to inject such a URL. + * + * Delete-the-fix test: remove esc_url() from the view href and the first + * assertion fails because the bare & survives into the attribute. + */ + public function test_calendar_view_link_href_is_url_escaped() { + global $edit_flow; + + $post = self::factory()->post->create_and_get( + array( + 'post_status' => 'publish', + 'post_author' => self::$admin_user_id, + ) + ); + + add_filter( + 'post_link', + static function () { + return 'http://example.com/?p=1&preview=true'; + } + ); + + ob_start(); + $edit_flow->calendar->get_inner_information( + $edit_flow->calendar->get_post_information_fields( $post ), + $post + ); + $html = ob_get_clean(); + + remove_all_filters( 'post_link' ); + + $this->assertStringContainsString( + 'href="http://example.com/?p=1&preview=true"', + $html, + 'View link href must pass through esc_url() — bare & in permalink is invalid in an HTML attribute.' + ); + $this->assertStringNotContainsString( + 'href="http://example.com/?p=1&preview=true"', + $html, + 'Unescaped & must not appear in the view href.' + ); + } +} From 6c49387365284f73fed0cbf2c74615b52e7a0dac Mon Sep 17 00:00:00 2001 From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:27:21 -0400 Subject: [PATCH 3/9] test: fix assertion to use & (esc_url encoding) not & (esc_html encoding) --- tests/Integration/CalendarEscapingTest.php | 60 ++++++++++++---------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php index f0f8421e..dad206ed 100644 --- a/tests/Integration/CalendarEscapingTest.php +++ b/tests/Integration/CalendarEscapingTest.php @@ -34,14 +34,16 @@ protected function setUp(): void { } /** - * Edit and trash link hrefs must be wrapped in esc_url(). + * Edit link href must be wrapped in esc_url(). * - * A raw & in a URL breaks HTML attribute syntax; esc_url() converts it to - * &. We inject a URL with a bare & via a filter so the test fails on - * the pre-fix code (where esc_url() was absent) and passes after the fix. + * esc_url() encodes raw & as the numeric entity &. We inject a URL with + * a bare & via a filter so the assertion fails on the pre-fix code (where + * esc_url() was absent) and passes after the fix. * - * Delete-the-fix test: remove esc_url() from either href and this assertion - * fails because 'href="…&action=edit"' appears instead of '&action=edit'. + * Delete-the-fix test: remove esc_url() from the edit href on line 1204 of + * modules/calendar/calendar.php and the assertStringContainsString below + * fails because the raw & survives into the output instead of being encoded + * as &. */ public function test_calendar_edit_link_href_is_url_escaped() { global $edit_flow; @@ -53,8 +55,8 @@ public function test_calendar_edit_link_href_is_url_escaped() { ) ); - // Override the edit link to contain a bare & so we can detect whether - // esc_url() is applied (it would encode & as &). + // Inject a URL with a bare & so we can assert esc_url() encoding. + // esc_url() converts & to & (numeric entity), not &. add_filter( 'get_edit_post_link', static function () { @@ -72,9 +74,9 @@ static function () { remove_all_filters( 'get_edit_post_link' ); $this->assertStringContainsString( - 'href="http://example.com/wp-admin/post.php?post=1&action=edit"', + 'href="http://example.com/wp-admin/post.php?post=1&action=edit"', $html, - 'Edit link href must pass through esc_url() — bare & is invalid in an HTML attribute.' + 'Edit link href must pass through esc_url() — bare & becomes & in URL context.' ); $this->assertStringNotContainsString( 'href="http://example.com/wp-admin/post.php?post=1&action=edit"', @@ -86,10 +88,11 @@ static function () { /** * Trash link href must be wrapped in esc_url(). * - * Same pattern as the edit link: bare & in the URL should be encoded. + * Same pattern as the edit link. get_delete_post_link() returns a raw URL; + * esc_url() must encode the & separators as &. * - * Delete-the-fix test: remove esc_url() from the trash href and the - * first assertion fails. + * Delete-the-fix test: remove esc_url() from the trash href on line 1206 + * and the assertStringContainsString below fails. */ public function test_calendar_trash_link_href_is_url_escaped() { global $edit_flow; @@ -102,7 +105,7 @@ public function test_calendar_trash_link_href_is_url_escaped() { ); add_filter( - 'post_delete_link', + 'delete_post_link', static function () { return 'http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc'; } @@ -115,26 +118,29 @@ static function () { ); $html = ob_get_clean(); - remove_all_filters( 'post_delete_link' ); + remove_all_filters( 'delete_post_link' ); - // The trash link is built with get_delete_post_link(); confirm that - // the output contains a properly-formed href (no raw &). - $this->assertStringNotContainsString( - 'href="http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc"', + // get_delete_post_link() wraps via wp_nonce_url which uses & itself, + // so the injected URL path is not directly reachable via a hook that + // overrides the full return value. We confirm instead that the output + // does not contain any raw & in an href attribute (would indicate + // esc_url() is absent). + $this->assertDoesNotMatchRegularExpression( + '~href="[^"]*&[^#038amp][^"]*"~', $html, - 'Trash href must not contain unescaped &.' + 'No href attribute in the calendar item should contain a raw & character.' ); } /** * Published-post view link href must be wrapped in esc_url(). * - * get_permalink() returns a plain URL (no HTML encoding). Without esc_url() - * a permalink containing & (e.g. from a query string) would produce invalid - * HTML. We use the post_link filter to inject such a URL. + * get_permalink() returns a plain URL with raw & separators. Without esc_url() + * a permalink containing & produces invalid HTML. The post_link filter lets us + * inject such a URL and verify it gets encoded as &. * - * Delete-the-fix test: remove esc_url() from the view href and the first - * assertion fails because the bare & survives into the attribute. + * Delete-the-fix test: remove esc_url() from the view href on line 1213 of + * modules/calendar/calendar.php and the assertStringContainsString below fails. */ public function test_calendar_view_link_href_is_url_escaped() { global $edit_flow; @@ -163,9 +169,9 @@ static function () { remove_all_filters( 'post_link' ); $this->assertStringContainsString( - 'href="http://example.com/?p=1&preview=true"', + 'href="http://example.com/?p=1&preview=true"', $html, - 'View link href must pass through esc_url() — bare & in permalink is invalid in an HTML attribute.' + 'View link href must pass through esc_url() — bare & in permalink becomes &.' ); $this->assertStringNotContainsString( 'href="http://example.com/?p=1&preview=true"', From a7ac605cd787607c3c0d65f230acafad76e446c9 Mon Sep 17 00:00:00 2001 From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:28:36 -0400 Subject: [PATCH 4/9] chore: add Unreleased CHANGELOG entry for calendar escaping fix --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a8115b..18f25d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] - TBD + +### Security + +* fix: escape calendar item action link hrefs with esc_url() and link text with esc_html__() by @thisismyurl + ## [0.11.1] - 2026-08-18 A maintenance and compatibility release. It closes an information-disclosure issue in the Editorial Metadata module, corrects calendar date handling, declares compatibility with WordPress 7.1, and takes a batch of dependency updates including several security fixes. All users are encouraged to update. From 9aeb11026933add61b6d56296149d40c93bd3b3b Mon Sep 17 00:00:00 2001 From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:42:23 +0000 Subject: [PATCH 5/9] test: drop untestable trash-link guard; keep edit + view guards --- tests/Integration/CalendarEscapingTest.php | 54 +++------------------- 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php index dad206ed..4001e0f7 100644 --- a/tests/Integration/CalendarEscapingTest.php +++ b/tests/Integration/CalendarEscapingTest.php @@ -7,6 +7,13 @@ * WordPress.Security.EscapeOutput — the same class of defect fixed in * story-budget.php via PR #993. * + * The trash link (get_delete_post_link()) is not independently testable at + * this integration layer: the function routes through wp_nonce_url() which + * already encodes & as & before esc_url() sees it. An integration test + * for that path cannot distinguish "esc_url() ran" from "wp_nonce_url() + * pre-encoded the value." The production fix is present in calendar.php; + * the edit and view links cover the pattern. + * * @package Automattic\EditFlow\Tests\Integration */ @@ -85,53 +92,6 @@ static function () { ); } - /** - * Trash link href must be wrapped in esc_url(). - * - * Same pattern as the edit link. get_delete_post_link() returns a raw URL; - * esc_url() must encode the & separators as &. - * - * Delete-the-fix test: remove esc_url() from the trash href on line 1206 - * and the assertStringContainsString below fails. - */ - public function test_calendar_trash_link_href_is_url_escaped() { - global $edit_flow; - - $post = self::factory()->post->create_and_get( - array( - 'post_status' => 'draft', - 'post_author' => self::$admin_user_id, - ) - ); - - add_filter( - 'delete_post_link', - static function () { - return 'http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc'; - } - ); - - ob_start(); - $edit_flow->calendar->get_inner_information( - $edit_flow->calendar->get_post_information_fields( $post ), - $post - ); - $html = ob_get_clean(); - - remove_all_filters( 'delete_post_link' ); - - // get_delete_post_link() wraps via wp_nonce_url which uses & itself, - // so the injected URL path is not directly reachable via a hook that - // overrides the full return value. We confirm instead that the output - // does not contain any raw & in an href attribute (would indicate - // esc_url() is absent). - $this->assertDoesNotMatchRegularExpression( - '~href="[^"]*&[^#038amp][^"]*"~', - $html, - 'No href attribute in the calendar item should contain a raw & character.' - ); - } - /** * Published-post view link href must be wrapped in esc_url(). * From 39d07b46e0d38ec4e6c36a7720497f4c003d7b31 Mon Sep 17 00:00:00 2001 From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:47:20 +0000 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20fix=20CHANGELOG=20format=20?= =?UTF-8?q?=E2=80=94=20plain=20imperative,=20props=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f25d6d..56c90248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Security -* fix: escape calendar item action link hrefs with esc_url() and link text with esc_html__() by @thisismyurl +* Escape calendar item action link hrefs with esc_url() and link text with esc_html__() (props @thisismyurl) ## [0.11.1] - 2026-08-18 From b2c2af688e314e59ce5bd84ddd49a508fb724e27 Mon Sep 17 00:00:00 2001 From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:48:44 +0000 Subject: [PATCH 7/9] fix: capitalize docblock long descriptions to satisfy PHPCS rule --- tests/Integration/CalendarEscapingTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php index 4001e0f7..b8b62765 100644 --- a/tests/Integration/CalendarEscapingTest.php +++ b/tests/Integration/CalendarEscapingTest.php @@ -43,7 +43,7 @@ protected function setUp(): void { /** * Edit link href must be wrapped in esc_url(). * - * esc_url() encodes raw & as the numeric entity &. We inject a URL with + * The esc_url() function encodes raw & as the numeric entity &. We inject a URL with * a bare & via a filter so the assertion fails on the pre-fix code (where * esc_url() was absent) and passes after the fix. * @@ -95,7 +95,7 @@ static function () { /** * Published-post view link href must be wrapped in esc_url(). * - * get_permalink() returns a plain URL with raw & separators. Without esc_url() + * The get_permalink() function returns a plain URL with raw & separators. Without esc_url() * a permalink containing & produces invalid HTML. The post_link filter lets us * inject such a URL and verify it gets encoded as &. * From 55391531bd69f314a6c77ecd93dbd5dcdafd507b Mon Sep 17 00:00:00 2001 From: thisismyurl <122108986+thisismyurl@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:17:55 -0400 Subject: [PATCH 8/9] fix: escape module short-description hrefs in Calendar and Story Budget registrations Folds in the two remaining admin_url()-in-href spots flagged in review, closing out the escaping sweep for this module surface. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + modules/calendar/calendar.php | 2 +- modules/story-budget/story-budget.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c90248..e42557fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Security * Escape calendar item action link hrefs with esc_url() and link text with esc_html__() (props @thisismyurl) +* Escape module short-description link hrefs in the Calendar and Story Budget registrations with esc_url() (props @thisismyurl) ## [0.11.1] - 2026-08-18 diff --git a/modules/calendar/calendar.php b/modules/calendar/calendar.php index 11fdebcb..10b29541 100644 --- a/modules/calendar/calendar.php +++ b/modules/calendar/calendar.php @@ -107,7 +107,7 @@ public function __construct() { $args = array( 'title' => __( 'Calendar', 'edit-flow' ), /* translators: %s: URL to the calendar page */ - 'short_description' => sprintf( __( 'View upcoming content in a customizable calendar.', 'edit-flow' ), admin_url( 'index.php?page=calendar' ) ), + 'short_description' => sprintf( __( 'View upcoming content in a customizable calendar.', 'edit-flow' ), esc_url( admin_url( 'index.php?page=calendar' ) ) ), 'extended_description' => __( 'Edit Flow’s calendar lets you see your posts over a customizable date range. Filter by status or click on the post title to see its details. Drag and drop posts between days to change their publication date.', 'edit-flow' ), 'module_url' => $this->module_url, 'img_url' => $this->module_url . 'lib/calendar_s128.png', diff --git a/modules/story-budget/story-budget.php b/modules/story-budget/story-budget.php index af06748b..e188f4a7 100644 --- a/modules/story-budget/story-budget.php +++ b/modules/story-budget/story-budget.php @@ -102,7 +102,7 @@ public function __construct() { $args = array( 'title' => __( 'Story Budget', 'edit-flow' ), // translators: %s is a link to the story budget page. - 'short_description' => sprintf( __( 'View the status of all your content at a glance.', 'edit-flow' ), admin_url( 'index.php?page=story-budget' ) ), + 'short_description' => sprintf( __( 'View the status of all your content at a glance.', 'edit-flow' ), esc_url( admin_url( 'index.php?page=story-budget' ) ) ), 'extended_description' => __( 'Use the story budget to see how content on your site is progressing. Filter by specific categories or date ranges to see details about each post in progress.', 'edit-flow' ), 'module_url' => $this->module_url, 'img_url' => $this->module_url . 'lib/story_budget_s128.png', From e717259e1d268961955edbb1bd66b4241324f01e Mon Sep 17 00:00:00 2001 From: thisismyurl <122108986+thisismyurl@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:47:37 -0400 Subject: [PATCH 9/9] Address review feedback on #1016 Failing checks: WP latest and PHP latest --- tests/Integration/UserGroupsAjaxTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Integration/UserGroupsAjaxTest.php b/tests/Integration/UserGroupsAjaxTest.php index bc5c85e2..34e23aee 100644 --- a/tests/Integration/UserGroupsAjaxTest.php +++ b/tests/Integration/UserGroupsAjaxTest.php @@ -21,6 +21,9 @@ class UserGroupsAjaxTest extends AjaxTestCase { protected function setUp(): void { parent::setUp(); + global $edit_flow; + $edit_flow->user_groups->install(); + require_once ABSPATH . 'wp-admin/includes/ajax-actions.php'; }