Movable TypeとInvisible reCAPTCHA

| | コメント(0)
はじめに
このような鄙のブログの記事にコメントを寄せる奇特な方は滅多におられないものの、ロボットによるスパムコメントは時折やってくる。その対策として、今となっては珍しいreCAPTCHA V1を長年利用してきた。

reCatptchaV1.jpg ところが過日、Googleから「reCAPTCHA V1のサービスを2018年3月末で終了するのでV2以上にアップデートするように」とのメールが届いた。確かに今頃文字入力による認証というのも古めかしい。AIによる画像認識技術の進歩により、容易に突破されてしまうという話も聞く。そこで遅ればせながら、新しいreCAPTCHAを実装してみることにした。

調べてみると、最近ではreCAPTCHA V2とInvisible reCAPTCHAの二通りが使われているらしい。前者については、かなり以前から提供されているので、Movable Type用のプラグインを公開しているページもある。V1の公式プラグインを改造してV2対応にしたものとのこと(「Invisible reCAPTCHAを使う」とのタイトルが付けられているが、このプラグインで用いられているのはreCAPTCHA V2の方である)。

V1とV2では、前者が文字入力方式、後者がタイル選択方式というインターフェース上の違いがあるだけで、テンプレートへの追記内容はほぼ同じである。タグを追加した位置にreCAPTCHAが表示される。V2の場合だと以下のようになる。

reCatptchaV2.jpg これはこれで悪くないのだが、昨年になってInvisible reCAPTCHAなる新しいバージョンが公開されたとのこと。何でもGoogle独自のアルゴリズムで投稿者が人間かロボットかを判定するとの触れ込みだ。そこでこの際、このInvisible reCAPTCHAにアップデートしてみることにした。

reCatptchaV3.jpg Webで検索してみると、日本語の情報はまだ少ないが、海外のサイトには動作の仕組みと具体的な実装方法について詳細に解説したページが数多く存在している。中でも"Installing the new Invisible reCaptcha on your Website"は、ステップ・バイ・ステップの説明でとても判り易かった。

これを読むと、V2までのreCAPTCHAが、htmlドキュメント内にタグを追加して実装するのに対して、Invisible reCAPTCHAでは、投稿ボタンそのものに属性を追加することにより実装する方式に変更されていることがわかる。そのため、V1/2用のプラグインは、そのままでは利用できない。

当初は、真面目に新たなプラグインを開発することを検討したのだが、"quick-and-dirty"を生活信条とする私の性格上、今更Perlで新規にコーディングするのが面倒臭くなった。

という訳で、どこぞに利用可能なPerlのソースコードがないか検索したところ、"reCAPTCHA example in Perl"というほとんどそのまま利用できるコードが掲載されているページがあったので、参考にさせてもらうことにした。以下、備忘録としてテンプレート修正の手順とサーバー側CGIの改造内容について記載しておく。

【警 告】
以下の改造では、テンプレートだけではなくMovable TypeのCGIそのものにも手を加えています。当然、Six Apart社のサポート対象外となりますので、改造するに当たっては、あくまで自己責任でお願いします。改造の結果、発生する可能性がある総ての不具合、損失、データの破損、消失等について当方は一切の責任を負わないことを予めご承知おき願います。



テンプレートの修正
まず最初にMovable Typeの管理画面から、ヘッダー・テンプレートとコメント入力フォーム・テンプレートに以下の追記、修正を行う。詳細については、上記"Installing the new Invisible reCaptcha on your Website"を参照のこと。また、reCAPTCHAの認証キーの取得は、GoogleのreCAPTCHAサイトから行う。取得したキーを、以下のコードの「 Your Site Key 」と「 Your Secret Key 」の部分に記入する。

ヘッダー・テンプレートの<head>-</head>の間に以下を追記
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<script>
    function captchaSubmit(data) {
        document.getElementById("comments-form").submit();
    }
</script>

コメント入力フォーム・テンプレートのformタグに「 id="comments-form" 」を追記
<form method="post" action="<$MTCGIPath$><$MTCommentScript$>" name="comments_form" id="comments-form" onsubmit="if (this.bakecookie.checked) rememberMe(this)">
idとして用いる「 comments-form 」の名称は任意だが、上記ヘッダー・テンプレートの「 document.getElementById("comments-form").submit(); 」と揃える必要があることに注意。

コメント入力フォーム・テンプレートの以下の部分を削除またはコメントアウト
<input type="submit" accesskey="v" name="preview" id="comment-preview" value="確認" />
<input type="submit" accesskey="s" name="post" id="comment-submit" value="投稿" />

コメント入力フォーム・テンプレートの上記削除部分の代りに以下を追記
<button class="g-recaptcha" data-sitekey="Your Site Key" data-callback="captchaSubmit" data-badge="inline" type="submit" accesskey="s" name="post" id="comment-submit">投稿</button>


CGIの修正
以上でフロントエンド側の追加・修正は完了したので、次にバックエンド側のCGIに手を加える。
対象は、「 mt/mt-comment.cgi 」で、これは上記コメント入力フォーム・テンプレートにおいてformのaction属性に指定されている<$MTCGIPath$><$MTCommentScript$>の参照先である。このCGIは、コメント処理の本体プログラムをuseにより読み込むだけの機能しか有していない。

まず、デフォルトのCGIの末尾にあるuse部分(L10、L11)をコメントアウトする。
#!/usr/bin/perl -w

# Movable Type (r) (C) 2001-2017 Six Apart, Ltd. All Rights Reserved.
# This code cannot be redistributed without permission from www.sixapart.com.
# For more information, consult your Movable Type license.
#
# $Id$

use strict;
# use lib $ENV{MT_HOME} ? "$ENV{MT_HOME}/lib" : 'lib';
# use MT::Bootstrap App => 'MT::App::Comments';

この後ろに、上記の"reCAPTCHA example in Perl"を参考にして作成した以下のコードを追加する。L40、L44において、reCAPTCHAサイトとの接続失敗や認証失敗が発生した場合には、エラーページに遷移するようにしているので、そのURLを適宜修正すること。
use warnings;
use CGI;
use LWP::UserAgent;
use JSON::Parse 'parse_json';

my $secret = "Your Secret Key";
my $gurl = "https://www.google.com/recaptcha/api/siteverify";
my $mtuse = 'use lib $ENV{MT_HOME} ? "$ENV{MT_HOME}/lib" : \'lib\'; use MT::Bootstrap App => \'MT::App::Comments\';';

my $cgi = CGI->new ();
my $ua = LWP::UserAgent->new ();
my $response = $cgi->param ('g-recaptcha-response');
my $remoteip = $ENV{REMOTE_ADDR};
my $greply = $ua->post (
    $gurl,
    {
        remoteip => $remoteip,
        response => $response,
        secret => $secret,
   },
);

if ($greply->is_success ()) {
    my $json = $greply->decoded_content ();
    my $result = parse_json ($json);
    if($result->{success}) {
        eval ($mtuse);
    } else {
        print "Location: http://www.example.com/comment-error2.html\n\n"; #jump to your authentication error page.
    }

} else {
    print "Location: http://www.example.com/comment-error1.html\n\n"; #jump to your connection error page.
}

最後にMovable Typeの管理画面で、
設定->コミュニケーション->CAPTCHAプロバイダを「なし」
にする。
適当な記事を再構築して、このページのコメント欄の下部に表示されているInvisible reCAPTCHAのdata-badgeが表示されコメントが投稿できれば成功。


制限事項と解説
現時点のInvisible reCAPTCHAは、一つのform内では単一ボタンにしか紐付けられないようなので、コメント入力フォーム・テンプレートの確認ボタンを削除している(残しておいても認証エラーとなる)。Perlの追加コードを工夫すればなんとかなりそうだが、今のところはこれで良しとする。

追加コードのポイントは、mt-comment.cgi でのuseによるモジュール読み込みを、reCAPTCHA認証後まで遅延させることにある。このMovable Typeのモジュール(MT::Bootstrap)は、読み込み直後に自動実行されるので、認証とは関係なく無条件でコメントを受け付けてしまうからだ。遅延の具体的方法は、L19で変数$mtuseにuse部分を単なる文字列として格納し、L38の eval ($mtuse); により評価・実行するという実にストレートなものである。

これは蛇足だが、追加コードのL37( if($result->{success}) { )内や evalブロックを用いてuseしても無駄。CGIがロードされた時点で、コードのどの部分にあろうとuseによる読み込みが行われてしまうためである。これを回避する一つの方法としてrequireを使うことも可能だが、その場合、コメント処理の本体プログラムに手を加える必要がありMovable Typeのバージョンアップ時の対応が厄介になると予想される。その点、mt-comment.cgiは、単なる「バッチファイル」なので、切り分けが容易だ。また、基本的にMovable Typeのバージョンにも依存しない。

ところで追加コードのL37「 if($result->{success}) { 」の挙動については、このサーバー環境ではJSONモジュールが使えないので未確認である。Googleから受け取るJSON(下記参照)で「true」が戻る場合は0以外なので真だが、「false」が戻った場合に偽となるのだろうか? Perlには、予約語としての true も false も無いということをどこかで聞いた憶えがあるのだが...。たぶんL36の「 parse_json 」がうまくやってくれるのだろう。


JSONモジュールが使えない場合
このブログのレンタルサーバーでは、JSONモジュールが使えないので、追加コードのL34以降を以下のように変更して対応している。JSONを単一の文字列にして連想配列を作るという些か荒っぽい方法だが、Googleから受け取るJSONは極めて単純なので実用上はこれで十分だ。(*1)
if ($greply->is_success ()) {
    my $json = $greply->decoded_content ();
    $json =~ s/\{//g;
    $json =~ s/\}//g;
    $json =~ s/\r//g;
    $json =~ s/\n//g;
    $json =~ s/\":/\";/g;
    $json =~ s/\"//g;
    $json =~ s/[\s ]+//g;
    my %result = split(/[;,]/, $json);

    if($result{success} eq "true") {
        eval ($mtuse);
    } else {
        print "Location: http://www.example.com/comment-error2.html\n\n"; #jump to authentication error page.
    }

} else {
    print "Location: http://www.example.com/comment-error1.html\n\n"; #jump to connection error page.
}

この場合、L15のJSONモジュールの読み込みもコメントアウトまたは削除すること。そのままだとCGIロード時にエラーとなるので念のため。
use warnings;
use CGI;
use LWP::UserAgent;
# use JSON::Parse 'parse_json';

因みにL40で「 ": 」を 「 "; 」に置換しているのは、以下に示すようにJSONの中にタイムスタンプが含まれており、これがキーの末尾のコロンと重複するのを避けるためである。
{
  "success": true,
  "challenge_ts": "2018-01-13T13:10:07Z",
  "hostname": "www.minimalvideo.com"
}


おまけ
スパムコメントが認証に失敗した場合にGoogleから受け取るJSONは以下の通り。
{
  "success": false,
  "error-codes": [
    "missing-input-response"
  ]
}

コメント入力フォームから投稿したにもかかわらず上記のエラーとなる場合は、「 g-recaptcha-response 」の値が間違っているか欠落していることが原因の一つだ。別のサイトにInvisible reCAPTCHAを配置している時に気付いた。特に認証部分をサブルーチン化している場合には引数の受け渡しに要注意だ。


実装効果
本改造を実施した後、CGIの中にアクセスログ機能を設置して経過を観察してみた。約一週間で20件ほどのスパムコメントがあったが、総て防御できていた。当ブログの環境下では十分な効果を発揮しているようだ。


(*1)どうしても規約通りにparseしたいという潔癖症の人は、Simple JSON Parser in Perlというのもある(雀を撃つのに大砲を使うようなところもあるが)。

コメントする

pyccapをフォローしましょう

アーカイブ