XMLRPCをフィルタリングする

WordPressのXMLRPCって便利なのですけどpingbackスパムやログイン攻撃の的になるんですよね。
スカイプでそんな事を話していたら、同じくWordPressを持ってる人に「まじ?どうすればいいの?」と言われたので、うちのブログで使っているヤツのコードを組み直して渡しました。

コードはこれ

<?php
 
class kerberos_xmlrpc_filter {
    private $config = [
        'disable-rpc' => false,
        'remove-pingback-header' => false,
        'filter' => [
            'enable' => true,
            'process' => 1, // 0 = remove method and continue WP, 1 = HTTP 403, 2 = exit
            'message' => [ // process = 1 or 2
                'enable' => true,
                'status' => 200,
                'content-mime' => 'text/plain',
                'message' => 'You don\'t have permission to access on this server.'
            ],
            'methods' => [
                // WordPress API
                'demo.sayHello'            => ['enable' => true,    'process' => 0],
                'system.multicall'        => ['enable' => false,    'process' => 0],
                'wp.getUsersBlogs'        => ['enable' => false,    'process' => 0],
                'wp.newPost'            => ['enable' => false,    'process' => 0],
                'wp.editPost'            => ['enable' => false,    'process' => 0],
                'wp.deletePost'            => ['enable' => false,    'process' => 0],
                'wp.getPost'            => ['enable' => true,    'process' => 0],
                'wp.getPosts'            => ['enable' => true,    'process' => 0],
                'wp.newTerm'            => ['enable' => false,    'process' => 0],
                'wp.editTerm'            => ['enable' => false,    'process' => 0],
                'wp.deleteTerm'            => ['enable' => false,    'process' => 0],
                'wp.getTerm'            => ['enable' => true,    'process' => 0],
                'wp.getTerms'            => ['enable' => true,    'process' => 0],
                'wp.getTaxonomy'        => ['enable' => true,    'process' => 0],
                'wp.getTaxonomies'        => ['enable' => true,    'process' => 0],
                'wp.getUser'            => ['enable' => false,    'process' => 0],
                'wp.getUsers'            => ['enable' => false,    'process' => 0],
                'wp.getProfile'            => ['enable' => false,    'process' => 0],
                'wp.editProfile'        => ['enable' => false,    'process' => 0],
                'wp.getPage'            => ['enable' => true,    'process' => 0],
                'wp.getPages'            => ['enable' => true,    'process' => 0],
                'wp.newPage'            => ['enable' => false,    'process' => 0],
                'wp.deletePage'            => ['enable' => false,    'process' => 0],
                'wp.editPage'            => ['enable' => false,    'process' => 0],
                'wp.getPageList'        => ['enable' => true,    'process' => 0],
                'wp.getAuthors'            => ['enable' => true,    'process' => 0],
                'wp.getCategories'        => ['enable' => true,    'process' => 0],    // Alias
                'wp.getTags'            => ['enable' => true,    'process' => 0],
                'wp.newCategory'        => ['enable' => false,    'process' => 0],
                'wp.deleteCategory'        => ['enable' => false,    'process' => 0],
                'wp.suggestCategories'        => ['enable' => true,    'process' => 0],
                'wp.uploadFile'            => ['enable' => false,    'process' => 0],    // Alias
                'wp.deleteFile'            => ['enable' => false,    'process' => 0],    // Alias
                'wp.getCommentCount'        => ['enable' => true,    'process' => 0],
                'wp.getPostStatusList'        => ['enable' => false,    'process' => 0],
                'wp.getPageStatusList'        => ['enable' => false,    'process' => 0],
                'wp.getPageTemplates'        => ['enable' => false,    'process' => 0],
                'wp.getOptions'            => ['enable' => false,    'process' => 0],
                'wp.setOptions'            => ['enable' => false,    'process' => 0],
                'wp.getComment'            => ['enable' => true,    'process' => 0],
                'wp.getComments'        => ['enable' => true,    'process' => 0],
                'wp.deleteComment'        => ['enable' => false,    'process' => 0],
                'wp.editComment'        => ['enable' => false,    'process' => 0],
                'wp.newComment'            => ['enable' => false,    'process' => 0],
                'wp.getCommentStatusList'     => ['enable' => false,    'process' => 0],
                'wp.getMediaItem'        => ['enable' => false,    'process' => 0],
                'wp.getMediaLibrary'        => ['enable' => false,    'process' => 0],
                'wp.getPostFormats'         => ['enable' => false,    'process' => 0],
                'wp.getPostType'        => ['enable' => false,    'process' => 0],
                'wp.getPostTypes'        => ['enable' => false,    'process' => 0],
                'wp.getRevisions'        => ['enable' => false,    'process' => 0],
                'wp.restoreRevision'        => ['enable' => false,    'process' => 0],
 
                // Blogger API
                'blogger.getUsersBlogs'     => ['enable' => true,    'process' => 0],
                'blogger.getUserInfo'         => ['enable' => true,    'process' => 0],
                'blogger.getPost'         => ['enable' => true,    'process' => 0],
                'blogger.getRecentPosts'     => ['enable' => true,    'process' => 0],
                'blogger.newPost'         => ['enable' => true,    'process' => 0],
                'blogger.editPost'         => ['enable' => true,    'process' => 0],
                'blogger.deletePost'         => ['enable' => true,    'process' => 0],
 
                // MetaWeblog API (with MT extensions to structs]
                'metaWeblog.newPost'         => ['enable' => true,    'process' => 0],
                'metaWeblog.editPost'         => ['enable' => true,    'process' => 0],
                'metaWeblog.getPost'         => ['enable' => true,    'process' => 0],
                'metaWeblog.getRecentPosts'     => ['enable' => true,    'process' => 0],
                'metaWeblog.getCategories'     => ['enable' => true,    'process' => 0],
                'metaWeblog.newMediaObject'     => ['enable' => true,    'process' => 0],
 
                // MetaWeblog API aliases for Blogger API
                // see http://www.xmlrpc.com/stories/storyReader$2460
                'metaWeblog.deletePost'     => ['enable' => true,    'process' => 0],
                'metaWeblog.getUsersBlogs'     => ['enable' => true,    'process' => 0],
 
                // MovableType API
                'mt.getCategoryList'         => ['enable' => true,    'process' => 0],
                'mt.getRecentPostTitles'     => ['enable' => true,    'process' => 0],
                'mt.getPostCategories'         => ['enable' => true,    'process' => 0],
                'mt.setPostCategories'         => ['enable' => true,    'process' => 0],
                'mt.supportedMethods'         => ['enable' => true,    'process' => 0],
                'mt.supportedTextFilters'     => ['enable' => true,    'process' => 0],
                'mt.getTrackbackPings'         => ['enable' => true,    'process' => 0],
                'mt.publishPost'         => ['enable' => true,    'process' => 0],
 
                // PingBack
                'pingback.ping'             => ['enable' => true,    'process' => 0],
                'pingback.extensions.getPingbacks'     => ['enable' => false,    'process' => 0]
            ]
        ]
    ];
 
    public function __construct() {
        if ($this->config['disable-rpc'] === true) {
            add_filter('option_enable_xmlrpc', false);
        } else {
            if ($this->config['remove-pingback-header'] === true) {
                add_filter('wp_headers', [$this, 'wp_headers']);
            }
            if ($this->config['filter']['enable'] === true) {
                add_filter('xmlrpc_methods', [$this, 'xmlrpc_methods'], 3);
            }
        }
    }
 
    public function wp_headers($headers) {
        unset($headers['X-Pingback']);
 
        return $headers;
    }
 
    public function xmlrpc_methods($methods) {
        if ($_SERVER['REMOTE_ADDR'] === '127.0.0.1') {
            return $methods;
        }
 
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            if (isset($_SERVER['POST']) === false) {
                return [];
            }
 
            foreach ($this->config['filter']['methods'] as $method_name => $method_option) {
                $postData = $postData = $_SERVER['POST'];
 
                if (strpos($postData, $method_name) !== false) {
                    if ($method_option['enable'] === false) {
                        switch ($this->config['filter']['process']) {
                            case 0:
                                unset($methods[$method_name]);
                                break 2;
                            case 1:
                                http_response_code(403);
                                if ($this->config['filter']['message']['enable'] === true) {
                                    header('Content-type: '.$this->config['filter']['message']['content-mime']);
                                    echo $this->config['filter']['message']['message'];
                                }
                                exit;
                                break 2;
                            case 2:
                                http_response_code($this->config['filter']['message']['status']);
                                if ($this->config['filter']['message']['enable'] === true) {
                                    header('Content-type: '.$this->config['filter']['message']['content-mime']);
                                    echo $this->config['filter']['message']['message'];
                                }
                                exit;
                                break 2;
                        }
                        break 1;
                    }
                }
            }
        }
 
        return $methods;
    }
}
 
new kerberos_xmlrpc_filter();
 
?>

コードは凄く簡単。
前処理はイロイロあるけど、add_filterでxmlrpc_methodsにフィルターをかける。
XMLRPCにPOSTが飛んでくると$methodsにWordPressで有効になっているメソッド一覧が入っているので、許可しない場合は$methodsから対象のメソッドを削除すればOK。
削除するだけなら処理はWordPressに引き継がれ有効なメソッドではないとのメッセージが表示される仕組み。

このコードは設定でメソッド毎に許可/却下をするだけなので、改造すればイロイロできます。
うちの場合は、公開ブラックリストの照会やデータベースへの記録・過去のデータベースからスパムかどうかの判定などをしています。

設定のdisable-rpcはXMLRPC自体を無効化してしまうので、pingback等を受け取れなくなるので注意です。

WordPressでpingbackを検証できないサイトがある

最近うちのブログにpingbackを飛ばしてきたブログがあってそのブログのpingbackがエラーコード0、エラーメッセージnullで失敗とログに記録されていました。
なんだろうー?とXMLRPCサーバーの処理を追ってみたところ、HEADエレメント内のHTML文法エラーとJavaScriptの問題でした。

HTML文法エラーはそのまんまで <meta property="og:description" content=""説明""/> など普通の文法エラーによる解析失敗。
そしてJavaScriptはpackerで難読化した物がHEAD内にあると動作不良を起こしていました。

pingbackの主な処理はwp-includes/class-wp-xmlrpc-server.phpのfunction pingback_pingで行われます。

<?php
 
public function pingback_ping( $args ) {
    ...省略...
 
    $request = wp_safe_remote_get( $pagelinkedfrom, $http_api_args );
    $remote_source = $remote_source_original = wp_remote_retrieve_body( $request );
 
    if ( ! $remote_source ) {
        return $this->pingback_error( 16, __( 'The source URL does not exist.' ) );
    }
 
    /**
     * Filters the pingback remote source.
     *
     * @since 2.5.0
     *
     * @param string $remote_source Response source for the page linked from.
     * @param string $pagelinkedto  URL of the page linked to.
     */
    $remote_source = apply_filters( 'pre_remote_source', $remote_source, $pagelinkedto );
 
    // Work around bug in strip_tags():
    $remote_source = str_replace( '<!DOC', '<DOC', $remote_source );
    $remote_source = preg_replace( '/[\r\n\t ]+/', ' ', $remote_source ); // normalize spaces
    $remote_source = preg_replace( "/<\/*(h1|h2|h3|h4|h5|h6|p|th|td|li|dt|dd|pre|caption|input|textarea|button|body)[^>]*>/", "\n\n", $remote_source );
 
    preg_match( '|<title>([^<]*?)</title>|is', $remote_source, $matchtitle );
    $title = isset( $matchtitle[1] ) ? $matchtitle[1] : '';
    if ( empty( $title ) ) {
        return $this->pingback_error( 32, __( 'We cannot find a title on that page.' ) );
    }
 
    $remote_source = strip_tags( $remote_source, '<a>' ); // just keep the tag we need
 
    $preg_target = preg_quote($pagelinkedto, '|');
 
    foreach ( $p as $para ) {
        if ( strpos($para, $pagelinkedto) !== false ) { // it exists, but is it a link?
            preg_match("|<a[^>]+?".$preg_target."[^>]*>([^>]+?)</a>|", $para, $context);
 
            // If the URL isn't in a link context, keep looking
            if ( empty($context) )
                continue;
 
            // We're going to use this fake tag to mark the context in a bit
            // the marker is needed in case the link text appears more than once in the paragraph
            $excerpt = preg_replace('|\</?wpcontext\>|', '', $para);
 
            // prevent really long link text
            if ( strlen($context[1]) > 100 )
                $context[1] = substr($context[1], 0, 100) . '&#8230;';
 
            $marker = '<wpcontext>'.$context[1].'</wpcontext>';    // set up our marker
            $excerpt= str_replace($context[0], $marker, $excerpt); // swap out the link for our marker
            $excerpt = strip_tags($excerpt, '<wpcontext>');        // strip all tags but our context marker
            $excerpt = trim($excerpt);
            $preg_marker = preg_quote($marker, '|');
            $excerpt = preg_replace("|.*?\s(.{0,100}$preg_marker.{0,100})\s.*|s", '$1', $excerpt);
            $excerpt = strip_tags($excerpt); // YES, again, to remove the marker wrapper
            break;
        }
    }
 
    ...省略...
}
 
?>

ここのwp_safe_remote_getでpingback送信元へGETリクエストを送信しHTTPヘッダーとHTMLをダウンロードします。
そして次のwp_remote_retrieve_bodyでHTMLのみのデータを変数へセット。

その後はスペース、改行、エレメントを整理しpreg_match( '|<title>([^<]*?)</title>|is'....で記事タイトルをゲット。
strip_tagsでアンカーを消してexplode( "\n\n", $remote_source );で改行毎に配列に格納します。

後はforeach ($p as $para)で配列の中からpingback送信先URLが含まれる配列を探して、配列内にあるpingback送信先URLの前後の文章を抜粋して終了の流れ。

抜粋処理周りにはフィルターが一切ないので処理の変更ができません。
packerの難読化を使っているどうしようもないサイトからのpingbackへどうしても対応したい・・って事なら処理が開始される前に通るフィルターpre_remote_sourceがあるので、これでscriptタグを全て削除すると良いかもしれません。
 
私的には・・・
packerの難読化のような何にもならない物を使っているサイトは放置しても良いと考えます。
と言うのも、packerには悪いと思いますが、後ろめたいコードを書いている多くのサイトに使われている物ですし、元のコードへ戻すのが非常に簡単で使う意味がほぼ無な物なので。

送信側の方はpingbackの送信に失敗するな?と思ったら自分のブログのコードが正常か確認を。

WordPressを完全にSSLへ移行してみた

うちのサイトは証明書の関係でPreload HSTS(HTTP Strict Transport Security)にはしていないけれど、
HSTS設定前でもかなりhttpsからのアクセスがあったのでHSTSの設定をして、常にhttpsへ接続してもらうようにました。

で、2017年1月にリリース予定のChromium 56から、パスワードやカード情報を送信するhttpなサイトは「安全ではない」と表示するようになるようです。
Chromium Blog: Moving Towards a More Secure Web

HTTP/2.0になって完全にhttpは要らないんじゃ?って感じになってきてるみたいですね。

もうそれなら完全にSSLへ移行しちゃって良いんじゃないかー!って事で、httpをhttpsへ301でリダイレクトするようにしました。
方法はいろいろあるけれど、WordPressならrewriteで良いと思う。

RewriteEngine on
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://blog.wolfs.jp$1 [R=301,L]

ちなみに、RewriteRuleのURLで最後にスラッシュを入れるとリダイレクト先でダブルスラッシュになるので注意。
例:
 RewriteRule: RewriteRule ^(.*)$ https://blog.wolfs.jp/$1 [R=301,L]
 アクセスURL: http: //blog.wolfs.jp/page-1/
 リダイレクトURL: https: //blog.wolfs.jp//page-1/

これの何が悪いかと言うと、
ダブルスラッシュになっているとWordPressのリダイレクトが働いてhttps://blog.wolfs.jp/page-1/へリダイレクトされるので合計2回リダイレクトされる事に。
かなり無駄なので注意。
RewriteBase /なんかを使うと良いけど、別にそんなの指定しなくても/を1つ抜くだけで動くから要らないとおもうw

この状態でMozillaが提供しているサイトのセキュリティ診断、Observatory by Mozillaをうけてみると・・・
Observatory 結果
うんA+でかなり良いスコアがでました。
続きを読む

WordPressのインラインJavaScriptをなくして、Content Security Policyを設定

うちのテーマは今のこのデザインになるまでは、サードパーティーのテーマを使っていたんです。
で、そのテーマには脆弱性があってXSSが有効だったんですよー((゜Д゜;))

今は1から作った独自のテーマを使っていて、汎用的な拡張性(テーマに必要な画像等のアップロード機能やAJAX)や外部を参照する不明なコードはありません。
なのでテーマ経由の脆弱性はない感じなのです。

だけれど、XSSを防ぐセキュリティ関連のヘッダーを設定しておいた方が良いって事なのでApacheにヘッダーを追加してみました。
新しく追加したのはX-Content-Type-Options、X-XSS-Protection、Content-Security-Policyの3つで、どれもXSSを防ぐのに効果が高いもの。
X-Frame-Optionsだけはブログをiframeで表示されていたサイトがあったので設定済みでしたw

設定内容はこんな感じ。

Header always set X-Frame-Options SAMEORIGIN
Header always set X-Content-Type-Options nosniff
Header always set X-XSS-Protection "1; mode=block"
Header always set Content-Security-Policy "default-src 'self' *.wolfs.jp; script-src 'self' *.wolfs.jp; child-src 'self' data: www.youtube.com; style-src 'self' 'unsafe-inline' *.wolfs.jp; img-src 'self' data: *.wolfs.jp *.gravatar.com;"

 
で、今回躓いたのはContent Security Policy(CSP)の設定。

JavaScriptやCSSは以前から1つにまとめているので大した問題ではなかったのですが、CSPで「script-src 'self' *.wolfs.jp」のようにインラインスクリプトを許可しない設定だとWordPressが出力するコメント関連のスクリプトが動かなくなっちゃう。
かと言って許可するとXSSに対して弱くなり、CSPを設定しないのとあまり変わらないような結果になります。

うちの環境で見た感じ、主にWordPressが出力するインラインJavaScriptは、
 ・コメントフォームの隠しフィールド「_wp_unfiltered_html_comment_」
 ・コメントのある「返信」ボタンのonclick
のようです。

とりあえず、その辺を無効化して無効化した物を再度使用可能にするように作っていきます。
続きを読む

WordPress 4.6で追加されたdns-prefetchを無効化する

WordPress 4.6からヘッダーに<link rel="dns-prefetch" href="//s.w.org/">が追加されるようになりました。
見つけた瞬間サイトがクラックされたのか?!とヒヤっとしたけれど、s.w.orgはWordPressのショートドメインて事がわかって一安心しましたw

うちはWordPressの絵文字とかを使っていないのでs.w.orgのDNSプリフェッチは必要なし。
なのでこれを無効化してみました。

作成したPHPコードはこれ。

<?php
 
add_filter('wp_resource_hints', function ($urls, $relation_type) {
    if (is_admin() === false) {
        if ($relation_type === 'dns-prefetch') {
            return array();
        }
    }
 
    return $urls;
}, 10, 2);
 
?>

コードは簡単でwp_resource_hintsにフィルターをかけて、管理ページ以外かつタイプがdns-prefetchの場合は定義されている内容を空っぽにして返すって感じ。
管理ページの場合は、なにかあるとダメなので引数をそのまま返すようにしてあります。

全く使わないならremove_action('wp_head', 'wp_resource_hints');の方がはやいんじゃない?って思ったんだけど、なぜかこれが効かなかったのでadd_filterで対処する荒療治に。

ちなみに、この記事を書いた時の$relation_typeの種類はdns-prefetch、preconnect、prefetch、prerenderとありました。
詳しい動作を追いたい場合はwp-includes/general-template.phpの2800行付近にfunction wp_resource_hints()があるのでそこから見てください。