カテゴリー: apache

「Jailhouse Lock」の運用・トラブルシューティング(mod_rewriteで詰まったところ)

この記事の修正となります。

Webサーバーを公開していると、日常的に /wp-admin や /.git、生IPアドレスへのスキャン(Probe)が大量に飛んできます。

単に 404 や 403 を返すだけでも防御にはなりますが、今回は「悪意あるスキャナーやボットを検知し、専用のトラップ(檻)ページへ自動転送・隔離する」という多層防御システム(通称: Jailhouse Lock)を Apache + ModSecurity で構築しました。

構築の過程でハマった「設定の競合」と「ModSecurityのフェーズ問題」の解決策を記録として残します。

1. 目的

  1. 不正スキャンの隔離:
    隠しファイル(/.git)、WordPress探索(/wp-admin)、CGI探索(/cgi-bin)、生IP直撃などの不審なリクエストを検知し、アプリケーション(Rails/Node/PHP等)へ届く前に専用のトラップページ(檻)へ 302 Redirect する。
  2. 正常通信の保護:
    通常のブラウザからのアクセスや Let's Encrypt(ACME チャレンジ)などの正常な通信に一切影響を与えない。
  3. 無駄なログ・負荷の削減:
    ボットによる無駄なリクエスト処理コストを最小限に抑える。

2. 最初の設定と準備

(1) トラップページ(檻)ディレクトリとファイルの準備

トラップ先のコンテンツを配置するディレクトリとダミーファイルを作成します。

# 檻となるディレクトリの作成
sudo mkdir -p /var/www/jailhouse_trap

# トラップページの作成(例: Git用、WordPress用、CGI用、生IP/ボット用)
sudo touch /var/www/jailhouse_trap/git.html
sudo touch /var/www/jailhouse_trap/login.html
sudo touch /var/www/jailhouse_trap/cgi.html
sudo touch /var/www/jailhouse_trap/topgear.html

# 権限の調整
sudo chown -R www-data:www-data /var/www/jailhouse_trap

(2) Apache VirtualHost の基本設定(修正後)

諸々ハマったあとで最終的に決定した内容がこれです。

  • ドメイン名: example.com
  • ドキュメントルート: /var/www/my_app/public
  • トラップディレクトリ Alias: /__jailhouse_lock -> /var/www/jailhouse_trap
<VirtualHost *:443>
    ServerName example.com
    DocumentRoot /var/www/my_app/public

    # ----- 檻(トラップベースディレクトリ)の定義 -----
    <IfModule mod_alias.c>
        Alias /__jailhouse_lock /var/www/jailhouse_trap

        <Directory /var/www/jailhouse_trap>
            Options -Indexes -ExecCGI
            AllowOverride None
            Require all granted
        </Directory>
    </IfModule>

    # ----- Jailhouse Lock (mod_rewrite トラップ群) -----
    <IfModule mod_rewrite.c>
        RewriteEngine On

        # ----- 0. ループ防止および例外通過(最優先判定) -----
        # 檻(トラップページ自身)と ACME チャレンジ(SSL更新)は即座に通過
        RewriteCond %{REQUEST_URI} ^/__jailhouse_lock [NC,OR]
        RewriteCond %{REQUEST_URI} ^/\.well-known/acme-challenge/ [NC]
        RewriteRule ^ - [L]

        # ----- 1. トラップ1: 隠しファイル / ドットディレクトリ探知 -----
        # /.git, /.env, /.htaccess 等
        RewriteRule ^/\. /__jailhouse_lock/git.html [R=302,L,E=dontlog:1]

        # ----- 2. トラップ1.5: WordPress探知 -----
        # /wp-admin, /wp-admin/*, /wordpress配下, /wp-login.php を隔離
        RewriteRule ^/(wp-admin|wordpress)(/.*)?$ /__jailhouse_lock/login.html [R=302,L,NC,E=dontlog:1]
        RewriteRule ^/wp-login\.php$ /__jailhouse_lock/login.html [R=302,L,NC,E=dontlog:1]

        # ----- 3. トラップ2: CGI探索者探知 -----
        # /cgi-bin配下、または .cgi, .pl, .py 拡張子を隔離
        RewriteRule ^/(cgi-bin|cgi-sys|cgi-mod)(/.*)?$ /__jailhouse_lock/cgi.html [R=302,L,NC,E=dontlog:1]
        RewriteRule \.(cgi|pl|py)$ /__jailhouse_lock/cgi.html [R=302,L,NC,E=dontlog:1]

        # ----- 4. トラップ3: 生IP直撃 / Hostヘッダー不正判定 -----
        # HostヘッダーがIPアドレス形式、または空の場合
        RewriteCond %{HTTP_HOST} ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?$ [OR]
        RewriteCond %{HTTP_HOST} ^$
        RewriteRule ^.*$ /__jailhouse_lock/topgear.html [R=302,L,E=dontlog:1]

        # ----- 5. トラップ4: 悪質ボット隔離 -----
        RewriteCond %{ENV:bad_bot} ^1$
        RewriteRule ^.*$ /__jailhouse_lock/topgear.html [R=302,L,E=dontlog:1]

    </IfModule>
</VirtualHost>

3. 想定外の動きと原因(ハマりポイント)

構築中、/wp-login.php は正常に 302 で login.html に転送されるものの、/wp-admin や /wp-admin/admin-ajax.php などのディレクトリ配下へのアクセスが 404 Not Found になる現象が発生しました。

原因の調査で分かった「罠」は以下の2点です。

原因1:ModSecurity(Phase 1)による先行拒否

過去に ModSecurity 側に「WordPress 探索者を 404 で弾く」独自ルール(phase:1)を記述していました。

# [原因となった旧 ModSecurity ルール]
SecRule REQUEST_URI "@rx /(?:wordpress|wp-admin)" \
    "id:10002, phase:1, deny, status:404"

ModSecurity の phase:1(リクエストヘッダー受信直後)は、Apache の mod_rewrite 処理(Phase 2 相当)よりも前に実行されます。
そのため、mod_rewrite でリダイレクト(302)をかける前に ModSecurity が 404 を返してリクエストを終了させていました。

原因2:Alias と mod_rewrite のパス展開の競合

当初 /wp-admin を Alias /wp-admin /var/www/jailhouse_trap/login.html で処理しようとしたところ、/wp-admin/admin-ajax.php へのアクセスが /var/www/jailhouse_trap/login.html/admin-ajax.php という存在しないファイルパスへ展開され、Apache 内部で 404 が発生していました。

4. 修正内容

  1. ModSecurity 側の旧ルールの撤去:
    ModSecurity 側で 404 拒否していた旧ルールを削除し、URI 判定と転送ロジックをすべて mod_rewrite へ統一・一元化しました。
  2. Alias から mod_rewrite(正規表現捕獲)への切り替え:
    サブパスを持つ可能性のあるディレクトリトラップ(wp-admin や cgi-bin)は、Alias ではなく RewriteRule の正規表現 ^/(wp-admin|wordpress)(/.*)?$ を使用して配下ファイルごと一網打尽に捕捉するように変更しました。

5. 修正後の検証テスト

設定反映後、curl を使用して各エンドポイントの挙動をテストしました。

1 WordPress探索のテスト

# 末尾スラッシュなし
curl -i -A "Mozilla/5.0" "https://example.com/wp-admin"

# 配下ファイル
curl -i -A "Mozilla/5.0" "https://example.com/wp-admin/admin-ajax.php"

【結果】
いずれも HTTP/1.1 302 Found が返り、Location: [https://example.com/__jailhouse_lock/login.html](https://example.com/__jailhouse_lock/login.html) へ正しく誘導されました。

2 CGI探索のテスト

curl -i -A "Mozilla/5.0" "https://example.com/cgi-bin/test.cgi"

【結果】
HTTP/1.1 302 Found で Location: [https://example.com/__jailhouse_lock/cgi.html](https://example.com/__jailhouse_lock/cgi.html) へ誘導。

3 生IPアドレスアクセスのテスト

curl -i -k "https://192.0.2.1/"

【結果】
HTTP/1.1 302 Found で Location: [https://192.0.2.1/__jailhouse_lock/topgear.html](https://192.0.2.1/__jailhouse_lock/topgear.html) へ誘導後、ModSecurity との連携により無効化完了。

4 正常アクセス・ループ防止テスト

# 正常なブラウザ通信
curl -i -A "Mozilla/5.0" "https://example.com/"

# トラップページへの直接アクセス(ループ確認)
curl -i -A "Mozilla/5.0" "https://example.com/__jailhouse_lock/git.html"

【結果】
いずれもリダイレクトループを起こさず HTTP/1.1 200 OK が返り、正常なコンテンツが表示されました。

まとめ

今回のハマりポイントを通して、「WAF (ModSecurity) と Apache (mod_rewrite) の処理フェーズの違い」を理解することの重要性を痛感しました。

多くのクローラーは

  • IPアドレス直打ちである
  • ヘッダが欠損している
  • wp-adminやwp-loginを真っ先に狙ってくる
  • 隠しファイルを執拗に狙う

が私の経験則です。であれば、

  • IPアドレス直打ちやヘッダの欠損はまともにリクエストをさせない。(前段で弾く)
  • 存在しないパスや隠しファイルはダミーページに飛ばす

で、多くのノイズを減らすことができます。

Redmine 6.x移行記(後編)犯人はGooglebotだった。Apacheの410 GoneでPassengerを守るまで

前回の記事では、Redmine 6.1への移行後に Passenger の CPU 使用率が異常に高止まりし、production.log と netstat を追い始めたところまでを書きました。

調べていく中で、

  • 削除した Knowledgebase へのアクセス
  • sort= や set_filter= を含むチケット一覧
  • PDF や CSV、Atom のエクスポート要求

といった、一見すると関連性のないリクエストが大量に飛んできていることが分かってきます。そして最後に、

「もしかしてクローラーなのでは?」

という疑問が残りました。今回は、その続きを書いていこうと思います。

アクセス元を確認してみる

まずは production.log とアクセスログを突き合わせながら、アクセス元を確認してみました。

すると、見えてきたのは見慣れた IP アドレスです。

66.249.xx.xx

Googlebot でした。しかも一度や二度ではありません。HTTPS のセッションを何十本も張りながら、次々とリクエストを投げています。

しかもアクセスされていたのは、削除した Knowledgebase だけではありません。チケット一覧についても、

sort=
page=
set_filter=

といったパラメータ違いを延々と巡回しています。さらに、

issues.pdf
issues.csv
issues.atom

まで取得しようとしていました。普段ブラウザで Redmine を使っていると、これらは「便利な機能」という認識です。

ですが、サーバー側から見ると話は変わります。例えば PDF の生成であれば、データベースからチケットを取得し、テンプレートを組み立て、PDF をレンダリングし、レスポンスを返す。

HTML を返すだけとは比較にならないくらい重い処理になります。

つまり Googlebot は、リンクがあるから辿っているだけなのですが、Passenger からすると重い仕事ばかり持ち込まれている状態でした。

404でもPassengerは起きてしまう

Knowledgebase のアクセスについても同じです。

「存在しないページなんだから404で終わりでは?」

最初はそう考えていました。しかし実際には、Rails が起動し、ルーティングを行い、存在しないことを確認し、404 を返します。404 を返すこと自体は正しい動作です。ですが、その404を返すためにも Passenger は仕事をしています。

これでは、存在しないページへのアクセスであっても CPU を使い続けることになります。

ならばRailsまで届かせなければいい

ここで考え方を変えました。Rails が重いのではありません。Rails に仕事を渡してしまっていることが問題です。

であれば、Rails が起きる前にApacheで止めればいい。Apache を使っている以上、一番軽いのは Apache の段階で処理してしまうことです。

そこで mod_rewrite を使うことにしました。バーチャルホストの.confを以下のように変えていきます。

RewriteRule ^/projects/.*/knowledgebase - [G,L]
RewriteRule ^.*/knowledgebase - [G,L]

まずは削除済みの Knowledgebase。続いて、

RewriteCond %{HTTP_USER_AGENT} (Googlebot|GoogleOther|bingbot|Baiduspider) [NC]
RewriteRule ^/(projects/[^/]+/)?issues\.(pdf|csv|atom)$ - [G,L]

PDF、CSV、Atom のエクスポート。さらに、

RewriteCond %{QUERY_STRING} (sort=|set_filter=|per_page=) [NC]
RewriteRule ^/(projects/[^/]+/)?issues - [G,L]

検索エンジンが総当たりしている動的クエリも Apache 側で受け止めることにしました。

404ではなく410を選んだ理由

404でも目的は達成できます。ですが、今回は410 Goneを選びました。

404エラーは、「今は見つからない」という意味です。検索エンジンから見ると、

「また後で来れば復活しているかもしれない」

という扱いになります。一方、410 Gone は、

「もう永久にありません。」

という宣言です。今回削除した Knowledgebase は、将来復活させる予定はありません。だったら、その意思を HTTP ステータスとして返した方が正確です。

robots.txtも追加した

もちろん、Apache の設定だけではありません。今後の巡回そのものを減らすため、robots.txt にも、

Disallow: /projects/*/knowledgebase*
Disallow: /*?*sort=
Disallow: /*?*set_filter=

などを追加しました。ただし、ここは誤解しやすいところです。robots.txt は即効性のある仕組みではありません。

既に巡回予定へ入っているリクエストは、そのまま飛んできます。まず Apache で止める。その上で robots.txt により今後の巡回を減らす。この二段構えにしました。

効果はすぐに現れた

設定を反映し、Passenger(mod_passengerなのでapacheそのもの)を再起動します。

その後、再び CPU 使用率を確認すると、先ほどまで70〜90%を推移していた Passenger が数%程度で落ち着いていました。

production.log からも

  • Knowledgebase の404、
  • IssuesController の大量アクセス、
  • 500.html

の RoutingError は姿を消しています。試しに curl から Googlebot の User-Agent を付けてアクセスしてもApache が Rails を起動することなく、即座に

HTTP/1.1 410 Gone

を返しました。狙い通りです。Passengerに到達すること無くは何も仕事をしていません。

今回学んだこと

今回改めて感じたのは、「プラグインを削除すること」と、「削除したことを検索エンジンへ伝えること」は全く別の話だということでした。

Redmine 側では綺麗にプラグインをアンインストールできていても、Google は昔の URL を覚えています。そして真面目に巡回を続けます。そのリクエストを Rails まで届けてしまえば、存在しないページであっても Passenger は起き、CPU を使います。

だからこそ、410エラーにより「もうありません。」という判断を Apache が先に行う。

今回のケースでは、この水際防衛が一番効果的でした。

Redmine に限らず、長く運用してきた Web アプリケーションでは、大きな整理やプラグインの削除を行った後、一度アクセスログを眺めてみることをおすすめします。

自分ではとっくに忘れていた URL を、検索エンジンは驚くほど律儀に覚えているものです。そして、それが思わぬサーバー負荷につながっていることも、決して珍しくありません。

余談「Apacheは実は柔軟」

これは私の持論なのですが、超大手プラットフォームや大手配信サイトではapacheは重い。nginxこそ主流だ的な記事を見かけますが筆者のように

多数のサーバを立てられない
それでもWebアプリを多数動かしたい
セキュリティも譲れない
場合はApacheの方が驚くほど柔軟なパターンがあります。その辺の話はまた改めて。

Powered by WordPress & Theme by Anders Norén