diff --git a/CHANGELOG.md b/CHANGELOG.md
index 638e3f03..b87991c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ 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
+
+* 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
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.
diff --git a/modules/calendar/calendar.php b/modules/calendar/calendar.php
index fa4eab0b..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',
@@ -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 );
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',
diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php
new file mode 100644
index 00000000..b8b62765
--- /dev/null
+++ b/tests/Integration/CalendarEscapingTest.php
@@ -0,0 +1,142 @@
+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 link href must be wrapped in esc_url().
+ *
+ * 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.
+ *
+ * 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;
+
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_status' => 'draft',
+ 'post_author' => self::$admin_user_id,
+ )
+ );
+
+ // 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 () {
+ 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 & becomes & in URL context.'
+ );
+ $this->assertStringNotContainsString(
+ 'href="http://example.com/wp-admin/post.php?post=1&action=edit"',
+ $html,
+ 'Unescaped & must not appear in the edit href.'
+ );
+ }
+
+ /**
+ * Published-post view link href must be wrapped in 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 &.
+ *
+ * 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;
+
+ $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 becomes &.'
+ );
+ $this->assertStringNotContainsString(
+ 'href="http://example.com/?p=1&preview=true"',
+ $html,
+ 'Unescaped & must not appear in the view href.'
+ );
+ }
+}