404が、少なすぎた
Apacheログの集計を始めたところ、ステータスの内訳で404が妙に少ないことに気づきました。
/wp-login.php や /.env を探しにくるスキャナは毎日来ています。
それなのに404の件数が、遮断した403より少ない。
素朴に確かめてみました。
$ curl -sI https://example.jp/nonexistent-test-12345.php | head -1 HTTP/2 301 ← 404 ではない
301。リダイレクトです。追いかけると、その先は 200 でした。 存在しないパスが、正常なページとして応答していた。いわゆるソフト404です。
まず、確かめ方
自分のサイトがどう返しているかは、3つのコマンドで分かります。
# ① 最初の応答を見る curl -sI https://example.jp/nonexistent-test-12345.php | head -1 # ② リダイレクトを最後まで追う curl -sIL https://example.jp/nonexistent-test-12345.php | grep -iE "^HTTP|^location" # ③ .php 以外でも試す(ルールで挙動が違うことがある) curl -sIL https://example.jp/nonexistent-page-12345/ | grep -iE "^HTTP"
| 最終的な応答 | 判定 |
|---|---|
| 404 または 410 | 正常。正しく「無い」と答えている |
| 200 | ソフト404。存在しないのに「ある」と答えている |
| 301 → 200 | ソフト404。途中のリダイレクトは症状の一部 |
| 403 | 遮断されている(別の設定が効いている) |
/test のような短い名前だと、たまたま実在したり、リダイレクト設定に引っかかったりします。
nonexistent-test-12345 のようにまず衝突しない名前を使ってください。
犯人は、catch-all
CMS を使っていると、.htaccess にこういう行が入ります。
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.php [L]
「実在するファイルでもディレクトリでもないなら、すべて index.php に渡す」。
パーマリンクを成立させるために必要な仕組みです
(この2行の詳細は前回の記事に書きました)。
問題は、渡された先で CMS が何を返すかです。 該当する記事が無いとき、CMS が 404 テンプレートをステータス404つきで返せば正常。 ところが 200 で返してしまうと、そこでソフト404になります。
途中のリダイレクトはどこから来るか
うちの場合は 301 が1回挟まっていました。末尾スラッシュを付ける動きです。
これは mod_dir のスラッシュ補完か、CMS 側の URL 正規化のどちらかで起きます。
どちらが原因でも、最後が200なら結果は同じなので、切り分けは後回しで構いません。
ソフト404は「攻撃者を欺くために選んだ設定」であることは稀で、たいていは catch-all の副産物です。うちもそうでした。誰も決めていないのに、そうなっていた。
何が壊れていたか
① 計測が壊れる
これが最初に気づいた実害でした。修正したところ、404の記録が桁違いに増えました。 攻撃が増えたのではありません。それまで 200 に化けていた探索が、正しく数えられるようになっただけです。
② 探索が、正常アクセスに紛れる
200 で記録されるということは、アクセス解析でも正常な閲覧として集計されるということです。 PV が水増しされ、bot 判定もすり抜けます。 「何を探されているか」を知る手がかりが、丸ごと失われていました。
③ クローラが、諦めない
404 は検索エンジンに対して「ここには何も無い、もう来なくていい」という合図です。 200 を返せば逆の意味になります。実在するページとして扱われ、再訪を招く。 Google はソフト404を明確に問題として扱っており、クロール予算の浪費や重複コンテンツ扱いにつながります。
④ 自分のリンク切れも見えない
攻撃者の探索だけの話ではありません。自サイト内のリンク切れも200になります。 記事を移動したときの張り忘れ、画像の消し忘れ——本来なら404の急増で気づけるものが、気づけなくなる。
⑤ 監視が効かない
ステータスコードで死活監視をしている場合、何を叩いても200が返る状態では監視になりません。 「サイトは生きている」と報告し続けます。
ステータスと、中身は別
ソフト404を擁護する意見でよく聞くのが「404だと素っ気ないから、親切なページを見せたい」です。 これは理由になりません。
404 を返しながら、親切なページを表示できます。 ステータスコードは機械への通知、ページの中身は人間への案内です。両者は独立しています。
# ○ 404 を返しつつ、中身は充実させる ErrorDocument 404 /404.html # 検索窓・人気記事・サイトマップへの導線 # ✕ トップページへ飛ばす(これがソフト404を作る) ErrorDocument 404 / RedirectMatch 404 ^/.*$ /
ErrorDocument 404 https://example.jp/404.html のように絶対URLを書くと302になります。
同一サーバー内なら /404.html のようにパスで指定してください。
これも意図せずソフト404を作る原因のひとつです。
直す — 設定
実在しない .php は、その場で404にする
スキャナが叩いてくるのは大半が .php です。ここだけでも catch-all に渡す前に止められます。
RewriteCond %{REQUEST_FILENAME} !-f RewriteRule \.php$ - [R=404,L] # ↓ 既存の catch-all はこの後ろ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.php [L]
順序が命です。 catch-all の後ろに置いても、先に index.php へ
書き換えられてしまうので効きません。実在チェック(!-f)が要るのは、
実在する .php まで404にしないためです。
.php だけです。
/2026/07/存在しない記事/ のような一般のパスは、依然として catch-all を通って CMS に渡ります。
こちらは CMS 側が404を返せているかの問題で、Apache の設定では解決できません。
デモで両方の挙動を確かめられます。
CMS 側も確かめる
WordPress なら、テーマに 404.php があるか、そして正しく404ステータスを返しているかを見ます。
プラグインが正規化リダイレクトを挟んでいることもあるので、
§1 の curl -sIL で最終的な応答を確認するのが確実です。
消したページには 410
404 は「見つからない(また来るかもしれない)」、410 は「意図的に消した(もう無い)」です。 410 のほうがクローラは早く諦めます。 記事を削除したことがはっきりしている URL には、410 を返す価値があります。
RedirectMatch 410 ^/old-campaign-2019/.*$
「隠す」が正当な場面もある
ここまで「正しく404を」と書いてきましたが、あえて404を返すのが妥当な場面もあります。 ただしそれは、ソフト404とは向きが逆の話です。
| 存在しないものを | 実在するものを | |
|---|---|---|
| ソフト404 | 在るように見せる ✕ | — |
| 意図的な404 | — | 無いように見せる ○ |
管理画面や内部APIに 403 を返すと、「そこに何かある」と教えてしまいます。 403 は「あるけど権限が無い」という意味だからです。 見つかること自体を避けたいリソースなら、404 を返す判断はあり得ます。
<Location /internal-api> Require ip 192.0.2.0/24 ErrorDocument 403 /404.html # 中身は404ページ </Location>
判断の軸はひとつです。隠したいのは「存在」か、「中身」か。 存在を隠したいなら404、中身だけ守れば十分なら403。 いずれにせよ「存在しないものを在るように見せる」理由にはならない——そこが今回の主題でした。
リクエストは、どこを通るか
パスと設定を選ぶと、リクエストが mod_rewrite → CMS をどう通り、 最終的に何を返すかが分かります。同じリクエストがログにどう記録されるかも並べました。
/wxfyf.php を A と B で見比べてください。A では 301 を挟んで 200、 B では即座に 404 です。一方 /2026/07/存在しない記事/ は、 B にしても 200 のまま——ここは CMS 側が404を返せるかの問題で、 Apache の設定では届きません。直せる範囲を正しく知っておくことも設定の一部です。
隠す設定は、自分からも隠す
ソフト404の擁護論に「攻撃者に存在を教えない」があります。ですが応答サイズや本文の違いで、 機械には簡単に見分けられます。徹底するのは難しいのに、副作用は全方位に及ぶ。 そして何より、攻撃者から隠れた情報は、自分からも同じだけ隠れます。 「相手には見えず、自分には見える」という都合のいい状態は作れません。
この記事のまとめ
curl -sILで最終的な応答を確認する。200 ならソフト404。- 原因の多くは catch-all の副産物。意図して選んだ設定ではないことが多い。
- 実害は5つ:計測・解析・クロール・リンク切れ検出・監視がまとめて壊れる。
- ステータスと中身は別。404を返しながら親切なページは出せる。
- 実在しない
.phpは catch-all の前で 404 にする。順序が命。 - ただし一般パスは CMS 側の対応が必要。Apache だけでは直らない。
- 消したものは 410、移転は個別に301。全部トップへ、は実質ソフト404。
- 「実在するものを隠す404」は別問題。隠したいのが存在か中身かで決める。