From e241ef29c6b01eca521c124c073e63dbc2772086 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/8] 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 53fb16c2b71b65c2846239faba6dd460a1df40fe 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/8] 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 41857559572afc74a902ced354735701484412a8 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/8] 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 aab58444335e9d71baebc77650649d74c450fb62 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/8] 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 638e3f03..1b496262 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.0] - 2026-06-10 This release completes the security-review remediation begun in 0.10.4. It resolves the remaining issues from a full audit of the plugin's authenticated code paths — the headline stored XSS in the editorial-metadata location field, two information-disclosure issues (the iCal feed and the Story Budget), and a long tail of defence-in-depth hardening across access control, input handling, deserialisation, output escaping, and client-side code. None are known to be exploited in the wild, but all users are encouraged to update. From 1e65775fd6c8cc0ae36159b06a11571ef2bd8578 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/8] 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 59bb2f4fd92322bdde4b4909788fdf5e593f5cc4 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/8] =?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 1b496262..f3ab7e2f 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.0] - 2026-06-10 From f54b46921dcb952e904f1a19060f0fe5645eefd2 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/8] 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 63b1e7da887ea7eb9584290d4be617f461902ad8 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/8] 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 f3ab7e2f..b87991c9 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.0] - 2026-06-10 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',