スマートフォン・タブレットからインターネットサーバーオペレーション

APPW.jp
 
soft 404 / status code

404を、正しく返す
ソフト404が壊していたもの

ログを集計し始めて、404がやけに少ないことに気づきました。スキャナは毎日来ているのに、なぜか記録が少ない。 調べると、存在しないパスが 301 → 200 を返していました。 攻撃者に「無い」と教えない設定は、自分にも同じだけ教えてくれなくなる——その話です。

Apache mod_rewrite WordPress 404 / 410 SEO

[ § 0 ] 導入

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です。

[ § 1 ] 診断

まず、確かめ方

自分のサイトがどう返しているかは、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 のようにまず衝突しない名前を使ってください。
[ § 2 ] 原因

犯人は、catch-all

CMS を使っていると、.htaccess にこういう行が入ります。

.htaccess — CMSの定番
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 の副産物です。うちもそうでした。誰も決めていないのに、そうなっていた。

[ § 3 ] 実害

何が壊れていたか

① 計測が壊れる

これが最初に気づいた実害でした。修正したところ、404の記録が桁違いに増えました。 攻撃が増えたのではありません。それまで 200 に化けていた探索が、正しく数えられるようになっただけです。

正確な増加率は出せません。 集計を時間帯ごとの累積で持っているため、修正前後の厳密な差分が切り出せませんでした。 言えるのは「桁が変わった」ということと、 404のパス一覧に、それまで一度も現れなかった種類のものが並ぶようになったことです。 数えられないことを、数えられたようには書けません。

② 探索が、正常アクセスに紛れる

200 で記録されるということは、アクセス解析でも正常な閲覧として集計されるということです。 PV が水増しされ、bot 判定もすり抜けます。 「何を探されているか」を知る手がかりが、丸ごと失われていました。

③ クローラが、諦めない

404 は検索エンジンに対して「ここには何も無い、もう来なくていい」という合図です。 200 を返せば逆の意味になります。実在するページとして扱われ、再訪を招く。 Google はソフト404を明確に問題として扱っており、クロール予算の浪費や重複コンテンツ扱いにつながります。

④ 自分のリンク切れも見えない

攻撃者の探索だけの話ではありません。自サイト内のリンク切れも200になります。 記事を移動したときの張り忘れ、画像の消し忘れ——本来なら404の急増で気づけるものが、気づけなくなる。

⑤ 監視が効かない

ステータスコードで死活監視をしている場合、何を叩いても200が返る状態では監視になりません。 「サイトは生きている」と報告し続けます。

[ § 4 ] 誤解

ステータスと、中身は別

ソフト404を擁護する意見でよく聞くのが「404だと素っ気ないから、親切なページを見せたい」です。 これは理由になりません

404 を返しながら、親切なページを表示できます。 ステータスコードは機械への通知、ページの中身は人間への案内です。両者は独立しています。

やっていいこと / いけないこと
# ○ 404 を返しつつ、中身は充実させる
ErrorDocument 404 /404.html      # 検索窓・人気記事・サイトマップへの導線

# ✕ トップページへ飛ばす(これがソフト404を作る)
ErrorDocument 404 /
RedirectMatch 404 ^/.*$ /
ErrorDocument にはパスを書く。 ErrorDocument 404 https://example.jp/404.html のように絶対URLを書くと302になります。 同一サーバー内なら /404.html のようにパスで指定してください。 これも意図せずソフト404を作る原因のひとつです。
[ § 5 ] 修正

直す — 設定

実在しない .php は、その場で404にする

スキャナが叩いてくるのは大半が .php です。ここだけでも catch-all に渡す前に止められます。

.htaccess — 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 を返す価値があります。

.htaccess — 削除済みURLに 410
RedirectMatch 410 ^/old-campaign-2019/.*$
移転したものは、個別に 301。 「消えたページを全部トップへ301」は、実質ソフト404です。 移転先が具体的にあるものだけ301し、無いものは素直に404か410を返す—— 対応関係があるかどうかが判断の分かれ目になります。
[ § 6 ] 例外

「隠す」が正当な場面もある

ここまで「正しく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。 いずれにせよ「存在しないものを在るように見せる」理由にはならない——そこが今回の主題でした。

[ § 7 ] 体感デモ

リクエストは、どこを通るか

パスと設定を選ぶと、リクエストが mod_rewrite → CMS をどう通り、 最終的に何を返すかが分かります。同じリクエストがログにどう記録されるかも並べました。

Interactive · soft 404 diagnosis
経路と、記録のされ方
CMS は「404テンプレートを返せていない」状態(=ソフト404が起きている状態)を想定しています。
リクエストされたパス
最終ステータス
判定

/wxfyf.php を A と B で見比べてください。A では 301 を挟んで 200、 B では即座に 404 です。一方 /2026/07/存在しない記事/ は、 B にしても 200 のまま——ここは CMS 側が404を返せるかの問題で、 Apache の設定では届きません。直せる範囲を正しく知っておくことも設定の一部です。

[ § 8 ] まとめ

隠す設定は、自分からも隠す

ソフト404の擁護論に「攻撃者に存在を教えない」があります。ですが応答サイズや本文の違いで、 機械には簡単に見分けられます。徹底するのは難しいのに、副作用は全方位に及ぶ。 そして何より、攻撃者から隠れた情報は、自分からも同じだけ隠れます。 「相手には見えず、自分には見える」という都合のいい状態は作れません。

この記事のまとめ

  • curl -sIL最終的な応答を確認する。200 ならソフト404。
  • 原因の多くは catch-all の副産物。意図して選んだ設定ではないことが多い。
  • 実害は5つ:計測・解析・クロール・リンク切れ検出・監視がまとめて壊れる。
  • ステータスと中身は別。404を返しながら親切なページは出せる。
  • 実在しない .php は catch-all ので 404 にする。順序が命。
  • ただし一般パスは CMS 側の対応が必要。Apache だけでは直らない。
  • 消したものは 410、移転は個別に301。全部トップへ、は実質ソフト404。
  • 「実在するものを隠す404」は別問題。隠したいのが存在か中身かで決める。

関連記事

『404を、正しく返す — ソフト404が壊していたもの』を公開しました。