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.' + ); + } +}