Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public static function init() {
// Sync photo post content to photo media on update.
add_action( 'post_updated', [ __CLASS__, 'sync_photo_post_to_photo_media_on_update' ], 5, 3 );

// Photo content is plain text (the alternative text), never post markup.
add_filter( 'the_content', [ __CLASS__, 'render_content_as_plain_text' ], PHP_INT_MIN );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Knock-on effect one file down, at posts.php:407: the RSS alt attribute is now double-escaped.

add_photo_to_rss_feed() is on the_content_feed, which core feeds the output of the_content. It does strip_tags( $content ) and passes the result as [ 'alt' => $content ] to get_the_post_thumbnail(), and wp_get_attachment_image() runs every attribute through esc_attr().

Before this change, a description Fish & chips reached that point as raw text and esc_attr produced the correct alt="Fish &amp; chips". Now esc_html has already produced Fish &amp; chips, so the feed emits alt="Fish &amp;amp; chips" and the entity is announced literally. Same for <, > and ".

The <figcaption> on the following line is unaffected, it is inserted as HTML.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

esc_attr() doesn't double encode. It uses the same _wp_specialchars() call as esc_html(), with double_encode off, so an &amp; that is already there stays &amp;. Checked the patched path on the sandbox:

Screenshot 2026-09-03 at 10 27 01

Encoded once, same as before the change.


// Offset subsequent paginations of front page by number of posts on front page.
add_action( 'pre_get_posts', [ __CLASS__, 'offset_front_page_paginations' ], 11 );
// Fix pages count for front page paginations.
Expand Down Expand Up @@ -256,6 +259,35 @@ public static function use_photo_url_instead_of_media_permalink_url( $url, $post
return wp_get_attachment_url( $post_id );
}

/**
* Renders a photo's content as the plain text it is.
*
* A photo's content is the alternative text submitted with it. The submit
* form and its sanitization treat that as plain text, so the content must
* not be interpreted as post markup on output either. It is escaped here,
* ahead of every other 'the_content' callback, so that they only ever see
* text.
*
* Keys on the global post, like core's own content callbacks, so it applies
* to whatever 'the_content' is run for while a photo is the current post.
* The reverse also holds: a photo's content filtered while another post is
* global, such as an excerpt built outside the loop, is not escaped here.
* Nothing on the site does that.
*
* @param string $content Post content.
* @return string
*/
public static function render_content_as_plain_text( $content ) {
if ( Registrations::get_post_type() !== get_post_type() ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard keys on the global post, not on the post whose content is being filtered.

get_post_type() with no argument reads $GLOBALS['post'], but the_content is routinely applied to a different post's content. Core's wp_trim_excerpt() is the clearest case: it resolves $post = get_post( $post ) and then calls apply_filters( 'the_content', $text ) on that post's content while leaving the global alone.

So get_the_excerpt( 24724 ) from outside the loop (a widget, a sidebar list, a page template) skips the escaping entirely and the legacy <a>/<p> markup goes through, which is the case this patch exists to prevent. wp_trim_words strips tags afterwards, so the practical damage is limited, but the guarantee has a hole in it rather than a documented tradeoff.

The docblock does own the other direction (a non-photo post filtered while a photo is the global post gets escaped and shows its markup as text). Worth noting WP_REST_Posts_Controller is safe here, it calls setup_postdata() first.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, documented in the docblock now. Nothing in the plugin or the theme builds a photo excerpt outside the loop, so it's a known edge rather than a path anything uses.

return $content;
}

$content = esc_html( $content );
Comment thread
mcliwanow marked this conversation as resolved.

// Shortcode and URL syntax stay visible text: hide the characters shortcodes and embeds key on.
return str_replace( [ '[', '://' ], [ '&#91;', '&#58;//' ], $content );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

esc_html() calls _wp_specialchars( $text, ENT_QUOTES, 'UTF-8', false ), and with $double_encode = false an already-valid entity passes through untouched. A description submitted as the literal nine characters &amp;amp; survives sanitize_textarea_field intact and then renders as a single &, and &#91;gallery&#93; renders as [gallery].

Neither is exploitable, do_shortcode matches only a literal [, but it contradicts the stated contract that the submitted text is shown verbatim. htmlspecialchars( $content, ENT_QUOTES, 'UTF-8', true ) would be faithful.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's intentional. Intake goes through wp_filter_post_kses(), which stores a typed & as &amp;, so the output has to leave existing entities alone or every ampersand ever submitted shows up as &amp;. From the sandbox, including a real published row:

image

}

/**
* Syncs the photo post content to the caption for the associated photo media.
*
Expand Down