DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-89274: When a Comment Meets do_shortcode() in WP Recipe Maker

TL;DR

In WP Recipe Maker 10.8.1 and earlier, the text of a visitor's rated comment is passed to do_shortcode() while the plugin builds its recipe JSON-LD. Any [shortcode] written in an approved comment is executed on the server each time the page renders, and the output lands in the page for everyone to see.

CVE CVE-2026-89274
Product WP Recipe Maker (WordPress plugin)
Affected 10.8.1 and earlier
Fixed in 10.8.2
Type CWE-94, arbitrary shortcode execution
CVSS 3.1 9.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
Authentication None (the comment has to be approved)

One caveat up front: this is not arbitrary PHP execution. An attacker can only run shortcodes that are already registered on the site, so the real impact depends on what those shortcodes do.


Background you need

What a shortcode is

A shortcode is WordPress's [tag option="value"] syntax. When do_shortcode() sees one, it calls the PHP function registered for that tag and swaps the shortcode for the return value.

add_shortcode( 'hello', function() {
    return 'Hello!';
});

echo do_shortcode( 'Hi [hello]' );   // Hi Hello!
Enter fullscreen mode Exit fullscreen mode

The important part is that do_shortcode() runs every shortcode it finds in the string. That is fine for text written by editors and authors, who are trusted. It is not fine for text written by anonymous visitors, such as comments.

What JSON-LD is

JSON-LD is structured data placed in the page so search engines can understand it. A recipe page carries something like this:

<script type="application/ld+json">
{ "@type": "Recipe", "name": "...", "review": [ { "reviewBody": "..." } ] }
</script>
Enter fullscreen mode Exit fullscreen mode

WP Recipe Maker turns rated comments into entries of that review array, with the comment text as reviewBody. This block is public: every visitor and every crawler can read it.


The vulnerable code

Some of the code below is simplified to make the flow easier to follow. The file is includes/public/class-wprm-metadata.php from 10.8.1.

The data flow

comment_content
   └─▶ reviewBody                    (class-wprm-metadata.php:1016-1017)
        └─▶ $metadata['review'][]    (:1028)
             └─▶ sanitize_metadata( get_metadata($recipe) )   (:374)
                  └─▶ do_shortcode( ... )   <- sink            (:553)
Enter fullscreen mode Exit fullscreen mode

How to read Figure 2. It is a five-step chain, top to bottom. The amber card at the top is the untrusted source, and the red card at the bottom is the sink. The small gray text on each card is the line number. The dashed red bracket on the right marks the point of the whole bug: from step 1 to step 5 there is no validation or filtering of any kind.

Step 1: which comments get collected

// simplified
$comments = get_comments( array(
    'post_id'  => $recipe->parent_post_id(),   // the post the recipe card sits in
    'status'   => 'approve',                   // approved comments only
    'meta_key' => 'wprm-comment-rating',       // only comments that carry a rating
) );

foreach ( $comments as $comment ) {
    $review = array(
        '@type'      => 'Review',
        'reviewBody' => $comment->comment_content,   // raw comment text
        // ... author, reviewRating, etc.
    );
    $metadata['review'][] = $review;
}
Enter fullscreen mode Exit fullscreen mode

The plugin takes approved, rated comments from the post that contains the recipe and puts comment_content straight into reviewBody. Nothing is wrong yet. The problem is what happens to that array next.

Step 2: the sanitizer that executes

Before output, the metadata goes through sanitize_metadata(). It recurses into arrays and cleans every string it finds.

// simplified
public static function sanitize_metadata( $metadata ) {
    if ( is_array( $metadata ) ) {
        foreach ( $metadata as $key => $value ) {
            $metadata[ $key ] = self::sanitize_metadata( $value );   // recurse
        }
        return $metadata;
    }

    // string case: the vulnerable line (10.8.1, :553)
    $sanitized = strip_shortcodes( wp_strip_all_tags( do_shortcode( $metadata ) ) );
    return $sanitized;
}
Enter fullscreen mode Exit fullscreen mode

Read that one line from the inside out:

Order Function What it does
1 do_shortcode( $metadata ) Executes shortcodes and replaces them with their output
2 wp_strip_all_tags( ... ) Removes HTML tags from the result
3 strip_shortcodes( ... ) Removes any shortcode syntax still left

The developer's intent was probably "strip shortcodes and tags so only clean text reaches the JSON-LD". But execution (step 1) happens before the stripping (steps 2 and 3). The later steps only clean up output that has already been produced, so they cannot stop the shortcode from running.

An analogy: it is like opening a package and letting its contents run, and only then inspecting the wrapping paper.

Why reviewBody is the way in

sanitize_metadata() applies do_shortcode() to every string in the array. For fields like the recipe title or ingredients, that is harmless, because an administrator wrote them. reviewBody is the one place where text from anonymous visitors flows into the same array. A pipeline designed for trusted data ended up receiving untrusted data.


The attack flow

  1. An attacker posts a rated comment on the post that contains a recipe, with shortcode syntax in the body. The rating (wprm-comment-rating) is what makes the comment count as a review.
  2. The comment gets approved, either automatically or by an administrator.
  3. Someone opens the recipe page, and the plugin builds the metadata. The approved comment is now inside review.
  4. sanitize_metadata() calls do_shortcode() on the comment text, and the shortcode runs on the server.
  5. The output is written into reviewBody in the JSON-LD and is visible to every visitor and crawler.

How to read Figure 1. Three horizontal lanes (attacker, site, visitors), followed in the order of the numbered badges. The attacker appears only in step 1. Everything after that happens in the site and visitor lanes, and the curved arrows between lanes are the hand-offs: approval, then a page request, then metadata generation. Only card 5 has a red accent, because it is the point where code runs on the server. The red arrow from 5 to 6 shows that the result is delivered to ordinary visitors, not to the attacker.

A note on approval: WordPress ships with "comment author must have a previously approved comment" enabled, so a first-time commenter is often held in moderation. That raises the bar somewhat. Sites with looser moderation are exposed immediately.

Impact

  • Because the output is embedded in the page, any shortcode that returns data becomes a disclosure channel. The public advisory mentions things like attachment captions, fields of private posts, and data exposed by other installed plugins.
  • If a shortcode has side effects, it can fire on every render of the page.
  • It is not RCE. The attacker cannot run arbitrary PHP, only what the site has registered as shortcodes.

The patch (10.8.2)

// 10.8.1 (vulnerable)
$sanitized = strip_shortcodes( wp_strip_all_tags( do_shortcode( $metadata ) ) );

// 10.8.2 (fixed)
$sanitized = strip_shortcodes( wp_strip_all_tags( WPRM_Instacart::do_shortcode_safe( $metadata ) ) );
Enter fullscreen mode Exit fullscreen mode

One call changes: do_shortcode() becomes WPRM_Instacart::do_shortcode_safe().

  • do_shortcode_safe() does not execute anything. It removes the shortcode tags and keeps only the inner content (class-wprm-instacart.php:501).
  • The same comment no longer produces shortcode output in reviewBody.
  • The plugin's changelog describes the change as preventing shortcode execution from comment text in recipe metadata.

How to read Figure 3. The top row is 10.8.1 and the bottom row is 10.8.2. In each row, only the first card has a color, and the other two are gray. That is the whole patch: the first step changed and the rest did not. The red bracket under the top row says the two cleanup steps come after execution and are too late. The teal bracket under the bottom row says the fix blocks execution at the first step.

How to read Figure 4. One approved comment at the top feeds both versions. On the left (10.8.1), the highlighted reviewBody line contains the shortcode's output, which is public. On the right (10.8.2), the shortcode is stripped and never runs. The empty value on the right assumes a comment that contains only a shortcode. If the shortcode wraps some inner text, that text can remain.


What to do

  • Update WP Recipe Maker to 10.8.2 or later, preferably the latest release.
  • Until you can update, switch rated comments to manual approval and review pending and approved comments for [ and ] syntax.
  • Review which shortcodes your site registers, and check whether any of them return sensitive data.

Top comments (0)