Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

* Escape calendar item action link hrefs with esc_url() and link text with esc_html__() (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.
Expand Down
10 changes: 5 additions & 5 deletions modules/calendar/calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] = '<a href="' . get_edit_post_link( $post->ID, true ) . '" title="' . esc_attr( __( 'Edit this item', 'edit-flow' ) ) . '">' . __( 'Edit', 'edit-flow' ) . '</a>';
$item_actions['edit'] = '<a href="' . esc_url( get_edit_post_link( $post->ID, true ) ) . '" title="' . esc_attr__( 'Edit this item', 'edit-flow' ) . '">' . esc_html__( 'Edit', 'edit-flow' ) . '</a>';
// Trash this post.
$item_actions['trash'] = '<a href="' . get_delete_post_link( $post->ID ) . '" title="' . esc_attr__( 'Trash this item', 'edit-flow' ) . '">' . __( 'Trash', 'edit-flow' ) . '</a>';
$item_actions['trash'] = '<a href="' . esc_url( get_delete_post_link( $post->ID ) ) . '" title="' . esc_attr__( 'Trash this item', 'edit-flow' ) . '">' . esc_html__( 'Trash', 'edit-flow' ) . '</a>';
// Preview/view this post.
if ( ! in_array( $post->post_status, $this->published_statuses ) ) {
/* translators: %s: post title */
$item_actions['view'] = '<a href="' . esc_url( apply_filters( 'preview_post_link', add_query_arg( 'preview', 'true', get_permalink( $post->ID ) ), $post ) ) . '" title="' . esc_attr( sprintf( __( 'Preview &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" rel="permalink">' . __( 'Preview', 'edit-flow' ) . '</a>';
$item_actions['view'] = '<a href="' . esc_url( apply_filters( 'preview_post_link', add_query_arg( 'preview', 'true', get_permalink( $post->ID ) ), $post ) ) . '" title="' . esc_attr( sprintf( __( 'Preview &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" rel="permalink">' . esc_html__( 'Preview', 'edit-flow' ) . '</a>';
} elseif ( 'trash' != $post->post_status ) {
/* translators: %s: post title */
$item_actions['view'] = '<a href="' . get_permalink( $post->ID ) . '" title="' . esc_attr( sprintf( __( 'View &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" rel="permalink">' . __( 'View', 'edit-flow' ) . '</a>';
$item_actions['view'] = '<a href="' . esc_url( get_permalink( $post->ID ) ) . '" title="' . esc_attr( sprintf( __( 'View &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" rel="permalink">' . esc_html__( 'View', 'edit-flow' ) . '</a>';
}
// Save metadata.
/* translators: %s: post title */
$item_actions['save hidden'] = '<a href="#savemetadata" id="save-editorial-metadata" class="post-' . esc_attr( $post->ID ) . '" title="' . esc_attr( sprintf( __( 'Save &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" >' . __( 'Save', 'edit-flow' ) . '</a>';
$item_actions['save hidden'] = '<a href="#savemetadata" id="save-editorial-metadata" class="post-' . esc_attr( $post->ID ) . '" title="' . esc_attr( sprintf( __( 'Save &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" >' . esc_html__( 'Save', 'edit-flow' ) . '</a>';
}
// Allow other plugins to add actions.
$item_actions = apply_filters( 'ef_calendar_item_actions', $item_actions, $post->ID );
Expand Down
142 changes: 142 additions & 0 deletions tests/Integration/CalendarEscapingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php
/**
* Tests that calendar item action links are correctly escaped.
*
* Regression guard for the missing esc_url() / esc_html__() calls in
* EF_Calendar::get_inner_information() identified by PHPCS
* 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 &amp; 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
*/

declare( strict_types=1 );

namespace Automattic\EditFlow\Tests\Integration;

use Yoast\WPTestUtils\WPIntegration\TestCase;

class CalendarEscapingTest extends TestCase {

protected static $admin_user_id;

public static function wpSetUpBeforeClass( $factory ) {
self::$admin_user_id = $factory->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 &#038;. 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 &#038;.
*/
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 &#038; (numeric entity), not &amp;.
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&#038;action=edit"',
$html,
'Edit link href must pass through esc_url() — bare & becomes &#038; 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 &#038;.
*
* 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&#038;preview=true"',
$html,
'View link href must pass through esc_url() — bare & in permalink becomes &#038;.'
);
$this->assertStringNotContainsString(
'href="http://example.com/?p=1&preview=true"',
$html,
'Unescaped & must not appear in the view href.'
);
}
}