まず、下記のソースを見て欲しい。

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="profile" href="https://gmpg.org/xfn/11">
  <title>{サイトのタイトル} – {キャッチフレーズ}</title>
  <meta name="robots" content="noindex,nofollow">
  <link rel="dns-prefetch" href="//s.w.org">
  <link rel="alternate" type="application/rss+xml" title="{サイトのタイトル} » フィード" href="https://{Domain_Name}/index.php/feed/">
  <link rel="alternate" type="application/rss+xml" title="{サイトのタイトル} » コメントフィード" href="https://{Domain_Name}/index.php/comments/feed/">
  <script>
    window._wpemojiSettings = {"baseUrl":"https:\/\/s.w.org\/images\/core\/emoji\/12.0.0-1\/72x72\/","ext":".png","svgUrl":"https:\/\/s.w.org\/images\/core\/emoji\/12.0.0-1\/svg\/","svgExt":".svg","source":{"concatemoji":"https:\/\/{Domain_Name}\/app\/wp-includes\/js\/wp-emoji-release.min.js?ver=5.3.2"}};
    !function(e,a,t){ (...以下省略)
  </script>
  <script src="https://{Domain_Name}/app/wp-includes/js/wp-emoji-release.min.js?ver=5.3.2" type="text/javascript" defer=""></script>
  <style>
    img.wp-smiley,
    img.emoji { (...以下省略) }
  </style>
  <link rel="stylesheet" id="wp-block-library-css" href="https://{Domain_Name}/app/wp-includes/css/dist/block-library/style.min.css?ver=5.3.2" media="all">
  <link rel="stylesheet" id="twentytwenty-style-css" href="https://{Domain_Name}/assets/themes/twentytwenty/style.css?ver=1.1" media="all">
  <style id="twentytwenty-style-inline-css">
    (...以下省略)
  </style>
  <link rel="stylesheet" id="twentytwenty-print-style-css" href="https://{Domain_Name}/assets/themes/twentytwenty/print.css?ver=1.1" media="print">
  <script src="https://{Domain_Name}/assets/themes/twentytwenty/assets/js/index.js?ver=1.1" async=""></script>
  <link rel="https://api.w.org/" href="https://{Domain_Name}/index.php/wp-json/">
  <link rel="EditURI" type="application/rsd+xml" title="RSD" href="https://{Domain_Name}/app/xmlrpc.php?rsd">
  <link rel="wlwmanifest" type="application/wlwmanifest+xml" href="https://{Domain_Name}/app/wp-includes/wlwmanifest.xml"> 
  <meta name="generator" content="WordPress 5.3.2">
  <script>document.documentElement.className = document.documentElement.className.replace( 'no-js', 'js' );</script>
  <style>.recentcomments a{display:inline !important;padding:0 !important;margin:0 !important;}</style>
</head>

 これは第1章でインストールしたWordPressサイトのホームURLを表示した時のHTMLソースの<head>要素だ。なお、デフォルトインストールなので、使用しているテーマはTwentyTwentyである。
 第1章で色々秘匿化したものの、こうやってソースを覗かれた時点で、このサイトがWordPressで作成されていることがバレバレなことがわかるだろうか?
 端的には、<meta name="generator" content="WordPress 5.3.2">の記述によって、生成元のアプリがWordPress 5.3.2だと宣言されているし、他にも読み込まれる<script><link>タグのリソースURLにwp-includes等のディレクトリパスが含まれているので、そこからWordPressのインストールパスまでが露見してしまっている。

 このままではWordPressの秘匿化は到底成しえないので、ここからはテーマ側をカスタマイズしていく必要がある。まぁ、バンドルテーマのTwentyシリーズ以外のWordPressっぽさが排除されているテーマを使用してしまえば一番手っ取り早いのだが、このチャレンジでは、あえてTwentyシリーズのテーマを秘匿化することで、他のテーマをカスタマイズする時の知見になればと考えている。

テーマの独自拡張のコツ

 WordPressのテーマは各テーマディレクトリ内のfunctions.phpで様々な制御ができることは広く知られている。しかし、functions.phpの1ファイルのみにテーマの全制御系を詰め込んでしまうと、保守がしづらくなってしまう。バンドルテーマであるTwentyシリーズでは、functions.phpから機能ごとにクラス化された外部ファイルをrequireする仕組みを取り入れているものの、一部のフィルタリング処理が併記されているので、全体的な処理が把握しづらい。
 ちなみに、私がオリジナルテーマを作成する場合、テーマ制御系の全処理をクラス化してしまい、functions.phpはディスパッチャーとして、クラスのオートロードとインスタンス化のみを行うような建付けにしている。例えば、下記のような構成である。

WordPressThemeName/
 ├ vendor/
 │ ├ composer/
 │ └ autoload.php 
 ├ libs/
 │ ├ wpTheme.php (抽象クラス=基底クラス: abstract class wpTheme)
 │ ├ ThemeName.php (wpThemeを継承したメインクラス: class ThemeName extends wpTheme)
 │ ├ actions.php (アクションフックのみをメソッドとして集約した、メインクラスのtrait)
 │ ├ filters.php (フィルターフックのみをメソッドとして集約した、メインクラスのtrait)
 │ └ apis.php (独自テンプレート関数をAPIとして集約した、メインクラスのtrait)
 ├ functions.php (ディスパッチャースクリプト=クラスのオートロード等)
 └ index.php (テーマのインデックス)

 ディスパッチャースクリプトとしての役目だけを持ったfunctions.phpは、下記のようにシンプルな構造になる。

<?php
defined( 'MS_THEME_VERSION' ) or define( 'WP_THEME_VERSION', '1.0.0' );
defined( 'MS_THEME_SLUG' ) or define( 'WP_THEME_SLUG', 'ThemeName' );

require_once( __DIR__ . '/vendor/autoload.php' );

use mywp\ThemeName;

$class = 'mywp\ThemeName';

if ( class_exists( 'mywp\ThemeName' ) ) {
    ThemeName::get_object();
} else {
    trigger_error( "Unable to load class: $class", E_USER_WARNING );
    exit;
}

 この構成はプラグインでも同じように使えるうえ、機能を細分化したり追加したりするのは全てtraitに集約してしまえるので、クラスの管理がかなり楽になる。この辺りの詳しいことは近いうちに別記事で紹介したいと思う。

 さて、話が飛んでしまったので元に戻そう。
 今回はバンドルテーマ「TwentyTwenty」をカスタマイズするのだが、functions.phpにはあまり手を加えたくない。なぜなら、元ソースの処理が読みづらくなったりして保守しづらくなるからだ。ということで、オリジナルのfunctions.phpの末尾に下記の記述を追加してカスタマイズを進めることにする。

$concealer_path = $_SERVER['DOCUMENT_ROOT'] . '/conceal.php';
if ( file_exists( $concealer_path ) ) {
    require $concealer_path;
}

 次に、秘匿化処理用のファイルconceal.phpをドキュメントルート直下に作成する。

$ cd {Document_Root_Path}
$ touch conceal.php

 さらに、.htaccessconceal.phpへの直接アクセスを抑止する設定を追加しておくと万全だ。

# ---
# 8. ドキュメントルート直下のconceal.phpは、
# 接続元が許可ホスト以外の場合は、404レスポンスを返す
# ---
RewriteCond %{ENV:is_allow} !^true$
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^conceal.php$ - [R=404,L]

 これで、以後テーマ側には手を入れる必要がなくなり、conceal.phpだけでテーマの秘匿化処理を行うことができるようになる。このconceal.phpは基本的にテーマに依存することなく共通の処理を行うので、およそどんなテーマでもfunctions.phprequireしてしまえば同等の秘匿化効果が得られるようになる。
 本当は、このconceal.phpで行うような処理はプラグイン化してしまうのが最適解なのだが、今回はテーマの秘匿化におけるカスタマイズポイントを総覧するという目的があるので、この方法で進めていくことにする。

テーマ名とテーマディレクトリパスを秘匿化する

 テーマ名がTwentyTwentyだと、そこからそのサイトがWordPress建てであることが類推されてしまう可能性がある。また、テーマ名にwp-のような接頭辞がついているテーマも同様だ。そこで、テーマ名とそのディレクトリパスを秘匿化したい。ただし、直接テーマのディレクトリ名を変更してしまうと、配布されているテーマを利用していた場合に、テーマのアップデート時などに不都合が発生することが起こり得る。今回は、オリジナルのテーマディレクトリパスはそのままにして保守性をキープした形で秘匿化を行ってみる。

 新しいテーマディレクトリ名を/assets/viewにしてみる。まず、conceal.phpに次のフィルターフックを追加する。

<?php
// 新しいテーマディレクトリ名の設定
defined( 'MY_THEME_DIR' ) or define( 'MY_THEME_DIR', 'view' );
add_filter( 'theme_root_uri', function( $theme_root_uri, $siteurl, $stylesheet_or_template ) {
    return is_admin() ? $theme_root_uri : WP_CONTENT_URL .'/'. MY_THEME_DIR;
}, 10, 3 );
add_filter( 'stylesheet_directory_uri', function( $stylesheet_dir_uri, $stylesheet, $theme_root_uri ) {
    return is_admin() ? $stylesheet_dir_uri : $theme_root_uri;
}, 10, 3 );
add_filter( 'template_directory', function( $template_dir, $template, $theme_root ) {
    return is_admin() ? $template_dir : WP_CONTENT_DIR .'/'. MY_THEME_DIR;
}, 10, 3 );
add_filter( 'template_directory_uri', function( $template_dir_uri, $template, $theme_root_uri ) {
    return is_admin() ? $template_dir_uri : $theme_root_uri;
}, 10, 3 );

 次に、.htaccessに新しいテーマディレクトリパスを実体であるテーマディレクトリtwentytwentyにリライトする設定を行う。合わせて、テーマ内の静的リソースへのアクセス制限も追加する。

# ---
# 9. テーマディレクトリパスへのルーティング設定
# テーマ内の静的リソースへのアクセスは制限する
# ---
RewriteCond %{ENV:is_allow} !^true$ [OR]
RewriteCond %{ENV:is_allow_referer} !^true$
RewriteRule ^assets/view/(^.(.*)|readme.txt|screenshot.png|package(|-lock).json)$ - [R=404,L]
RewriteCond %{ENV:is_allow} ^true$ [OR]
RewriteCond %{ENV:is_allow_referer} ^true$
RewriteRule ^assets/view/(.*)$ /app/wp-content/themes/twentytwenty/$1 [L]

 これでテーマディレクトリが擬似パスに変更され、WordPressページのHTML内の<head>要素中のリソースパスhttps://{Domain_Name}/assets/themes/twentytwenty/style.cssなどは、https://{Domain_Name}/assets/view/style.css等に書き換わる。
 また、許可された接続元以外からはhttps://{Domain_Name}/assets/view/readme.txtなどを覗かれることもなくなる。

<head>要素のクリーンアップと秘匿化

 WordPressが生成するHTMLの<head>要素には、そのサイトがWordPressを使っていることを匂わすタグがデフォルトでいくつか出力される。下記がその一例だ。

  • <link rel="dns-prefetch" href="//s.w.org"> 等のWordPress専用のCDNを利用する際のパフォーマンスを向上するためにDNSを先読みする(DNSプリフェッチ)タグ
  • <link rel="alternate" type="application/rss+xml" title="{サイトのタイトル} » フィード" href="https://{Domain_Name}/feed/"> 等の投稿やコメントのXMLフィード出力用のタグ
  • <script src="https://{Domain_Name}/app/wp-includes/js/wp-emoji-release.min.js?ver=5.3.2" type="text/javascript" defer=""></script> 等のWordPress専用絵文字を利用するためのタグ
  • <link rel="stylesheet" id="wp-block-library-css" href="https://{Domain_Name}/app/wp-includes/css/dist/block-library/style.min.css?ver=5.3.2" media="all"> WordPress5.x以降で実装されたブロックエディタ「Gutenberg」用のスタイルシート読み込みタグ
  • <link rel="https://api.w.org/" href="https://{Domain_Name}/index.php/wp-json/"> WP REST APIを利用するためのタグ
  • <link rel="EditURI" ...<link rel="wlwmanifest" ... は外部投稿アプリ連携用のタグ
  • <meta name="generator" content="WordPress 5.3.2"> はこのページはWordPressが生成していることを宣言するタグ

 基本的に上記にリスト化したタグは、サイトを秘匿化するにあたっては全て出力を抑止するべきだろう。とは言っても、コメント機能やブロックエディタを利用するサイトを構築する場合は、切り捨てられないタグも出てくるので、そこは秘匿化効果が限定的になることを認識しながら取捨選択してほしい。

 私見になるが、特にインタラクティブ性を売りにするようなWebサービスでもない限りはWordPressサイトでコメント機能を有効にするのは無駄だと感じている。よっぽどのPVとAUがなければほとんどコメントなどつかないにもかかわらず、常時スパムの温床になるため、Akismetsなどのプラグインが必須になってリスクばかりが高くなるからだ。コメントのやり取りをしたいのならば、投稿をSNSに引用してそちらでやった方が、記事拡散力も高く有用だと思えるのだ。
 という個人的嗜好性もあって、今回のチャレンジではコメント機能は利用しないという方向性で、原則的にWordPressを匂わすタグは全て出力しないようにする。

 なお、twentytwentyなどのバンドルテーマTwentyシリーズでは、<head>要素内に<link rel="profile" href="https://gmpg.org/xfn/11">というタグがある。これ自体はWordPressを匂わすものではないが、特段必要性もないタグなので、削除してしまいたい。しかし、この記述はテーマディレクトリ内のheader.php中にハードコーディングされているので、WordPressのフィルタ関数等では削除できない。まぁ、手取り早く対応するならheader.phpから該当する記述を削除するのが良いだろう。
 今回は、極力オリジンソースには手を加えたくないので、conceal.php側で強引に削除している。

defined( 'USE_REST_API' ) or define( 'USE_REST_API', false );

add_filter( 'wp_resource_hints', function( $hints, $relation_type ) {
    if ( 'dns-prefetch' === $relation_type ) {
        return array_diff( wp_dependencies_unique_hosts(), $hints );
    }
    return $hints;
}, 10, 2 );

add_action( 'after_setup_theme', function() {
    global $_wp_theme_features;
    // Prevent output all feed links
    if ( array_key_exists( 'automatic-feed-links', $_wp_theme_features ) ) {
        unset( $_wp_theme_features['automatic-feed-links'] );
    }
    // Disable comment
    if ( array_key_exists( 'html5', $_wp_theme_features ) ) {
        unset( $_wp_theme_features['html5']['comment-form'] );
        unset( $_wp_theme_features['html5']['comment-list'] );
    }
    ob_start( function( $buffer ) {
        // Remove the `<link rel="profile" href="https://gmpg.org/xfn/11">`
        $buffer = str_replace( "<link rel=\"profile\" href=\"https://gmpg.org/xfn/11\">\n", '', $buffer );
        // Remove the `id='twentytwenty-*'` attributes
        $buffer = preg_replace( '/id=(\'|")twentytwenty\-.*?(\'|")/', '', $buffer );
        return $buffer;
    } );
}, PHP_INT_MAX );

add_action( 'shutdown', function() {
    ob_end_flush();
}, PHP_INT_MAX );

add_action( 'init', function() {
    // Remove feed links
    remove_action( 'wp_head', 'feed_links', 2 );
    remove_action( 'wp_head', 'feed_links_extra', 3 );
    // Remove emoji
    remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
    remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
    remove_action( 'wp_print_styles', 'print_emoji_styles' );
    remove_action( 'admin_print_styles', 'print_emoji_styles' );
    remove_filter( 'the_content_feed', 'wp_staticize_emoji' );
    remove_filter( 'comment_text_rss', 'wp_staticize_emoji' );
    remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
    // Remove Others
    remove_action( 'wp_head', 'rsd_link' );
    remove_action( 'wp_head', 'wlwmanifest_link' );
    remove_action( 'wp_head', 'adjacent_posts_rel_link_wp_head' );
    remove_action( 'wp_head', 'wp_generator' );
    remove_action( 'wp_head', 'rel_canonical' );
    remove_action( 'wp_head', 'wp_shortlink_wp_head' );
    // Remove Embeds
    remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
    remove_action( 'wp_head', 'wp_oembed_add_host_js' );
    if ( ! USE_REST_API ) {
        // Remove rest api links
        remove_action( 'wp_head', 'rest_output_link_wp_head' );
    }
} );

add_action( 'widgets_init', function() {
    // Remove recent comment style
    global $wp_widget_factory;
    remove_action( 'wp_head', [ $wp_widget_factory->widgets['WP_Widget_Recent_Comments'], 'recent_comments_style' ] );
} );

add_filter( 'rest_pre_dispatch', function( $result, $wp_rest_server, $request ) {
    if ( USE_REST_API || ! is_admin() ) {
        $namespaces = $request->get_route();
        // oembed
        if ( strpos( $namespaces, 'oembed/' ) === 1 ) {
            return $result;
        }
        // Jetpack
        if ( strpos( $namespaces, 'jetpack/' ) === 1 ) {
            return $result;
        }
        // BlockEditor
        if ( current_user_can( 'edit_posts' ) ) {
            return $result;
        }
    }
    return new WP_Error( 'rest_disabled', __( 'The REST API on this site has been disabled.' ), [ 'status' => rest_authorization_required_code() ] );
}, 10, 3 );

add_action( 'wp_enqueue_scripts', function() {
    if ( ! is_admin() ) {
        // Remove script tags for block editor
        wp_dequeue_style( 'wp-block-library' );
    wp_dequeue_style( 'wp-block-library-theme' );
    }
} );

 REST APIの利用だけは限定的に切り替えられるようにグローバル定数を設けてある。利用する場合はホワイトリスト形式で許可する名前空間をマッチさせる必要がある。
 そして、クリーンアップ後の<head>要素のHTMLソースは下記のようになった。

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{サイトのタイトル} – {キャッチフレーズ}</title>
  <link rel="stylesheet" href="https://{Domain_Name}/assets/view/style.css?ver=1.1" media="all">
  <style>(TwentyTwentyテーマのインラインスタイル...)</style>
  <link rel="stylesheet" href="https://{Domain_Name}/assets/view/print.css?ver=1.1" media="print">
  <script src="https://{Domain_Name}/assets/view/assets/js/index.js?ver=1.1" async=""></script>
  <script>document.documentElement.className = document.documentElement.className.replace( 'no-js', 'js' );</script>
</head>

 だいぶスッキリして、もはやWordPressの面影はない。

wp-includesとwp-adminディレクトリの秘匿化

 WordPressでは、wp-includesディレクトリとwp-adminディレクトリの下にあるCSSやJavaScript等の静的リソースを読み込んでページを表示するケースが多い。これらのディレクトリパスを持ったリソースが使用されると、ページのHTML内にそのリソースパスが出力されてしまい、せっかくWordPressのインストールディレクトリを変更しているのにインストールパスまでが露見してしまい、セキュリティ的にも都合が悪い。
 そこで、それらwp-includes/とwp-admin/へのルーティングパスを、それぞれassets/inc/とassets/manage/に変更する。まずは.htaccessに下記の設定を追加しよう。

# ---
# 10. wp-includes/とwp-admin/ディレクトリパスへのルーティング設定
# ---
RewriteRule ^assets/inc/(.*)$ /app/wp-includes/$1 [L]
RewriteRule ^assets/manage/(.*)$ /app/wp-admin/$1 [L]

 そして、conceal.phpに下記の関数を追加しておこう。

defined( 'ALT_WP_INCLUDES_DIR' ) or define( 'ALT_WP_INCLUDES_DIR', 'inc' );
defined( 'ALT_WP_ADMIN_DIR' ) or define( 'ALT_WP_ADMIN_DIR', 'manage' );

function filter_alt_dirs( $src ) {
    $src = preg_replace( '@/app/wp-includes/@', '/'. basename( WP_CONTENT_DIR ) .'/'. ALT_WP_INCLUDES_DIR .'/', $src );
    $src = preg_replace( '@/app/wp-admin/@', '/'. basename( WP_CONTENT_DIR ) .'/'. ALT_WP_ADMIN_DIR .'/', $src );
    return $src;
}

 これで、準備完了だ。

ページ内読み込みリソースを最適化する

 WordPressで出力されるページに読み込まれるCSSやJavaScriptといった外部リソースは、ビルトイン関数のwp_enqueue_style()wp_enqueue_script()で必要に応じて動的にインクルードされる。読み込まれるリソースは使用しているテーマやプラグインによって千差万別なので、一律で最適化するのは難しい部分でもある。
 ここでは、HTML中に出力される<link><script>タグのリソースパス(URL)を最適化しつつ、秘匿化が必要なものはフィルタリングしていく。
 前項で追加したフィルタ関数を使うのだ。conceal.phpに下記のコードを追加しよう。

add_filter( 'script_loader_src', function( $src, $handle ) {
    return filter_alt_dirs( $src );
}, PHP_INT_MAX, 2 );

add_filter( 'style_loader_src', function( $src, $handle ) {
    return filter_alt_dirs( $src );
}, PHP_INT_MAX, 2 );

 WordPressにログインした状態で、ページのHTMLを覗いてみると、/app/wp-includes/.../app/wp-admin/...のURLで読み込まれていたリソースのパスが変更されていることが確認できるだろう。

Twentyシリーズテーマ固有のフッターやメタ情報の最適化

 「TwentyTwenty」等のTwentyシリーズテーマのフッターやサイドバーには、デフォルトで『Powered by WordPress』の表記や、「メタ情報」ウィジェットによる各種WordPress関連リンクなどが設置されている。これらは、サイトを秘匿化するうえで不要なコンテンツなので、削除してしまおう。
 まず、「メタ情報」欄については、管理画面にログインして[外観]-[ウィジェット]を開き、「フッター2」のウィジェットグループから「メタ情報」のウィジェットを削除するだけだ。
 次に、フッターの『Powered by WordPress』の表記なのだが、これはtwentytwenty/footer.phpのファイル内にハードコーディングされてしまっているので、そのファイルから直接削除するのが手っ取り早くオススメする。ただ、オリジンテーマ側のファイルには極力手を加えたくないというのであれば、conceal.php'after_setup_theme'のアクションフックに下記のような処理を追加することでも対応可能だ。

add_action( 'after_setup_theme', function() {
    (...省略...)
    ob_start( function( $buffer ) {
        // Remove the `<link rel="profile" href="https://gmpg.org/xfn/11">`
        $buffer = str_replace( "<link rel=\"profile\" href=\"https://gmpg.org/xfn/11\">\n", '', $buffer );
        // Remove the `id='twentytwenty-*'` attributes
        $buffer = preg_replace( '/id=(\'|")twentytwenty\-.*?(\'|")/', '', $buffer );
        // Remove "Powered by WordPress" on the footer
        $buffer = preg_replace( "/<p class=\"powered-by-wordpress\">.*?<\!-- \.powered-by-wordpress -->/s", '', $buffer );
        return $buffer;
    } );
}, PHP_INT_MAX );

 ただし、このやり方は、将来的にオリジンテーマ側のアップデートによってフッター部分のHTML構造が変更されてしまう等で、書き換え条件がマッチしなくなると、削除されずに表示されてしまうリスクがあるので注意が必要である。

 さて、ここまでの秘匿化状態で、再度WordPressサイトチェッカーでサイトを確認してみると、

  • インストールディレクトリ直下にwp-login.phpが存在している

 以外の項目は緑色になった。これで、テーマ側の基本的な秘匿化は一通り完了だ。

 次の章では、プラグイン側の秘匿化を行っていくことにする。

→ 第3章 プラグインを隠蔽せよ