CentOS Stream 9 の Apache で、許可した拡張子だけを通し、探りを fail2ban で遮断する(サーバー管理者向け)

CentOS Stream 9 の Apache で、許可した拡張子だけを通し、探りを fail2ban で遮断する(サーバー管理者向け)

アフィリエイト広告を利用しています

このページの内容が役に立ったら X (旧twitter) でフォローして頂けると励みになります
挨拶や報告は無しで大丈夫です

さくらのVPSのCentOS Stream 9で、Web へのアクセスを fail2ban で遮断できるようにした覚書です。

前の記事で fail2ban を入れ、SSH を守りました。今回は、Web へのアクセスのうち、サーバー全体で決めておくものを設定します。許可した拡張子のファイルだけを通し、.env や設定の控えなどを次々に試す探りと、Basic 認証の総当たりは、Apache で拒んで fail2ban で遮断します。
この記事は、サーバー管理者の立場で、サーバー全体の設定を行います。サイトごとの設定は、サーバー管理者が行うサイトの conf を次の記事で、Web サイトの担当者が行う .htaccess をその次の記事で扱います。
記事の中の IP アドレス・ドメインなどは、実際のものから書き換えています。

なお本番環境で利用される場合はここにある内容だけを鵜呑みにせずセキュリティ専門家に相談されることをお勧めします。

環境

実施日2026-11-08
サーバさくらのVPS 2G
OSCentOS Stream 9
SELinux無効(Disabled)
cat /etc/redhat-release
CentOS Stream release 9

経緯と解決策

経緯について

Web サーバーのアクセスログを見ると、置いてはいけないファイルを次々に試すアクセスが、日々届いています。.env、.git/、wp-config.php の控え、wp-content/debug.log などです。1 つの接続元から、数秒で数十の URL を試してきます。
WordPress のサイトでは、こうしたアクセスも WordPress まで届きます。無い URL に 404 を返すためだけに、PHP と WordPress が動きます。

fail2ban には、Basic 認証の失敗を数える apache-auth が元からあります。ただ、これに Apache のエラーログを読ませると、Basic 認証の失敗のほかに、Require で拒んだアクセス(client denied by server configuration の行)も数えます。
歓迎しない botや海外からのアクセスを Require で拒んでいるサーバーでは、起動した直後から、Basic 認証と関係の無い接続元を次々に遮断します。

解決案を考える

そこで、Apache と fail2ban の役目を分けます。

  • Apache:どのアクセスを探りとみなすかを決めて拒み、fail2ban 向けのファイルに 1 行書く
  • fail2ban:fail2ban 向けのファイルを読み、行を数えて遮断する

fail2ban には、普通のエラーログやアクセスログを読ませません。fail2ban 向けのファイルには、探りと Basic 認証の失敗の行しか入らないので、関係の無い行を数えることがありません。
拒むのは Apache の段なので、WordPress まで届かず、PHP も動きません。

fail2ban 向けのファイルは、遮断の強さで、強・中・弱の 3 つに分けます。ファイルごとに、何回で、どれだけ遮断するかを決めます。すべてのサイトの分を、Apache の GlobalLog で 1 か所に集めます。
Basic 認証の失敗は、中に入れます。強・中・弱の決め方と、何回で遮断するかは、サーバー管理者が決めます。

これとは別に、サーバーで公開するファイルの拡張子を決めておき、それ以外は 403 で拒みます(許可する拡張子)。置き忘れた控えやデータのように、公開するつもりの無いファイルが、拡張子で止まります。
こちらは拒むだけで、fail2ban には書きません。並びに入れ忘れた形式のファイルを、正しく使う人が開いても、遮断されないようにするためです。

なお、fail2ban は失敗の行を数えるもので、短い時間に大量のアクセスを送り付ける攻撃(DoS)を止めるものではありません(前の記事の「fail2ban の仕組み」)。

事前確認

設定を書く前に、fail2ban が動いていることと、Apache の版を確かめます。
作業は root で行います。

fail2ban が動いているか

前の記事で入れた fail2ban が動いていることを確かめます。

fail2ban-client status

Jail list の行に sshd と出れば、動いています。

Apache の版

この記事では、Apache の GlobalLog を使います。GlobalLog は 2.4.19 からあるので、それより新しいかを確かめます。

httpd -v

Server version の行の版が 2.4.19 以上なら使えます。CentOS Stream 9 の Apache は、これより新しい版です(この記事では 2.4.62)。2.4.19 より前の Apache は、この記事では扱いません。

今回行うこと

はじめに、許可する拡張子と、どのアクセスを強・中・弱のどれで遮断するかを決めます。
そのあと、Apache に、許可する拡張子の設定と、探りを拒んで fail2ban 向けのファイルに書く設定を作ります。fail2ban には、フィルタと 3 つのジェイルを作ります。どちらも作ったら反映します。
最後に、試しのアクセスと fail2ban-client で、拒めることと、数えて遮断できることを確かめます。

許可する拡張子を決める

サーバーで公開するファイルの拡張子を決めます。並びに無い拡張子のファイルは、置いてあっても 403 で拒みます。
Linux・Apache・PHP・MySQL のサーバーで、PHP のサイト(WordPress など)を動かすなら、たとえば次の並びを既定にします。

種類拡張子
ページphp html htm
CSS・JavaScriptcss js mjs gz
データjson xml xsl txt webmanifest
画像jpg jpeg png gif svg webp avif ico
フォントwoff2 woff ttf otf eot
文書・音声・動画pdf mp3 mp4 webm
  • gz:前もって gzip で圧縮しておいた HTML・CSS・JavaScript(style.css.gz など)や、ページのキャッシュを置くプラグインが使います
  • xml・xsl・txt:サイトマップ(sitemap.xml と、その見た目の .xsl)や robots.txt です。WordPress は、ファイルが無くてもこれらの URL を返します
  • 既定に入れないもの:zip、Office の文書(docx・xlsx など)、csv、ソースマップ(map)です。開かれてもサーバーに害はありませんが、サイトの控え(backup.zip)や書き出したデータ(users.csv)のように、公開するつもりの無いものが置き忘れられやすい形式です。使うサイトだけが足します
  • 拡張子の無い URL:見るのは URL の最後の区切り(最後の / より後ろ)だけです。WordPress の記事(/technical/…)や /wp-json/… のように、最後の区切りに拡張子の無い URL と、/ で終わる URL は、拒みません

Ruby や Perl などを動かすサーバーや、ダウンロードのファイルを置くサイトがあるときは、並びに足します(「許可する拡張子を足す・外す」)。

強・中・弱を決める

拒むアクセスを、遮断の強さで 3 つに分けます。どれに入れるかは、次の 3 つの問いを上から当てて、最初に当てはまったものにします。迷ったら中にします。

順問い当てはまったら
1このサーバーで、正しく使う人やアプリが開くことはあるか弱(または、拒まない)
2普通の人が、打ち間違い・古いリンク・パスワードの誤りで当たることはあるか中
3どちらも無い(開くのは探る者だけ)強

PHP が主体のサーバーなら、たとえば次のように分けます。

強さ例遮断の条件
強.env、.git/、鍵(.key・.pem)1 回で 1 日
中Basic 認証の失敗、控え(.bak・.old)、このサーバーでは動かさない言語のソース(.py・.rb)10 分に 5 回で 1 時間
弱使っていない xmlrpc.php(スマートフォンのアプリや一部のプラグインが使う)1 時間に 30 回で 10 分

サーバー全体の設定に入れるのは、このサーバーのどのサイトも公開しないものだけです。xmlrpc.php のように、使うかどうかがサイトで違うものは、サイトごとの設定(次の記事)で目印を付けます。

Apache を設定する

Apache に、許可する拡張子の設定と、探りを拒んで fail2ban 向けのファイルに書く設定の、2 つのファイルを作ります。fail2ban を使わないサーバーでも、許可する拡張子の設定だけを置けば使えます。

許可する拡張子の設定を作る

/etc/httpd/conf.d/allow-ext.conf を作ります。

vi /etc/httpd/conf.d/allow-ext.conf
# ----------------------------------------------------------------
# 許可する拡張子(サーバー全体の既定)
# URL の最後の区切りに拡張子があり、ここに無いものは 403 で拒む(fail2ban には書かない)
# 拡張子の無い URL(WordPress の記事など)と、/ で終わる URL は見ない
# 大文字と小文字は区別しない(.JPG も .jpg と同じ)
# ----------------------------------------------------------------

# 種類ごとに | で区切って並べる。サーバー全体で足す・外すときは、合う種類の行を直す
# サイトごとに足すときは、サイトの conf で目印 ext_allow を立てる
Define WEB_ALLOW_PAGE  "php|html|htm"
Define WEB_ALLOW_CODE  "css|js|mjs|gz"
Define WEB_ALLOW_DATA  "json|xml|xsl|txt|webmanifest"
Define WEB_ALLOW_IMAGE "jpg|jpeg|png|gif|svg|webp|avif|ico"
Define WEB_ALLOW_FONT  "woff2|woff|ttf|otf|eot"
Define WEB_ALLOW_MEDIA "pdf|mp3|mp4|webm"

# 上をまとめたもの(ここは直さない)
Define WEB_ALLOW_EXT   "${WEB_ALLOW_PAGE}|${WEB_ALLOW_CODE}|${WEB_ALLOW_DATA}|${WEB_ALLOW_IMAGE}|${WEB_ALLOW_FONT}|${WEB_ALLOW_MEDIA}"


# ----------------------------------------------------------------
# 拒む(拡張子がある + 並びに無い + サイトが許可していない)
# ----------------------------------------------------------------
<If "%{REQUEST_URI} =~ m#\.[^./]+$# && !(%{REQUEST_URI} =~ m#\.(?:${WEB_ALLOW_EXT})$#i) && -z reqenv('ext_allow')">
    Require all denied
</If>
  • WEB_ALLOW_PAGE などの 6 行:許可する拡張子を、種類ごとに並べます。足す・外すときは、この 6 行だけを直します
  • WEB_ALLOW_EXT:6 行をつなげて、1 つの並びにします。Define の中で、別の Define の名前を使えます
  • \.[^./]+$:URL の最後の / より後ろに、.拡張子 があるかを見ます。/vendor/bootstrap-5.2.0/ のように、途中の区切りに . があっても当たりません
  • #i:大文字と小文字を区別せずに照らします
  • reqenv('ext_allow'):サイトの conf が目印 ext_allow を立てたアクセスは、拒みません。サイトごとに拡張子を足すための口です

探りを拒む設定を作る

/etc/httpd/conf.d/fail2ban-web.conf を作り、URL で見分ける、記録するときの見分け、拒む、fail2ban 向けのファイルに書く、の 4 つを書きます。

vi /etc/httpd/conf.d/fail2ban-web.conf
# ----------------------------------------------------------------
# URL で見分ける(サーバー全体で決めるもの)
# URL は、%2e などを元の文字に戻したあとの形(%{REQUEST_URI})で見る。
# SetEnvIf Request_URI は戻す前の URL を見るので使わない(/%2eenv のような頼み方ですり抜けられる)
# ----------------------------------------------------------------

# 強:. で始まるもの(/.well-known/ は除く)、鍵・証明書、DB のダンプ・ログ(.gz で圧縮したものも)、インフラの状態
Define F2B_STRONG_URI "(%{REQUEST_URI} =~ m#^/(?!\.well-known/)(?:.*/)?\.[^/]# || %{REQUEST_URI} =~ m#\.(?:key|pem|p12|pfx|(?:sql|log)(?:\.gz)?|tfstate|tfvars)$#i)"

# 中:このサーバーでは動かさない言語の拡張子(PHP が主体のサーバー)。| で区切る
Define F2B_MID_EXT    "py|rb|pl|cgi|sh|phtml|phar"

# 中:控え・エディターの残り、上の言語のソース
Define F2B_MID_URI    "(%{REQUEST_URI} =~ m#(?:\.(?:bak|old|orig|save|swp|backup)|~)$#i || (%{REQUEST_URI} =~ m#\.(?:${F2B_MID_EXT})$#i && -z reqenv('ext_allow')))"

# 弱:サーバー全体では無し(サイトごとの設定で、目印 f2b_weak を付けて拒む)


# ----------------------------------------------------------------
# 記録するときの見分け
# 上の URL に、サイトが付けた目印(f2b_strong・f2b_mid・f2b_weak)と、Basic 認証の失敗を足したもの
# ----------------------------------------------------------------

# 強:URL の強 + 目印 f2b_strong
Define F2B_IS_STRONG  "(${F2B_STRONG_URI} || -n reqenv('f2b_strong'))"

# 中:URL の中 + 目印 f2b_mid + Basic 認証の失敗(認証の情報を付けて 401)
Define F2B_IS_MID     "(${F2B_MID_URI} || -n reqenv('f2b_mid') || (%{REQUEST_STATUS} == 401 && -n req('Authorization')))"

# 弱:目印 f2b_weak
Define F2B_IS_WEAK    "(-n reqenv('f2b_weak'))"


# ----------------------------------------------------------------
# 拒む(サーバー全体で決めたもの。サイトが付けた目印は、サイトの規則が [F] で拒む)
# ----------------------------------------------------------------
<If "${F2B_STRONG_URI} || ${F2B_MID_URI}">
    Require all denied
</If>


# ----------------------------------------------------------------
# fail2ban 向けのファイルに書く(すべてのサイトの分。重いほうの 1 つだけに書く)
# ----------------------------------------------------------------
GlobalLog /var/log/httpd/fail2ban-strong_log "%a %t %v \"%r\" %>s" "expr=${F2B_IS_STRONG}"
GlobalLog /var/log/httpd/fail2ban-mid_log    "%a %t %v \"%r\" %>s" "expr=!${F2B_IS_STRONG} && ${F2B_IS_MID}"
GlobalLog /var/log/httpd/fail2ban-weak_log   "%a %t %v \"%r\" %>s" "expr=!${F2B_IS_STRONG} && !${F2B_IS_MID} && ${F2B_IS_WEAK}"

vi に貼り付けると字下げがずれることがあるので、:set paste を打ってから挿入モードに入って貼り付けます。保存して閉じたら(:wq)、ls でファイルができたかを確かめておくと安心です。

URL で見分ける
  • Define:式に名前を付けます。${F2B_STRONG_URI} のように書いた場所に、その式が入ります。同じ式を、拒むところと書くところの両方で使うためです
  • %{REQUEST_URI} =~ m#…#:URL のパスが正規表現に合うかを見ます。%2e などを元の文字に戻した形で見るので、/%2eenv も /.env と同じに当たります(補足説明の「SetEnvIf Request_URI で見分けない理由」)
  • /.well-known/:Let's Encrypt の確認などで、正しく使われます。(?!\.well-known/) で、強から外します
  • (?:sql|log)(?:\.gz)?:DB のダンプとログは、gzip で圧縮した .sql.gz・.log.gz も強にします
  • #i:拡張子は、大文字と小文字を区別せずに照らします。/dump.SQL も /dump.sql と同じに強に当たります
  • F2B_MID_EXT:このサーバーでは動かさない言語の拡張子を、| で区切って並べます。動かしている言語があれば、その拡張子を消します。たとえば Python のアプリを置いているなら、py| を消して "rb|pl|cgi|sh|phtml|phar" にします。サイトの conf で目印 ext_allow を立てたアクセスは、ここでは拒みません(「許可する拡張子を足す・外す」)
  • phtml・phar:PHP の別の拡張子です。.php だけを PHP で動かすサーバーでは、置かれていると中身がそのまま返るので、拒みます
記録するときの見分け
  • F2B_IS_STRONG・F2B_IS_MID・F2B_IS_WEAK:URL の見分けに、サイトの設定が付けた目印(強:f2b_strong、中:f2b_mid、弱:f2b_weak)を足したものです。目印は reqenv() で見ます。サイトの設定は、次の記事とその次の記事で扱います
  • 中の Basic 認証の失敗:401 を返し、しかも認証の情報が送られてきたものを、中に入れます。ブラウザは最初に認証の情報を付けずに開いて 401 を受け取るので、認証の情報が無い 401 は数えません
拒む
  • <If>:URL が強か中に当たれば、403 で拒みます。<If> の Require は、設定を合わせるときに、サイトの .htaccess や <Location> より後に合わせられるので、サイトの設定に上書きされません(拒むかどうかを決める判定そのものは、.htaccess の書き換えより先に動きます)。WordPress まで届かないので、PHP も動きません
  • サイトが付けた目印:目印を付けたサイトの設定が、自分で 403 にして拒むので、ここには入れません
fail2ban 向けのファイルに書く
  • GlobalLog:すべてのサイトのアクセスを、条件に合ったものだけ 1 つのファイルに書きます。サイトごとのアクセスログは、これまでどおり残ります
  • 重いほうの 1 つだけに書く:/.env.bak のように強と中の両方に当たるときは、強のファイルだけに書きます。中と弱の条件の頭にある !${F2B_IS_STRONG} が、強に当たったものを外します。2 つのファイルに書かれると、2 回数えられるためです
  • 書く形:接続元の IP [時刻] サイトの名前 "GET … HTTP/1.1" 状態。%v にサイトの名前が入るので、ファイルが 1 つでも、どのサイトへのアクセスかが分かります

反映する

apachectl configtest && systemctl reload httpd

# fail2ban 向けのファイルができたことを確かめる
ls -l /var/log/httpd/fail2ban-*_log
Syntax OK
-rw-r--r-- 1 root root 0 11月  8 02:33 /var/log/httpd/fail2ban-mid_log
-rw-r--r-- 1 root root 0 11月  8 02:33 /var/log/httpd/fail2ban-strong_log
-rw-r--r-- 1 root root 0 11月  8 02:33 /var/log/httpd/fail2ban-weak_log

Apache を反映したときに、大きさ 0 の 3 つのファイルができます。fail2ban は、読むファイルが無いとジェイルを始められません。Apache が先にファイルを作るので、fail2ban の設定はこのあとで反映します。
反映した時点から、許可していない拡張子と探りは 403 で拒まれ、探りはファイルに書かれ始めます。

fail2ban を設定する

fail2ban に、そのファイルを読むフィルタと、強・中・弱の 3 つのジェイルを作ります。

フィルタを作る

fail2ban 向けのファイルは、1 行が 1 回の失敗です。行の頭の IP アドレスを数えるだけのフィルタを、/etc/fail2ban/filter.d/apache-tier.conf に作ります。3 つのジェイルで共通に使います。

vi /etc/fail2ban/filter.d/apache-tier.conf
[Definition]
# fail2ban 向けのファイル(Apache の GlobalLog)の 1 行が、1 回の失敗。行の頭の IP アドレスを数える
failregex = ^<HOST> \[
ignoreregex =

時刻([08/Nov/2026:01:41:09 +0900] の形)は、fail2ban が自分で読み取るので、書きません。

ジェイルを作る

強・中・弱の 3 つのジェイルを、/etc/fail2ban/jail.d/apache-tier.local に書きます。jail.local とは分けておくと、元に戻すときに、このファイルを消すだけで済みます。

vi /etc/fail2ban/jail.d/apache-tier.local
# 強:1 日(findtime)のあいだに 1 回(maxretry)で、1 日(bantime)遮断する
[apache-strong]
enabled  = true
port     = http,https
backend  = auto
filter   = apache-tier
logpath  = /var/log/httpd/fail2ban-strong_log
maxretry = 1
findtime = 1d
bantime  = 1d

# 中:10 分のあいだに 5 回で、1 時間遮断する
[apache-mid]
enabled  = true
port     = http,https
backend  = auto
filter   = apache-tier
logpath  = /var/log/httpd/fail2ban-mid_log
maxretry = 5
findtime = 10m
bantime  = 1h

# 弱:1 時間のあいだに 30 回で、10 分遮断する
[apache-weak]
enabled  = true
port     = http,https
backend  = auto
filter   = apache-tier
logpath  = /var/log/httpd/fail2ban-weak_log
maxretry = 30
findtime = 1h
bantime  = 10m
  • 3 つの違い:読むファイル(logpath)と、回数・時間(maxretry・findtime・bantime)だけです。フィルタは共通です
  • port:遮断するのは http と https だけです。SSH は止めません
  • backend = auto:ジャーナルではなく、ファイルを読みます
  • 遮断しない接続元:前の記事で jail.local の [DEFAULT] に書いた ignoreip が、3 つのジェイルにもそのまま効きます。サーバー自身は、fail2ban が自分で外します(補足説明の「サーバー自身を数えない仕組み」)

設定に誤りが無いか確かめる

fail2ban-client -t
OK: configuration test is successful

フィルタが、行の頭の IP アドレスと時刻を読めるかを試します。fail2ban-regex は、設定を変えずに、1 行の文字とフィルタを当てた結果を出すだけのコマンドです。

fail2ban-regex '203.0.113.50 [08/Nov/2026:01:41:09 +0900] example.com "GET /.env HTTP/1.1" 403' apache-tier
Running tests
=============

Use      filter file : apache-tier, basedir: /etc/fail2ban
Use      single line : 203.0.113.50 [08/Nov/2026:01:41:09 +0900] example....

(略)

Lines: 1 lines, 0 ignored, 1 matched, 0 missed
[processed in 0.02 sec]

1 matched なら、フィルタが行を数えられます。

ignoreip が、新しい 3 つのジェイルにも入るかを見ます。

fail2ban-client -d | grep ignoreip
['set', 'sshd', 'addignoreip', '127.0.0.1/8', '::1', '203.0.113.10', '203.0.113.20', '198.51.100.0/24']
['set', 'apache-strong', 'addignoreip', '127.0.0.1/8', '::1', '203.0.113.10', '203.0.113.20', '198.51.100.0/24']
['set', 'apache-mid', 'addignoreip', '127.0.0.1/8', '::1', '203.0.113.10', '203.0.113.20', '198.51.100.0/24']
['set', 'apache-weak', 'addignoreip', '127.0.0.1/8', '::1', '203.0.113.10', '203.0.113.20', '198.51.100.0/24']

反映する

fail2ban-client reload
fail2ban-client status
OK
Status
|- Number of jail:	4
`- Jail list:	apache-mid, apache-strong, apache-weak, sshd

Jail list に、sshd と、新しい 3 つのジェイルが並べば、反映できています。

動きを確かめる

サーバー自身から、自分のサイトに試しのアクセスを送ります。example.com は、このサーバーのサイトのドメインに置き換えます。
歓迎しない botを拒んでいるサーバーでは、curl の名乗りも拒まれることがあるので、-A でブラウザに似た名乗りを付けます。

はじめに、許可する拡張子を確かめます。

UA='Mozilla/5.0 (probe test)'
for p in /robots.txt /wp-json/oembed/1.0/embed /F2B-TEST.JPG /f2b-test.zip /f2b-test.css.map; do
  printf '%-28s ' "$p"; curl -s --path-as-is -A "$UA" -o /dev/null -w '%{http_code}\n' "https://example.com$p"
done
/robots.txt                  200
/wp-json/oembed/1.0/embed    400
/F2B-TEST.JPG                404
/f2b-test.zip                403
/f2b-test.css.map            403
  • /wp-json/oembed/1.0/embed:途中の区切りに . がありますが、最後の区切りには拡張子が無いので拒まれず、WordPress が答えます(400 は、WordPress が URL の指定が足りないと返したものです)
  • /F2B-TEST.JPG:大文字でも jpg として通ります。無いファイルなので 404 です
  • /f2b-test.zip・/f2b-test.css.map:並びに無いので、ファイルが無くても 403 で拒まれます。fail2ban 向けのファイルには書かれません

続けて、探りを確かめます。

UA='Mozilla/5.0 (probe test)'
for p in /.env /%2eenv /.git/config /.well-known/f2b-test /wp-config.php.bak /.env.bak /; do
  printf '%-24s ' "$p"; curl -s --path-as-is -A "$UA" -o /dev/null -w '%{http_code}\n' "https://example.com$p"
done
/.env                    403
/%2eenv                  403
/.git/config             403
/.well-known/f2b-test    404
/wp-config.php.bak       403
/.env.bak                403
/                        200

強と中に当たる URL は 403 で拒まれ、/.well-known/ とトップページは、これまでどおり返ります(/.well-known/f2b-test は無いファイルなので 404 です)。/%2eenv も、/.env と同じに拒まれます。
--path-as-is は、curl に URL を書いたまま送らせるためのものです。

fail2ban 向けのファイルと、fail2ban のログを見ます。

tail -n 4 /var/log/httpd/fail2ban-strong_log /var/log/httpd/fail2ban-mid_log
grep -E 'apache-(strong|mid)' /var/log/fail2ban.log | tail -n 5
==> /var/log/httpd/fail2ban-strong_log <==
192.0.2.10 [08/Nov/2026:02:53:50 +0900] example.com "GET /.env HTTP/1.1" 403
192.0.2.10 [08/Nov/2026:02:53:50 +0900] example.com "GET /%2eenv HTTP/1.1" 403
192.0.2.10 [08/Nov/2026:02:53:50 +0900] example.com "GET /.git/config HTTP/1.1" 403
192.0.2.10 [08/Nov/2026:02:53:50 +0900] example.com "GET /.env.bak HTTP/1.1" 403

==> /var/log/httpd/fail2ban-mid_log <==
192.0.2.10 [08/Nov/2026:02:53:50 +0900] example.com "GET /wp-config.php.bak HTTP/1.1" 403
2026-11-08 02:53:50,352 fail2ban.filter         [12345]: INFO    [apache-strong] Ignore 192.0.2.10 by ignoreself rule
2026-11-08 02:53:50,394 fail2ban.filter         [12345]: INFO    [apache-strong] Ignore 192.0.2.10 by ignoreself rule
2026-11-08 02:53:50,413 fail2ban.filter         [12345]: INFO    [apache-strong] Ignore 192.0.2.10 by ignoreself rule
2026-11-08 02:53:50,690 fail2ban.filter         [12345]: INFO    [apache-mid] Ignore 192.0.2.10 by ignoreself rule
2026-11-08 02:53:50,781 fail2ban.filter         [12345]: INFO    [apache-strong] Ignore 192.0.2.10 by ignoreself rule

強のファイルに /.env・/%2eenv・/.git/config・/.env.bak、中のファイルに /wp-config.php.bak が書かれています。/.env.bak は、強だけに書かれています。
192.0.2.10 はサーバー自身の IP アドレスです。fail2ban のログの Ignore … by ignoreself rule は、サーバー自身なので数えなかった、という意味です。ファイルには書かれますが、遮断はされません。

この記事は、SELinux を無効にしたサーバーで試しています。
SELinux が有効な場合(getenforce で Enforcing と出る場合)は、SELinux に止められていないかも見ます。何も出なければ止められていません。

ausearch -m avc -ts recent | grep fail2ban

遮断できるかを、使われていない IP アドレスで試します。

# 試しに遮断する
fail2ban-client set apache-strong banip 192.0.2.1
firewall-cmd --list-rich-rules

# 解く
fail2ban-client set apache-strong unbanip 192.0.2.1
firewall-cmd --list-rich-rules
1
rule family="ipv4" source address="192.0.2.1" port port="https" protocol="tcp" reject type="icmp-port-unreachable"
rule family="ipv4" source address="192.0.2.1" port port="http" protocol="tcp" reject type="icmp-port-unreachable"
1

http と https のポートにだけかかる規則が 2 つ足され、解くと消えます。

しばらくしてから、本当の探りが遮断されているかを見ます。

fail2ban-client status apache-strong
grep -E 'apache-(strong|mid|weak)\] Ban' /var/log/fail2ban.log | tail -n 10
Status for the jail: apache-strong
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	52
|  `- File list:	/var/log/httpd/fail2ban-strong_log
`- Actions
   |- Currently banned:	2
   |- Total banned:	3
   `- Banned IP list:	203.0.113.80 203.0.113.81
2026-11-08 02:53:57,218 fail2ban.actions        [12345]: NOTICE  [apache-strong] Ban 192.0.2.1
2026-11-08 03:09:18,502 fail2ban.actions        [12345]: NOTICE  [apache-strong] Ban 203.0.113.80
2026-11-08 03:10:38,880 fail2ban.actions        [12345]: NOTICE  [apache-strong] Ban 203.0.113.81

しばらくすると、本当の探りも遮断されています(192.0.2.1 は、さきほどの試しの分です)。
強は 1 回で遮断するのに、Total failed が 52 になっているのは、遮断が効くまでの間に届いた分も数えているためです(補足説明の「1 回で遮断しても、数十行書かれる」)。

遮断を解く

自分や、知っている相手を遮断してしまったときは、次のように解きます。

# 遮断している IP アドレスを見る(Banned IP list の行)
fail2ban-client status apache-strong

# 1 つ解く(空白で区切って、いくつか並べてもよい)
fail2ban-client set apache-strong unbanip 203.0.113.30

強は 1 回で 1 日遮断するので、普通の人が当たったとき(問い合わせがあったときなど)は、この手順で解きます。何度も当たるなら、その URL は強から中へ動かします。fail2ban-web.conf で、その条件を F2B_STRONG_URI から F2B_MID_URI へ移すだけで、fail2ban の設定は変えません。

許可する拡張子を足す・外す

既定の並びで足りないときや、出したくない拡張子があるときは、次のようにします。

したいこと直すところ
サーバー全体で足す・外すallow-ext.conf の、合う種類の行(WEB_ALLOW_PAGE など)に |拡張子 を足す・消す
Ruby・Perl などを、サーバー全体で動かす上に加えて、fail2ban-web.conf の F2B_MID_EXT から、その拡張子を消す
あるサイトだけで足すそのサイトの conf で、目印 ext_allow を立てる(次の記事)
あるサイトだけで外すそのサイトの .htaccess で拒む(その次の記事)
  • Ruby・Perl など:rb・pl・cgi などは、探りの中の並び(F2B_MID_EXT)にも入っています。許可する並びに足すだけでは、探りの方で拒まれます。サイトの conf で ext_allow を立てたアクセスは、探りの中の並びでも拒まないので、あるサイトだけで動かすときは、サイトの conf だけで済みます
  • 強は外せない:ext_allow を立てても、強(. で始まるもの・鍵・DB のダンプなど)は拒みます
  • エラーページと DirectoryIndex:ErrorDocument のページや、DirectoryIndex で開くファイル(index.cgi など)の拡張子も、並びに要ります。並びに無いと、そのページが拒まれます。cgi は探りの中の並びにも入っているので、上の Ruby・Perl などと同じく、サイトの conf で ext_allow を立てます

直したら反映し、できあがった並びを確かめます。

apachectl configtest && systemctl reload httpd
httpd -t -D DUMP_RUN_CFG | grep WEB_ALLOW_EXT
Syntax OK
Define: WEB_ALLOW_EXT=php|html|htm|css|js|mjs|gz|json|xml|xsl|txt|webmanifest|jpg|jpeg|png|gif|svg|webp|avif|ico|woff2|woff|ttf|otf|eot|pdf|mp3|mp4|webm

6 行をつなげた並びが出ます。

元に戻す

サイトごとの設定(次の記事・その次の記事)も使わなくするなら、先にそちらを元に戻します。残したままだと、サイトの規則が拒むのは続きますが、fail2ban には数えられなくなります。

# fail2ban のジェイルとフィルタを消して、反映する
rm /etc/fail2ban/jail.d/apache-tier.local
rm /etc/fail2ban/filter.d/apache-tier.conf
fail2ban-client reload

# Apache の設定を消して、反映する(許可する拡張子だけを残すなら、allow-ext.conf は消さない)
rm /etc/httpd/conf.d/fail2ban-web.conf
rm /etc/httpd/conf.d/allow-ext.conf
apachectl configtest && systemctl reload httpd

# fail2ban 向けのファイルを消す
rm /var/log/httpd/fail2ban-strong_log /var/log/httpd/fail2ban-mid_log /var/log/httpd/fail2ban-weak_log

# fail2ban が足した規則が残っていないことを確かめる
firewall-cmd --list-rich-rules

補足説明

fail2ban 向けのファイルに分ける理由

fail2ban のフィルタは、ログの行が決まった形に合うかで失敗を見分けます。普通のエラーログやアクセスログには、いろいろな行が混ざるので、形が似た別の行まで数えてしまうことがあります。
fail2ban に元からある apache-auth のフィルタ(/etc/fail2ban/filter.d/apache-auth.conf)は、Basic 認証の失敗のほかに、Require で拒んだアクセスの行も数える決まりになっています。

grep -n 'denied by server configuration' /etc/fail2ban/filter.d/apache-auth.conf
17:# allow "denied by server configuration" from all modules:
27:failregex = ^client (?:denied by server configuration|used wrong authentication scheme)\b

海外からのアクセスや bot を Require で拒んでいると、そのたびにエラーログにこの行が出るので、apache-auth が数えて遮断します。

この記事では、何を失敗とするかを Apache の側で決め、当てはまったものだけを fail2ban 向けのファイルに書きます。fail2ban のフィルタは「1 行が 1 回」と数えるだけなので、関係の無い行を数えることがありません。読む行も少なくて済みます。

GlobalLog について

GlobalLog は、CustomLog と同じ書き方で、すべての VirtualHost のアクセスを 1 つのファイルに書く命令です。Apache 2.4.19 からあります。

CustomLogGlobalLog
書ける場所サーバー全体の設定、VirtualHost の中サーバー全体の設定だけ
サーバー全体の設定に書いたとき自分の CustomLog を持たない VirtualHost にだけ効く自分の CustomLog を持つ VirtualHost にも効く
条件(env=・expr=)付けられる同じように付けられる

サイトの VirtualHost は、ふつう自分の CustomLog(サイトのアクセスログ)を持っています。そのため、サーバー全体に CustomLog を書いても、サイトのアクセスはそこに書かれません。GlobalLog なら、サイトのアクセスログはそのまま残し、それとは別に、すべてのサイトの分を 1 つのファイルに書けます。

SetEnvIf Request_URI で見分けない理由

URL で見分けるのに、SetEnvIf Request_URI で目印を付ける書き方もあります。ただ、サーバー全体に書いた SetEnvIf Request_URI は、%2e などを元の文字に戻す前の URL を見ます。

Apache は、ファイルを探す前に %2e を . に戻すので、/%2eenv と頼まれると .env を返します。一方で、SetEnvIf Request_URI "^/\." のような条件は、/%2eenv を . で始まると見ません。目印が付かないので、拒まれず、数えられもしません。

試しのファイルをドキュメントルートに置いて、%2e の形で開いてみます。

echo f2b-dot-test > /home/example_com/public_html/.f2b-dot-test
curl -s --path-as-is -A 'Mozilla/5.0 (probe test)' -o /dev/null -w '%{http_code}\n' https://example.com/%2ef2b-dot-test
rm /home/example_com/public_html/.f2b-dot-test

SetEnvIf Request_URI で目印を付ける書き方では、200 で、ファイルの中身が返ります。この記事の書き方では、403 で拒まれ、強のファイルに書かれます。

URL では、どの文字も % と 16 進数で書けます(/.%65nv も /%2e%65%6e%76 も、/.env と同じです)。%2e などを条件に書き足していっても、書き切れません。
そのためこの記事では、元の文字に戻したあとの URL(%{REQUEST_URI})を、<If> と GlobalLog の expr= で見ます。なお、RewriteRule は元の文字に戻したあとの URL を見るので、サイトの設定で目印を付けるのに使えます(次の記事)。

サイトごとではなく、強・中・弱でファイルを分ける理由

  • 遮断はサーバー全体にかかる:fail2ban は、firewalld でサーバーの http と https をまとめて遮断します。どのサイトで探られても、接続元はすべてのサイトに入れなくなるので、ファイルをサイトごとに分けても働きは変わりません
  • サイトを足しても変えない:ファイルの置き場が決まっているので、サイトを足しても、fail2ban の設定も、ジェイルの読み直しも要りません
  • どのサイトかは行で分かる:行に %v(サイトの名前)が入るので、どのサイトへの探りかは、ファイルの中で分かります

サイトの担当者は、サイトに付く探りに目印を付けるだけです。何回で遮断するかは、サーバー管理者がジェイルで決めます。

サーバー自身を数えない仕組み

fail2ban には、サーバー自身の IP アドレスを数えない ignoreself があり、既定で有効です。サーバーのネットワークの口(ens3 など)に付いている IP アドレスを、自分のものとみなします。ignoreip より先に当たるので、サーバー自身の IP アドレスを ignoreip に書かなくても、遮断されません。
WordPress の wp-cron.php のように、サイトがサーバー自身のドメインに接続しても、数えられません。

ただし、NAT の内側にあるサーバー(ネットワークの口に付いているのが内側の IP アドレスで、外向きの IP アドレスは別の機器が持っている場合)では、外向きの IP アドレスを自分と分からないと思われます。その場合は、外向きの IP アドレスを ignoreip に足しておきます。
ネットワークの口に付いている IP アドレスは、ip -4 -brief addr show scope global で見られます。

1 回で遮断しても、数十行書かれる

動きを確かめたときの強のファイルを、接続元ごとに数えると、次のようになりました。

awk '{print $1}' /var/log/httpd/fail2ban-strong_log | sort | uniq -c | sort -rn
grep '^203\.0\.113\.80 ' /var/log/httpd/fail2ban-strong_log | sed -n '1p;$p'
     46 203.0.113.80
      6 203.0.113.81
      5 192.0.2.10
203.0.113.80 [08/Nov/2026:03:09:18 +0900] example.com "GET /.amplifyrc HTTP/1.1" 403
203.0.113.80 [08/Nov/2026:03:09:20 +0900] example.com "GET /wp-content/debug.log HTTP/1.1" 403

203.0.113.80 は、03:09:18 に遮断されましたが、そこから 03:09:20 までの約 2 秒に、続けて 45 行届いています。探りは、短い時間に何十もの URL を続けて送ってきます。fail2ban が行を読んで firewalld に規則を足すまでの間と、すでにつながっていた接続の分は、Apache まで届いてファイルに書かれます(firewalld は、つながっている接続を rich rule より先に通すためと思われます)。
遮断が効いたあとは、Apache まで届かないので、行は増えません。

ログのローテーション

CentOS Stream 9 の既定の /etc/logrotate.d/httpd は、/var/log/httpd/*log を回し、回したあとに httpd を読み直します。fail2ban-strong_log などもこれに当てはまるので、ほかのログと一緒に回ります。
fail2ban は、ファイルが入れ替わったことに気づくと、新しいファイルを頭から読みます。

ログの回し方を変えているサーバーでは、/var/log/httpd/ の新しいファイルも回すか、回したあとに httpd を読み直すかを確かめておきます。読み直さないと、Apache は名前の変わった古いファイルに書き続け、fail2ban は新しいファイルを見ているので、行を見落とします。

まとめ

  • 何を探りや失敗とみなすかは Apache で決め、fail2ban 向けのファイルに書く。fail2ban は、そのファイルを数えて遮断するだけ
  • fail2ban 向けのファイルは、遮断の強さで強・中・弱の 3 つに分け、GlobalLog ですべてのサイトの分を集める
  • どれを強・中・弱にするかは、サーバー管理者が 3 つの問いで決める。サーバー全体の設定には、どのサイトも公開しないものだけを入れる
  • URL は、%2e などを元の文字に戻したあとの形(%{REQUEST_URI})で見る。SetEnvIf Request_URI では、/%2eenv のような頼み方ですり抜けられる
  • サーバー全体で拒む拡張子は、このサーバーで動かしている言語のものを外して並べる
  • 拒むのは Apache の段なので、WordPress まで届かず、PHP も動かない
  • 公開するファイルの拡張子を決め(許可する拡張子)、それ以外は 403 で拒むだけにする。サーバー全体の既定は allow-ext.conf で、サイトごとに足すのはサイトの conf、外すのは .htaccess
  • サーバー自身は、fail2ban の ignoreself が数えずに外す

サイトごとの設定は、次の 2 つの記事で扱います。