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',