1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

the_contentだけ守っても有料本文は漏れる — WordPressペイウォールの露出6経路を塞ぐ

1
Posted at

この記事は Zenn に公開した記事の再掲です(原文・最新版: https://zenn.dev/acs_developer/articles/wp-paywall-paid-content-leak-paths )。

記事単位の有料販売プラグインを作り直す過程で、「有料本文を隠す」の実装場所を全面的に間違えていたことがわかった。旧版は本文の描画(the_content)だけを守っていたが、WordPressは同じ本文を6つの経路から外へ出す。本稿はその経路の列挙と、実際に塞いだコード、そして「漏れていないこと」を機械で確かめた検証まで記録したものです。

前提: 有料本文は post_content に平文で残る

ブロックエディタでペイウォールを作る場合、有料本文は Paywall ブロックの InnerBlocks として保存される。ここで savenull(完全な動的ブロック)にすると、有料本文そのものが保存時に消えてしまう。したがって saveInnerBlocks.Content にせざるを得ず、結果として次が確定する。

有料本文は post_content にそのまま入っている。保存時に隠すことはできない。出力の全経路で落とすしかない。

「本文の描画時にだけ切る」という発想でいると、ここで漏れる。

露出する6経路

未購入者のレスポンスに有料本文が載りうる経路は、少なくとも次の6つだった。

# 経路 実体
1 シングルページ the_content
2 自動抜粋 wp_trim_excerpt()the_content
3 RSS/Atom の本文 get_the_content_feed()the_content
4 RSS の抜粋 the_excerpt_rss
5 oEmbed の抜粋 the_excerpt_embed
6 REST の content.rendered WP_REST_Posts_Controllerthe_content

重要なのは、1・2・3・6が最終的に the_content を通ること。ここを1か所塞げば4経路が同時に片づく。逆に言えば、残る4・5は the_content を通らないので個別に塞ぐ必要がある。REST の content.raw は編集権限が必要なので、コア標準の保護で足りる。

網1: the_content 優先度5でブロックごと落とす

do_blocks は優先度9、wpautop は10で走る。それより前にブロックマークアップの状態でパースし、Paywall ブロックとそれ以降を丸ごと捨ててから再シリアライズする。この順序を守ると、未購入者のレスポンスには有料本文の文字列そのものが存在しなくなる(CSSで隠す・JSで消す、ではない)。

public function __construct() {
	// 優先度5。do_blocks(9)・wpautop(10)より前に本文を分割する。
	add_filter( 'the_content', array( $this, 'filter_the_content' ), 5 );

	// 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;
	}

	// ブロックが無いなら素通し(無関係な the_content 適用を壊さない)。
	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' ) ) {
		// 分割できない環境では、有料本文を出すより丸ごと落とす方を選ぶ。
		return '';
	}

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

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

	return serialize_blocks( $split['blocks'] );
}

切り詰めたあとには「属性だけ引き継いだ中身のない Paywall ブロック」を1個置く。これが do_blocks でレンダーコールバックに渡り、購入導線の箱になる。

入れ子は親ごと落とす

Paywall がグループやカラムの内側に置かれている場合、その最上位の親ごと捨てる。内側だけ抜くと、親の装飾やレイアウトに有料本文の断片が引きずられて出る可能性があるためだ。

foreach ( $blocks as $block ) {
	$paywall = self::find_paywall( $block ); // 入れ子を再帰で探す

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

	$found = $paywall;
	break;
}

網1の穴: 抜粋は the_content を通らないことがある

自動抜粋は wp_trim_excerpt() 経由で the_content を通るが、the_excerpt_rssthe_excerpt_embedすでに生成済みの抜粋文字列を渡してくる。しかも第2引数に投稿オブジェクトを渡してこないため、フィルタ側でグローバルの投稿を見に行くことになる。

public function filter_excerpt_rss( $excerpt ) {
	return $this->filter_excerpt( $excerpt, null ); // 中で get_post( null ) → 現在の投稿
}

そして抜粋は「消す」のではなく無料部分から作り直す。ペイウォールがあるからといって一覧ページの抜粋が空になるのは、読者にとっても運営にとっても損だからだ。著者が手で書いた post_excerpt はそのまま尊重する(意図的に書いたものを機械が上書きしない)。

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; // 空の Paywall ブロックも抜粋には要らない
		}
		$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', ' […]' ) );
}

serialize_blocks() の出力には <!-- wp:paragraph --> のようなブロックコメントが含まれる。wp_strip_all_tags() はHTMLコメントもタグごと落とすので、ここを通さないと抜粋にコメントが混ざる。

fail closed を2か所に置く

判定に必要な情報が取れないとき、「読める」に倒れないことを明示的に書いておく。

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 は「読めない」に倒れる
}
  • 投稿IDが特定できない → 0 を返し、購入判定は必ず false
  • parse_blocks() / serialize_blocks() が無い環境 → 本文を丸ごと空にする

どちらも「壊れたときに漏れる」のではなく「壊れたときに出さない」側へ倒す。

網2: レンダーコールバック側でももう一度判定する

網1を通らない描画経路(render_block() の直接呼び出し、テンプレート内のブロック描画など)に備えて、レンダーコールバックでも購入判定を通す。網1が効いていれば $content は空なので、二重に切っても副作用はない。

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

	if ( self::reader_can_read( $post_id ) ) {
		return (string) $content; // 購入済み・編集権限者は全文
	}

	return $this->render_box( (array) $attributes, $post_id );
}

旧ブロック名で保存された過去記事も、判定対象のブロック名配列に旧名を入れておく。ここを忘れると、旧ブロックで書かれた記事だけ有料本文が素で載る。移行を伴うプラグインでは、この1行が実質的な脆弱性になる。

検証: 「漏れていないこと」をアサートする

設計として正しくても、実際に漏れないかは別問題なので、テストハーネス側に有料本文のマーカー文字列が出力に含まれないことを経路ごとに書いた。

$excerpt = apply_filters( 'get_the_excerpt', '', $paid_post );
t( '★自動抜粋に有料本文が含まれない', false === strpos( $excerpt, ACSCW_TEST_PAID ), $excerpt );
t( '自動抜粋は無料部分から作られる', false !== strpos( $excerpt, '無料で読める' ), $excerpt );

$rss = apply_filters( 'the_excerpt_rss', '<p>' . ACSCW_TEST_PAID . '</p>' );
t( '★RSS の抜粋に有料本文が含まれない', false === strpos( $rss, ACSCW_TEST_PAID ), $rss );

$embed = apply_filters( 'the_excerpt_embed', '<p>' . ACSCW_TEST_PAID . '</p>' );
t( '★oEmbed の抜粋に有料本文が含まれない', false === strpos( $embed, ACSCW_TEST_PAID ) );

// フィード本文・REST content.rendered はどちらも the_content を通る
$feed = apply_filters( 'the_content', $paid_post->post_content );
t( '★フィード本文に有料本文が含まれない', false === strpos( $feed, ACSCW_TEST_PAID ) );

// 入れ子ケース
t( '入れ子に置かれた Paywall でも有料本文が漏れない', false === strpos( $nested_html, ACSCW_TEST_PAID ) );

// 逆方向: 過剰に消していないこと
t( '手書き抜粋は書き換えない', '著者が書いた抜粋。' === $manual_excerpt, $manual_excerpt );
t( '購入者の抜粋は書き換えない', '' === $paid_excerpt );

RSS と oEmbed のアサートにあえて有料本文入りの文字列を入力として渡しているのがポイントで、フィルタが素通しした瞬間にFAILする。本稿の執筆時点でこのハーネスを再走した結果は PASS 160 / FAIL 0(PHP 8.5.7・CLI)。

「漏れない」側だけでなく「消しすぎない」側(手書き抜粋・購入者の抜粋)も同じ数だけ書いておかないと、全部を空文字にする実装で緑になってしまう。

まとめ

  • ブロックエディタのペイウォールでは、有料本文は post_content に平文で残る。守る場所は保存時ではなく出力の全経路
  • the_content 優先度5(do_blocks より前)でブロックごと落とせば、シングル・自動抜粋・フィード本文・REST content.rendered の4経路が同時に片づく
  • 残る the_excerpt_rss / the_excerpt_embedthe_content を通らないので個別に塞ぐ。抜粋は空にせず無料部分から作り直す
  • 投稿IDが取れない・ブロックAPIが無い場合は fail closed。旧ブロック名の互換配列を忘れると過去記事だけ漏れる
  • 検証は「漏れない」と「消しすぎない」を対で書き、有料本文入りの入力を渡して素通しを検出する

CSSのぼかしやJSでの切り詰めは、レスポンスに本文が載っている限り保護ではない。まずレスポンスから消し、そのうえで箱を描く順序にすると、経路の追加漏れも機械で検出できるようになる。


検証の記録や関連するプラグインは ACS Developer で公開しています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?