DEV Community

ACS Developer
ACS Developer

Posted on Originally published at zenn.dev

Guarding the_content Is Not Enough: Six Routes That Leak Paid Content in a WordPress Paywall

Paid content in a WordPress paywall lives in post_content as plain text, and WordPress hands it out through six different exits — here are all six, the code that closes them, and the harness assertions that prove nothing leaks.

While rebuilding a per-article paid content plugin, I found that I had been implementing "hide the paid body" in entirely the wrong place. The old version only protected the body rendering (the_content), but WordPress emits that same body through six routes. This article lists those routes, shows the code that actually closed them, and records how I mechanically verified that nothing leaks.

Premise: the paid body stays in post_content as plain text

If you build a paywall with the block editor, the paid body is saved as InnerBlocks of a Paywall block. If you set save to null (a fully dynamic block), the paid body itself disappears on save. So save has to be InnerBlocks.Content, and that settles the following:

The paid body is sitting in post_content as-is. You cannot hide it at save time. You have to drop it on every output route.

If you think in terms of "cut it only when the body is rendered", this is where you leak.

The six exposure routes

An unpurchased reader's response can carry the paid body through at least these six routes:

# Route Mechanism
1 Single page the_content
2 Auto excerpt wp_trim_excerpt()the_content
3 RSS/Atom body get_the_content_feed()the_content
4 RSS excerpt the_excerpt_rss
5 oEmbed excerpt the_excerpt_embed
6 REST content.rendered WP_REST_Posts_Controllerthe_content

The important part is that 1, 2, 3 and 6 all end up passing through the_content. Close that one place and four routes are handled at once. Conversely, 4 and 5 do not pass through the_content, so they need closing individually. REST's content.raw requires edit permission, so core's own protection is enough there.

Net 1: drop the blocks in the_content at priority 5

do_blocks runs at priority 9 and wpautop at 10. Before those, parse while the content is still block markup, throw away the Paywall block and everything after it, and re-serialize. Keeping that order means the string of the paid body simply does not exist in an unpurchased reader's response (this is not hiding with CSS or removing with JS).

public function __construct() {
    // Priority 5. Split the body before do_blocks (9) and wpautop (10).
    add_filter( 'the_content', array( $this, 'filter_the_content' ), 5 );

    // Routes that do not pass through the_content.
    add_filter( 'get_the_excerpt', array( $this, 'filter_excerpt' ), 5, 2 );
    add_filter( 'the_excerpt_rss', array( $this, 'filter_excerpt_rss' ), 5 );
    add_filter( 'the_excerpt_embed', array( $this, 'filter_excerpt_rss' ), 5 );
}

public function filter_the_content( $content ) {
    if ( ! is_string( $content ) || '' === $content ) {
        return $content;
    }

    // No blocks, pass through (do not break unrelated the_content usage).
    if ( ! self::content_has_paywall( $content ) ) {
        return $content;
    }

    $post_id = self::current_post_id();

    if ( self::reader_can_read( $post_id ) ) {
        return $content;
    }

    if ( ! function_exists( 'parse_blocks' ) || ! function_exists( 'serialize_blocks' ) ) {
        // In an environment where we cannot split, drop everything rather than emit paid content.
        return '';
    }

    $split = self::split_blocks( parse_blocks( $content ) );

    if ( ! $split['found'] ) {
        return $content;
    }

    return serialize_blocks( $split['blocks'] );
}
Enter fullscreen mode Exit fullscreen mode

After truncating, place a single "Paywall block with attributes preserved but no inner content". That is what do_blocks hands to the render callback, and it becomes the box holding the purchase flow.

Nesting: drop the whole parent

If the Paywall sits inside a group or columns block, discard the entire top-level parent. Extracting only the inner part risks dragging fragments of the paid body out with the parent's decoration or layout.

foreach ( $blocks as $block ) {
    $paywall = self::find_paywall( $block ); // search nesting recursively

    if ( null === $paywall ) {
        $kept[] = $block;
        continue;
    }

    $found = $paywall;
    break;
}
Enter fullscreen mode Exit fullscreen mode

The hole in net 1: excerpts do not always pass through the_content

Auto excerpts go through wp_trim_excerpt() and therefore the_content, but the_excerpt_rss and the_excerpt_embed hand you an already-generated excerpt string. They also do not pass the post object as a second argument, so the filter has to consult the global post.

public function filter_excerpt_rss( $excerpt ) {
    return $this->filter_excerpt( $excerpt, null ); // inside: get_post( null ) → current post
}
Enter fullscreen mode Exit fullscreen mode

And an excerpt should not be "erased" but rebuilt from the free portion. An empty excerpt on index pages is a loss for both the reader and the site. A post_excerpt written by hand is respected as-is (a machine should not overwrite something written deliberately).

private function build_free_excerpt( $post ) {
    $split = self::split_blocks( parse_blocks( $post->post_content ) );
    $free  = array();

    foreach ( $split['blocks'] as $block ) {
        if ( isset( $block['blockName'] ) && in_array( $block['blockName'], self::get_block_names(), true ) ) {
            continue; // the empty Paywall block is not wanted in the excerpt either
        }
        $free[] = $block;
    }

    $text = wp_strip_all_tags( strip_shortcodes( serialize_blocks( $free ) ) );
    $text = trim( preg_replace( '/\s+/u', ' ', str_replace( array( "\r", "\n", "\t" ), ' ', $text ) ) );

    return wp_trim_words( $text, (int) apply_filters( 'excerpt_length', 55 ), apply_filters( 'excerpt_more', ' […]' ) );
}
Enter fullscreen mode Exit fullscreen mode

The output of serialize_blocks() contains block comments like <!-- wp:paragraph -->. wp_strip_all_tags() removes HTML comments along with tags, so without passing through it the comments end up inside the excerpt.

Fail closed in two places

When the information needed for a decision is unavailable, write explicitly that it must not fall back to "readable".

public static function current_post_id() {
    if ( isset( $GLOBALS['post'] ) && is_object( $GLOBALS['post'] ) && isset( $GLOBALS['post']->ID ) ) {
        return absint( $GLOBALS['post']->ID );
    }

    if ( function_exists( 'get_the_ID' ) ) {
        $post_id = get_the_ID();
        if ( $post_id ) {
            return absint( $post_id );
        }
    }

    return 0; // 0 falls back to "not readable"
}
Enter fullscreen mode Exit fullscreen mode
  • Cannot determine the post ID → return 0, and the purchase check is always false
  • Environment without parse_blocks() / serialize_blocks() → empty the body entirely

Both fall to "emit nothing when broken" rather than "leak when broken".

Net 2: check again in the render callback

For rendering routes that do not pass through net 1 (a direct render_block() call, block rendering inside a template, and so on), run the purchase check in the render callback as well. If net 1 worked, $content is already empty, so cutting twice has no side effect.

public function render( $attributes, $content = '', $block = null ) {
    $post_id = self::current_post_id();

    if ( self::reader_can_read( $post_id ) ) {
        return (string) $content; // purchasers and editors get the full body
    }

    return $this->render_box( (array) $attributes, $post_id );
}
Enter fullscreen mode Exit fullscreen mode

Old posts saved with a previous block name also need that old name in the array of block names being checked. Forget this and only the articles written with the old block leak their paid body raw. In a plugin that involves a migration, that one line is effectively a vulnerability.

Verification: assert that nothing leaked

Being correct by design is a separate question from actually not leaking, so the test harness asserts per route that a marker string from the paid body does not appear in the output.

$excerpt = apply_filters( 'get_the_excerpt', '', $paid_post );
t( '* auto excerpt contains no paid body', false === strpos( $excerpt, ACSCW_TEST_PAID ), $excerpt );
t( 'auto excerpt is built from the free portion', false !== strpos( $excerpt, '無料で読める' ), $excerpt );

$rss = apply_filters( 'the_excerpt_rss', '<p>' . ACSCW_TEST_PAID . '</p>' );
t( '* RSS excerpt contains no paid body', false === strpos( $rss, ACSCW_TEST_PAID ), $rss );

$embed = apply_filters( 'the_excerpt_embed', '<p>' . ACSCW_TEST_PAID . '</p>' );
t( '* oEmbed excerpt contains no paid body', false === strpos( $embed, ACSCW_TEST_PAID ) );

// feed body and REST content.rendered both pass through the_content
$feed = apply_filters( 'the_content', $paid_post->post_content );
t( '* feed body contains no paid body', false === strpos( $feed, ACSCW_TEST_PAID ) );

// nested case
t( 'a nested Paywall does not leak the paid body', false === strpos( $nested_html, ACSCW_TEST_PAID ) );

// the other direction: not over-removing
t( 'a hand-written excerpt is not rewritten', '著者が書いた抜粋。' === $manual_excerpt, $manual_excerpt );
t( "a purchaser's excerpt is not rewritten", '' === $paid_excerpt );
Enter fullscreen mode Exit fullscreen mode

The point of the RSS and oEmbed assertions is that they deliberately pass in a string containing the paid body as input, so the moment a filter lets it through, the test fails. Re-running this harness while writing the article gave PASS 160 / FAIL 0 (PHP 8.5.7, CLI).

You have to write just as many assertions on the "not over-removing" side (hand-written excerpts, purchasers' excerpts) as on the "does not leak" side — otherwise an implementation that blanks everything turns the suite green.

Summary

  • In a block editor paywall, the paid body remains in post_content as plain text. The place to protect is not save time but every output route
  • Dropping the blocks in the_content at priority 5 (before do_blocks) handles four routes at once: single page, auto excerpt, feed body, and REST content.rendered
  • The remaining the_excerpt_rss / the_excerpt_embed do not pass through the_content, so close them individually. Do not blank excerpts — rebuild them from the free portion
  • Fail closed when the post ID is unavailable or the block API is missing. Forget the compatibility array of old block names and only old posts leak
  • Write verification in pairs, "does not leak" and "does not over-remove", and feed in input containing the paid body so a pass-through is detected

A CSS blur or a JS truncation is not protection as long as the body is in the response. Remove it from the response first, then draw the box — do it in that order and missing routes become mechanically detectable too.


I publish verification records and related plugins on ACS Developer.

Originally published in Japanese on Zenn: https://zenn.dev/acs_developer/articles/wp-paywall-paid-content-leak-paths

Top comments (0)